Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

idea exploration for validated env vars in Start #2711

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
122 changes: 122 additions & 0 deletions packages/start/src/config/env/fs-ops.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import path from 'node:path'
import fs from 'node:fs'
import {
INTERNAL_START_TYPES_FILENAME,
TANSTACK_DIR_NAME,
} from '../setup-fw-types.js'
import { GET_ENV_INTERNAL_TEMPLATE } from './templates.js'
import type { ValidAccessSchema } from './schema.js'

const ENV_FILE_REFERENCE = `/// <reference path="start-env/env.d.ts" />`

const START_ENV_DIR = 'start-env'
const ENV_FILENAME = 'env.d.ts'

export function writeEnvDTSFile(options: {
root: string
accepted: Array<{
key: string
context: ValidAccessSchema
typeAnnotation: string
}>
}) {
const tanstackDir = path.join(options.root, TANSTACK_DIR_NAME)
const internalStartDtsFile = path.join(
tanstackDir,
INTERNAL_START_TYPES_FILENAME,
)

const startEnvDir = path.join(options.root, TANSTACK_DIR_NAME, START_ENV_DIR)

// make the start-env directory inside the .tanstack directory
if (!fs.existsSync(startEnvDir)) {
fs.mkdirSync(startEnvDir)
}

const envDtsFile = path.join(startEnvDir, ENV_FILENAME)

const clientContent = options.accepted
.filter((v) => v.context === 'client')
.map((v) => ` export const ${v.key}: ${v.typeAnnotation};`)
.join('\n')
const serverContent = options.accepted
.filter((v) => v.context === 'server')
.map((v) => ` export const ${v.key}: ${v.typeAnnotation};`)
.join('\n')

const template = GET_ENV_INTERNAL_TEMPLATE({
client: clientContent,
server: serverContent,
})

const existingTypesContent = fs.existsSync(envDtsFile)

// if the .tanstack/start-env/env.d.ts file doesn't exist, create it
// or if the .tanstack/start-env/env.d.ts file exists, check if the content is different, and update if needed
if (!existingTypesContent) {
fs.writeFileSync(envDtsFile, template, { encoding: 'utf-8' })
} else {
const content = fs.readFileSync(envDtsFile, 'utf-8')
if (content !== template) {
fs.writeFileSync(envDtsFile, template, { encoding: 'utf-8' })
}
}

// Check if the .tanstack/start-types.d.ts file has a reference to the env types
const startRootReferencesLines = fs
.readFileSync(internalStartDtsFile, 'utf-8')
.split('\n')
const hasReferenceToEnvTypes = startRootReferencesLines.some((line) =>
line.includes(ENV_FILE_REFERENCE),
)

if (!hasReferenceToEnvTypes) {
fs.writeFileSync(
internalStartDtsFile,
`${ENV_FILE_REFERENCE}\n${startRootReferencesLines.join('\n')}`,
{ encoding: 'utf-8' },
)
}
}

export function cleanupEnvDTSFile(options: { root: string }) {
const tanstackDir = path.join(options.root, TANSTACK_DIR_NAME)
const internalStartDtsFile = path.join(
tanstackDir,
INTERNAL_START_TYPES_FILENAME,
)

// If the root .tanstack/start-types.d.ts file doesn't exist, return
if (!fs.existsSync(internalStartDtsFile)) {
return
}

// Check if the .tanstack/start-types.d.ts file has a reference to the env types, if not, return
const internalStartReferencesLines = fs
.readFileSync(internalStartDtsFile, 'utf-8')
.split('\n')
if (
!internalStartReferencesLines.some((line) =>
line.includes(ENV_FILE_REFERENCE),
)
) {
return
}

// Remove the reference to the env types from the .tanstack/start-types.d.ts file
const newContent = internalStartReferencesLines
.filter((line) => !line.includes(ENV_FILE_REFERENCE))
.join('\n')
fs.writeFileSync(internalStartDtsFile, newContent, { encoding: 'utf-8' })

// Check if the .tanstack/start-env/env.d.ts file exists, if not, return
const startEnvDir = path.join(options.root, TANSTACK_DIR_NAME, START_ENV_DIR)
const envDtsFile = path.join(startEnvDir, ENV_FILENAME)
if (!fs.existsSync(envDtsFile)) {
return
}

// Remove the .tanstack/start-env/env.d.ts file
fs.unlinkSync(envDtsFile)
fs.rmdirSync(startEnvDir)
}
110 changes: 110 additions & 0 deletions packages/start/src/config/env/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import process from 'node:process'
import { z } from 'zod'
import { loadEnv } from 'vite'
import { EnvFieldUnion } from './schema.js'
import { validateEnvVariables } from './validators.js'
import {
ENV_MODULES_IDS,
ENV_MODULES_IDS_SET,
buildTemplates,
} from './templates.js'
import { cleanupEnvDTSFile } from './fs-ops.js'
import type { Plugin } from 'vite'

