diff --git a/docs/guide/env-vars.md b/docs/guide/env-vars.md index 86a016601..ee04a0060 100644 --- a/docs/guide/env-vars.md +++ b/docs/guide/env-vars.md @@ -150,13 +150,16 @@ Remote secrets count as "present" for deploy validation — `void deploy` checks ## Defaults -Schema defaults from `defineEnv({ PORT: number().default(3000) })` flow into both: +Schema defaults from `defineEnv({ PORT: number().default(3000) })` flow into: - The typed `env` proxy: `env.PORT` returns `3000` when no value is set. - Worker bindings (`c.env.PORT`, `process.env.PORT` on Node target): the default value is injected as a stringified `var` in dev, in `void deploy` manifests, and in prerender bindings, so any code path that reads the raw env sees the same fallback. +- Client-exposed keys (`VITE_*` by default, or your configured `envPrefix`): + defaults are added to Vite's client env, so browser code reading + `env.VITE_APP_TITLE` sees the same fallback. User-provided `.env` / shell values always win — defaults only fill gaps. diff --git a/packages/void/src/plugin-env-schema.ts b/packages/void/src/plugin-env-schema.ts index 6d23d3414..5680a587b 100644 --- a/packages/void/src/plugin-env-schema.ts +++ b/packages/void/src/plugin-env-schema.ts @@ -1,6 +1,6 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'pathe'; -import { type Plugin } from 'vite'; +import { type Plugin, type ResolvedConfig } from 'vite'; import { findEnvFile, generateEnvTypes } from './codegen/env-types.ts'; import { loadUserEnvFile } from './host/load-user-env.ts'; import { voidLog, voidWarn } from './log.ts'; @@ -57,12 +57,24 @@ async function loadSchemaModule(): Promise { /** Patterns Vite's `define` typically uses for env-shaped constants. */ const DEFINE_ENV_PATTERN = /^(?:globalThis\.)?(?:process\.env|import\.meta\.env)\.([A-Z_][A-Z0-9_]*)$/; +const DEFAULT_ENV_PREFIXES: ReadonlyArray = ['VITE_']; + +function resolveEnvPrefixes(prefix: ResolvedConfig['envPrefix']): ReadonlyArray { + if (typeof prefix === 'string') { + return [prefix]; + } + if (Array.isArray(prefix) && prefix.length > 0) { + return prefix; + } + return DEFAULT_ENV_PREFIXES; +} export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = {}): Plugin { const outDir = join(root, '.void'); let envFile: string | null = null; let viteMode = 'development'; let envDir = root; + const appliedClientDefaults = new Map(); async function emitDefaults() { if (!envFile || !options.onDefaults) { @@ -152,6 +164,36 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = voidWarn(lines.join('\n')); } + async function applyClientDefaultsToViteEnv(config: ResolvedConfig) { + if (!envFile) { + return; + } + + for (const [key, value] of appliedClientDefaults) { + if (config.env[key] === value) { + delete config.env[key]; + } + } + appliedClientDefaults.clear(); + + const { _hasRegisteredSchema, _getSchemaDefaults } = await loadSchemaModule(); + if (!_hasRegisteredSchema()) { + return; + } + + const prefixes = resolveEnvPrefixes(config.envPrefix); + for (const [key, value] of Object.entries(_getSchemaDefaults())) { + if (!prefixes.some((prefix) => key.startsWith(prefix))) { + continue; + } + if (config.env[key] !== undefined) { + continue; + } + config.env[key] = value; + appliedClientDefaults.set(key, value); + } + } + return { name: 'void:env-schema', async config() { @@ -170,6 +212,7 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = // `envDir: false` disables Vite's .env loading entirely — fall back to // root so our own validate-against-merged-env still picks up shell vars. envDir = typeof config.envDir === 'string' ? config.envDir : root; + await applyClientDefaultsToViteEnv(config); // Warn when vite `define` shadows a schema key. The two systems coexist // — define replaces source literals at build time; defineEnv types and // validates the runtime env — but using both for the same name causes @@ -199,7 +242,9 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = regenerateTypes(); await importUserEnv(); await emitDefaults(); + await applyClientDefaultsToViteEnv(server.config); await validateAndWarn(); + server.ws.send({ type: 'full-reload' }); } }); }, diff --git a/packages/void/test/integration/plugin-env-schema.test.ts b/packages/void/test/integration/plugin-env-schema.test.ts index da0bd6d98..52673ef33 100644 --- a/packages/void/test/integration/plugin-env-schema.test.ts +++ b/packages/void/test/integration/plugin-env-schema.test.ts @@ -186,4 +186,74 @@ describe('envSchemaPlugin auto-discovery', () => { const joined = calls.join('\n'); expect(joined).not.toContain('shadowing schema env keys'); }); + + it('adds client-exposed schema defaults to Vite env', async () => { + const { defineEnv } = await import('../../src/runtime/env-public.ts'); + const { number, string } = await import('../../src/runtime/env-helpers.ts'); + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + + const plugin = envSchemaPlugin(root); + await runConfig(plugin); + defineEnv({ VITE_GIT_SHA: string().default('abc123'), PORT: number().default(3000) }); + + const resolved = { + mode: 'development', + envDir: root, + envPrefix: 'VITE_', + env: {}, + define: {}, + }; + await runConfigResolved(plugin, resolved); + + expect(resolved.env).toEqual({ VITE_GIT_SHA: 'abc123' }); + expect('PORT' in resolved.env).toBe(false); + }); + + it('does not overwrite a Vite env value with a schema default', async () => { + const { defineEnv } = await import('../../src/runtime/env-public.ts'); + const { string } = await import('../../src/runtime/env-helpers.ts'); + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + + const plugin = envSchemaPlugin(root); + await runConfig(plugin); + defineEnv({ VITE_TITLE: string().default('Default') }); + + const resolved = { + mode: 'development', + envDir: root, + envPrefix: 'VITE_', + env: { VITE_TITLE: 'FromEnv' }, + define: {}, + }; + await runConfigResolved(plugin, resolved); + + expect(resolved.env.VITE_TITLE).toBe('FromEnv'); + }); + + it('honours custom envPrefix values when exposing schema defaults', async () => { + const { defineEnv } = await import('../../src/runtime/env-public.ts'); + const { string } = await import('../../src/runtime/env-helpers.ts'); + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + + const plugin = envSchemaPlugin(root); + await runConfig(plugin); + defineEnv({ + PUBLIC_TITLE: string().default('Public'), + VITE_TITLE: string().default('Vite'), + }); + + const resolved = { + mode: 'development', + envDir: root, + envPrefix: ['PUBLIC_'], + env: {}, + define: {}, + }; + await runConfigResolved(plugin, resolved); + + expect(resolved.env).toEqual({ PUBLIC_TITLE: 'Public' }); + }); });