diff --git a/packages/start/src/config/env/fs-ops.ts b/packages/start/src/config/env/fs-ops.ts new file mode 100644 index 0000000000..620fda3ee0 --- /dev/null +++ b/packages/start/src/config/env/fs-ops.ts @@ -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 = `/// ` + +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) +} diff --git a/packages/start/src/config/env/plugin.ts b/packages/start/src/config/env/plugin.ts new file mode 100644 index 0000000000..920a95c0cd --- /dev/null +++ b/packages/start/src/config/env/plugin.ts @@ -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(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 | 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 | 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 + }, + } +} diff --git a/packages/start/src/config/env/schema.ts b/packages/start/src/config/env/schema.ts new file mode 100644 index 0000000000..e3b7142105 --- /dev/null +++ b/packages/start/src/config/env/schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' + +const AccessSchema = z.enum(['client', 'server']) +export type ValidAccessSchema = z.infer + +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 + +const BooleanFieldSchema = z.object({ + type: z.literal('boolean'), + context: AccessSchema, + optional: z.boolean().optional(), + default: z.boolean().optional(), +}) +export type ValidBooleanFieldSchema = z.infer + +export const EnvFieldUnion = z.union([StringFieldSchema, BooleanFieldSchema]) +export type ValidEnvFieldUnion = z.infer + +export const stringEnv = ( + input: Omit, 'type'>, +): z.input => ({ ...input, type: 'string' }) + +export const booleanEnv = ( + input: Omit, 'type'>, +): z.input => ({ ...input, type: 'boolean' }) diff --git a/packages/start/src/config/env/templates.ts b/packages/start/src/config/env/templates.ts new file mode 100644 index 0000000000..7a7cc82795 --- /dev/null +++ b/packages/start/src/config/env/templates.ts @@ -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 + 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` diff --git a/packages/start/src/config/env/validators.ts b/packages/start/src/config/env/validators.ts new file mode 100644 index 0000000000..66e269da9a --- /dev/null +++ b/packages/start/src/config/env/validators.ts @@ -0,0 +1,130 @@ +import { buildTypeAnnotation } from './templates.js' +import { writeEnvDTSFile } from './fs-ops.js' +import type { + ValidAccessSchema, + ValidEnvFieldUnion, + ValidStringFieldSchema, +} from './schema.js' + +type EnvValidationResult = + | { ok: true; value: ValidEnvFieldUnion['default'] } + | { ok: false; error: string } +type EnvValueValidator = (value: unknown) => EnvValidationResult + +function stringValidator(input: ValidStringFieldSchema): EnvValueValidator { + return (value) => { + if (typeof value !== 'string') { + return { ok: false, error: 'Expected a string' } + } + + if (input.minLength !== undefined && !(value.length >= input.minLength)) { + console.log('value', value, value.length, input.minLength) + return { + ok: false, + error: `String is less than ${input.minLength} chars`, + } + } + + if (input.maxLength !== undefined && !(value.length <= input.maxLength)) { + return { + ok: false, + error: `String is more than ${input.maxLength} chars`, + } + } + + return { ok: true, value } + } +} + +function booleanValidator(): EnvValueValidator { + return (value) => { + if (typeof value !== 'boolean') { + return { ok: false, error: 'Expected a boolean' } + } + + return { ok: true, value } + } +} + +function getEnvValidator(schema: ValidEnvFieldUnion): EnvValueValidator { + switch (schema.type) { + case 'string': { + return stringValidator(schema) + } + case 'boolean': { + return booleanValidator() + } + default: { + throw new Error( + `Encountered an invalid env schema type: ${(schema as any)?.type}`, + ) + } + } +} + +export function validateEnvVariables(options: { + variables: Record + schema: Record + write: boolean + root: string +}): Array<{ + key: string + value: unknown + typeAnnotation: string + context: ValidAccessSchema +}> { + const accepted: Array<{ + key: string + value: unknown + typeAnnotation: string + context: ValidAccessSchema + }> = [] + const rejected: Array<{ key: string; error: string }> = [] + + for (const [key, schema] of Object.entries(options.schema)) { + let preValue: any = + options.variables[key] === '' ? undefined : options.variables[key] + + if (preValue === undefined && schema.default !== undefined) { + preValue = schema.default + } + + if (preValue === undefined && !schema.optional) { + rejected.push({ + key, + error: 'Missing required environment variable', + }) + continue + } + + const validator = getEnvValidator(schema) + const validatedResult = validator(preValue) + + const typeAnnotation = buildTypeAnnotation(schema) + + if (!validatedResult.ok) { + rejected.push({ key, error: validatedResult.error }) + } else { + accepted.push({ + key, + value: validatedResult.value, + typeAnnotation, + context: schema.context, + }) + } + } + + if (rejected.length > 0) { + throw new Error( + `Encountered ${rejected.length} errors while validating environment variables:\n${rejected + .map(({ key, error }) => ` - ${key}: ${error}`) + .join('\n')}`, + ) + } + + if (options.write) { + writeEnvDTSFile({ accepted, root: options.root }) + } + + return accepted +} diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 94bfe2e29f..314bc14e76 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -28,6 +28,13 @@ import { serverFunctions } from '@vinxi/server-functions/plugin' // @ts-expect-error import { serverTransform } from '@vinxi/server-functions/server' import { z } from 'zod' +import { + PUBLIC_TANSTACK_START_DTS_FILENAME, + TANSTACK_DIR_NAME, + setupFrameworkTypesFile, +} from './setup-fw-types.js' +import { envValidationSchema, tsrValidateEnvPlugin } from './env/plugin.js' +import { booleanEnv, stringEnv } from './env/schema.js' import type { AppOptions as VinxiAppOptions, RouterSchemaInput as VinxiRouterSchemaInput, @@ -38,6 +45,8 @@ import type { NitroOptions } from 'nitropack' import type { CustomizableConfig } from 'vinxi/dist/types/lib/vite-dev' +export const envValue = { string: stringEnv, boolean: booleanEnv } + type RouterType = 'client' | 'server' | 'ssr' | 'api' type StartUserViteConfig = CustomizableConfig | (() => CustomizableConfig) @@ -213,7 +222,7 @@ const routersSchema = z.object({ }) const tsrConfig = configSchema.partial().extend({ - appDirectory: z.string().optional(), + appDirectory: z.string().default('./app').optional(), }) const inlineConfigSchema = z.object({ @@ -222,6 +231,11 @@ const inlineConfigSchema = z.object({ tsr: tsrConfig.optional(), routers: routersSchema.optional(), server: serverSchema.optional(), + env: z + .object({ + schema: envValidationSchema.optional(), + }) + .optional(), }) export type TanStackStartInputConfig = z.input @@ -273,6 +287,8 @@ function mergeSsrOptions(options: Array) { export function defineConfig(inlineConfig: TanStackStartInputConfig = {}) { const opts = inlineConfigSchema.parse(inlineConfig) + const root = process.cwd() + const { preset: configDeploymentPreset, ...serverOptions } = serverSchema.parse(opts.server || {}) @@ -299,6 +315,15 @@ export function defineConfig(inlineConfig: TanStackStartInputConfig = {}) { const publicDir = opts.routers?.public?.dir || './public' const publicBase = opts.routers?.public?.base || '/' + const tanstackFolderExists = existsSync(path.join(root, TANSTACK_DIR_NAME)) + const tanstackDTsFileExists = existsSync( + path.join(appDirectory, PUBLIC_TANSTACK_START_DTS_FILENAME), + ) + + if (!tanstackDTsFileExists || !tanstackFolderExists) { + setupFrameworkTypesFile({ root, appDirectory }) + } + return createApp({ server: { ...serverOptions, @@ -328,6 +353,10 @@ export function defineConfig(inlineConfig: TanStackStartInputConfig = {}) { sourcemap: true, }, plugins: () => [ + tsrValidateEnvPlugin({ + configOptions: { ...opts, tsr }, + root, + }), ...(getUserConfig(opts.vite).plugins || []), ...(getUserConfig(opts.routers?.client?.vite).plugins || []), serverFunctions.client({ @@ -345,6 +374,10 @@ export function defineConfig(inlineConfig: TanStackStartInputConfig = {}) { ...(apiEntryExists ? [ withPlugins([ + tsrValidateEnvPlugin({ + configOptions: { ...opts, tsr }, + root, + }), config('start-vite', { ...getUserConfig(opts.vite).userConfig, ...getUserConfig(opts.routers?.api?.vite).userConfig, @@ -401,6 +434,10 @@ export function defineConfig(inlineConfig: TanStackStartInputConfig = {}) { target: 'server', handler: ssrEntry, plugins: () => [ + tsrValidateEnvPlugin({ + configOptions: { ...opts, tsr }, + root, + }), tsrRoutesManifest({ tsrConfig, clientBase, @@ -432,6 +469,11 @@ export function defineConfig(inlineConfig: TanStackStartInputConfig = {}) { // worker: true, handler: importToProjectRelative('@tanstack/start/server-handler'), plugins: () => [ + tsrValidateEnvPlugin({ + configOptions: { ...opts, tsr }, + root, + write: true, + }), serverFunctions.server({ runtime: '@tanstack/start/react-server-runtime', // TODO: RSCS - remove this diff --git a/packages/start/src/config/setup-fw-types.ts b/packages/start/src/config/setup-fw-types.ts new file mode 100644 index 0000000000..af80b3f5a9 --- /dev/null +++ b/packages/start/src/config/setup-fw-types.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs' +import path from 'node:path' + +export const TANSTACK_DIR_NAME = '.tanstack' +export const PUBLIC_TANSTACK_START_DTS_FILENAME = 'tanstack-start.d.ts' + +export const INTERNAL_START_TYPES_FILENAME = 'start-types.d.ts' + +const TANSTACK_START_DTS_TEMPLATE = `/// ` + +export function setupFrameworkTypesFile(opts: { + root: string + appDirectory: string +}) { + const tanstackDir = path.join(opts.root, TANSTACK_DIR_NAME) + const frameworkDTSPath = path.join( + opts.appDirectory, + PUBLIC_TANSTACK_START_DTS_FILENAME, + ) + + // Create the .tanstack directory if it doesn't exist + if (!fs.existsSync(tanstackDir)) { + fs.mkdirSync(tanstackDir) + } + + // Create the internal .tanstack/start-types.d.ts file if it doesn't exist + const internalRootExport = path.join( + tanstackDir, + INTERNAL_START_TYPES_FILENAME, + ) + if (!fs.existsSync(internalRootExport)) { + fs.writeFileSync(internalRootExport, '', { encoding: 'utf-8' }) + } + + // Create the tanstack-start.d.ts file if it doesn't exist + const frameworkDTSExists = fs.existsSync(frameworkDTSPath) + if (!frameworkDTSExists) { + fs.writeFileSync(frameworkDTSPath, `${TANSTACK_START_DTS_TEMPLATE}\n`, { + encoding: 'utf-8', + }) + } + + handleGitIgnore({ root: opts.root }) +} + +function handleGitIgnore({ root }: { root: string }) { + const gitIgnorePath = path.join(root, '.gitignore') + if (!fs.existsSync(gitIgnorePath)) { + return + } + + const gitIgnoreLines = fs + .readFileSync(gitIgnorePath, { encoding: 'utf-8' }) + .split('\n') + + // Check if the .tanstack directory is already in the .gitignore file + const tanstackFolderIndex = gitIgnoreLines.some((line) => + line.includes(TANSTACK_DIR_NAME), + ) + + // If the .tanstack folder is already in the .gitignore file, return + if (tanstackFolderIndex) { + return + } + + // Add the .tanstack directory to the .gitignore file + fs.appendFileSync(gitIgnorePath, `${TANSTACK_DIR_NAME}\n`, { + encoding: 'utf-8', + }) +} diff --git a/packages/start/src/config/utils.ts b/packages/start/src/config/utils.ts new file mode 100644 index 0000000000..609ed06d9f --- /dev/null +++ b/packages/start/src/config/utils.ts @@ -0,0 +1,5 @@ +import { join } from 'node:path/posix' + +export function getTanStackStartHiddenFolder(root: string) { + return join(root, '.tanstack') +}