export const envValidationSchema = z.record(z.string(), EnvFieldUnion)

function resolveVirtualModuleId<T extends string>(id: T): `\0tss:${T}` {
return `\0tss:${id}`
}

/**
* TODO: Replace this with the correct Config type
* Currently, since we've got a major rewrite going on it makes it difficult to coordinate
* the types between the branches.
* When the start package is settled, we can use the correct Config type that's used when
* setting up the Start definedConfig function in the app.config.ts file.
*/
type StartEnvOptions = {
[key: string]: any
tsr?: {
[key: string]: any
appDirectory?: string
}
env?: {
[key: string]: any
schema?: z.output<typeof envValidationSchema> | undefined
}
}

const VITE_PLUGIN_BASE: Plugin = {
name: 'tanstack-start:env-plugin',
enforce: 'pre',
}

export function tsrValidateEnvPlugin(options: {
configOptions: StartEnvOptions
root: string
write?: boolean
}): Plugin {
const shouldWrite = options.write ?? false

if (
!options.configOptions.env?.schema ||
Object.keys(options.configOptions.env.schema).length === 0
) {
return {
...VITE_PLUGIN_BASE,
buildStart() {
if (shouldWrite) {
cleanupEnvDTSFile({ root: options.root })
}
},
}
}

const schema = options.configOptions.env.schema

let templates: ReturnType<typeof buildTemplates> | null = null

return {
...VITE_PLUGIN_BASE,
resolveId(id) {
if (ENV_MODULES_IDS_SET.has(id)) {
return resolveVirtualModuleId(id)
}
return undefined
},
load(id, _loadOptions) {
if (id === resolveVirtualModuleId(ENV_MODULES_IDS.server)) {
return templates?.server
}

if (id === resolveVirtualModuleId(ENV_MODULES_IDS.client)) {
return templates?.client
}

return undefined
},
buildStart() {
const runtimeEnv = loadEnv('development', options.root, '')

for (const [k, v] of Object.entries(runtimeEnv)) {
if (typeof v !== 'undefined') {
process.env[k] = v
}
}

const variables = validateEnvVariables({
root: options.root,
write: shouldWrite,
variables: runtimeEnv,
schema,
})

templates = buildTemplates({ schema, variables })
},
buildEnd() {
templates = null
},
}
}
33 changes: 33 additions & 0 deletions packages/start/src/config/env/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { z } from 'zod'

const AccessSchema = z.enum(['client', 'server'])
export type ValidAccessSchema = z.infer<typeof AccessSchema>

const StringFieldSchema = z.object({
type: z.literal('string'),
context: AccessSchema,
optional: z.boolean().optional(),
default: z.string().optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
})
export type ValidStringFieldSchema = z.infer<typeof StringFieldSchema>

const BooleanFieldSchema = z.object({
type: z.literal('boolean'),
context: AccessSchema,
optional: z.boolean().optional(),
default: z.boolean().optional(),
})
export type ValidBooleanFieldSchema = z.infer<typeof BooleanFieldSchema>

export const EnvFieldUnion = z.union([StringFieldSchema, BooleanFieldSchema])
export type ValidEnvFieldUnion = z.infer<typeof EnvFieldUnion>

export const stringEnv = (
input: Omit<z.input<typeof StringFieldSchema>, 'type'>,
): z.input<typeof StringFieldSchema> => ({ ...input, type: 'string' })

export const booleanEnv = (
input: Omit<z.input<typeof BooleanFieldSchema>, 'type'>,
): z.input<typeof BooleanFieldSchema> => ({ ...input, type: 'boolean' })
53 changes: 53 additions & 0 deletions packages/start/src/config/env/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ValidAccessSchema, ValidEnvFieldUnion } from './schema'

export const ENV_MODULES_IDS = {
client: '@tanstack/start/env/client',
server: '@tanstack/start/env/server',
}
export const ENV_MODULES_IDS_SET = new Set(Object.values(ENV_MODULES_IDS))

export function buildTemplates(options: {
schema: Record<string, ValidEnvFieldUnion>
variables: Array<{ key: string; value: any; context: ValidAccessSchema }>
}): { client: string; server: string } {
let client = ''
let server = ''

for (const { key, value, context } of options.variables) {
if (context === 'client') {
client += `export const ${key} = ${JSON.stringify(value)}\n`
} else {
server += `export const ${key} = ${JSON.stringify(value)}\n`
}
}

return {
client,
server,
}
}

export function buildTypeAnnotation(schema: ValidEnvFieldUnion) {
const type = schema.type
const isOptional = schema.optional
? schema.default !== undefined
? false
: true
: false

return `${type}${isOptional ? ' | undefined' : ''}`
}

export const GET_ENV_INTERNAL_TEMPLATE = ({
client,
server,
}: {
client: string
server: string
}) => `declare module '@tanstack/start/env/client' {
${client}
}

declare module '@tanstack/start/env/server' {
${server}
}\n`
Loading