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')
+}