Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/guide/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
47 changes: 46 additions & 1 deletion packages/void/src/plugin-env-schema.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -57,12 +57,24 @@ async function loadSchemaModule(): Promise<RegisteredSchemaModule> {
/** 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<string> = ['VITE_'];

function resolveEnvPrefixes(prefix: ResolvedConfig['envPrefix']): ReadonlyArray<string> {
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<string, string>();

async function emitDefaults() {
if (!envFile || !options.onDefaults) {
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down Expand Up @@ -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' });
}
});
},
Expand Down
70 changes: 70 additions & 0 deletions packages/void/test/integration/plugin-env-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});