Skip to content
Closed
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@typescript/native-preview": "catalog:",
"cross-spawn": "^7.0.6",
"eslint-plugin-react-hooks": "^7.1.1",
"pathslash": "catalog:",
"playwright": "^1.61.1",
"rolldown": "^1.1.4",
"vite-plus": "catalog:",
Expand Down
22 changes: 17 additions & 5 deletions packages/void/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ export function voidPlugin(options?: {
const dialect = getDatabaseDialect(config);
const detected = detectFramework(root);
const framework = detected?.class === 'a' ? (detected.name as Framework) : undefined;
const initialFrameworkShellEnv = framework ? { ...process.env } : undefined;

// Infer bindings synchronously from source files
const scanDirs = config.inference?.scanDirs ?? (detected ? FRAMEWORK_SCAN_DIRS : undefined);
Expand Down Expand Up @@ -923,7 +924,7 @@ export function voidPlugin(options?: {
// apply equally to SvelteKit/Nuxt/Analog/Astro. We intentionally omit
// envPlugin itself — it mutates workerConfig.vars for Void's own CF
// build, which Class B/C frameworks don't go through.
envSchemaPlugin(root),
envSchemaPlugin(root, { frameworkOwnsRuntimeEnv: true }),
envClientGuardPlugin(root),
envClientFoldPlugin(),
drizzlePlugin(root, false, dialect, {
Expand Down Expand Up @@ -1311,6 +1312,7 @@ export function voidPlugin(options?: {
// loaded env from the value it saw — the preflight reading the final value would then diverge
// from what the build baked.
let voidDeployBuildConfig: { mode: string; envDir: string | undefined } | undefined;
let loadedUserEnv: Record<string, string> | undefined;
const envPlugin: Plugin = {
name: 'void:env',
api: {
Expand Down Expand Up @@ -1353,8 +1355,12 @@ export function voidPlugin(options?: {
// .env-file vars reach worker bindings. stripInternalEnvKeys then
// removes any internal Void keys a user may have set in their .env
// files, so they can't hijack credential-shaped worker bindings.
const filtered = filterLoadedEnv(loadEnv(mode, envDir, ''));
const filtered = filterLoadedEnv(
loadEnv(mode, envDir, ''),
initialFrameworkShellEnv ?? process.env,
);
const { sanitized: env, stripped } = stripInternalEnvKeys(filtered);
loadedUserEnv = env;
if (stripped.length > 0) {
voidWarn(
`Ignoring internal env keys from .env files: ${stripped.join(', ')}. These are reserved for Void runtime use.`,
Expand Down Expand Up @@ -1649,7 +1655,10 @@ export function voidPlugin(options?: {
restartPlugin(),
bindingWatchPlugin,
envPlugin,
envSchemaPlugin(root, { onDefaults: applySchemaDefaultsToWorkerVars }),
envSchemaPlugin(root, {
onDefaults: applySchemaDefaultsToWorkerVars,
getValidationEnv: () => loadedUserEnv,
}),
envClientGuardPlugin(root),
envClientFoldPlugin(),
clientStubsPlugin,
Expand Down Expand Up @@ -1714,7 +1723,7 @@ export function voidPlugin(options?: {
return [
restartPlugin(),
envPlugin,
envSchemaPlugin(root),
envSchemaPlugin(root, { getValidationEnv: () => loadedUserEnv }),
...pagesDevCssAssetsPlugin(root, devTriggerToken),
routingPlugin(root, {
isAuthEnabled: () => false,
Expand Down Expand Up @@ -1820,7 +1829,10 @@ export function voidPlugin(options?: {
restartPlugin(),
bindingWatchPlugin,
envPlugin,
envSchemaPlugin(root, { onDefaults: applySchemaDefaultsToWorkerVars }),
envSchemaPlugin(root, {
onDefaults: applySchemaDefaultsToWorkerVars,
getValidationEnv: () => loadedUserEnv,
}),
envClientGuardPlugin(root),
envClientFoldPlugin(),
clientStubsPlugin,
Expand Down
29 changes: 25 additions & 4 deletions packages/void/src/plugin-env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ export type EnvSchemaPluginOptions = {
* "skip if already set" semantics.
*/
onDefaults?: (defaults: Record<string, string>) => void;
/**
* Returns the sanitized dotenv values already loaded by Void's env plugin.
* Using the original snapshot keeps validation aligned with worker vars even
* when a later plugin copies those values into process.env.
*/
getValidationEnv?: () => Record<string, string> | undefined;
/**
* Class B/C frameworks own their runtime env and may copy dotenv values
* into process.env before this plugin validates. When true, preserve values
* added after plugin construction while still excluding the initial shell.
*
* Leave false when Void owns worker vars so validation uses the same
* current-process filtering as the worker binding path.
*/
frameworkOwnsRuntimeEnv?: boolean;
};

let cachedSchemaModule: RegisteredSchemaModule | null = null;
Expand Down Expand Up @@ -71,6 +86,7 @@ function resolveEnvPrefixes(prefix: ResolvedConfig['envPrefix']): ReadonlyArray<
}

export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = {}): Plugin {
const initialShellEnv = options.frameworkOwnsRuntimeEnv ? { ...process.env } : undefined;
const outDir = join(root, '.void');
let envFile: string | null = null;
let viteMode = 'development';
Expand Down Expand Up @@ -150,14 +166,19 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions =
return;
}
const { loadEnv } = await import('vite');
const merged = filterLoadedEnv(loadEnv(viteMode, envDir, ''));
const report = await validateAllEnv(merged as Record<string, unknown>);
// Framework-owned runtimes retain dotenv values copied into process.env
// after plugin construction. Void-owned runtimes validate the exact
// sanitized dotenv snapshot used for worker vars.
const merged =
options.getValidationEnv?.() ??
filterLoadedEnv(loadEnv(viteMode, envDir, ''), initialShellEnv ?? process.env);
const report = await validateAllEnv(merged);
if (report.valid) {
return;
}
const lines: Array<string> = ['Env validation issues:'];
for (const key of report.missing) {
lines.push(` • ${key} is required but missing — add it to .env.local or set in your shell`);
lines.push(` • ${key} is required but missing — add it to .env.local`);
}
for (const { key, messages } of report.invalid) {
lines.push(` • ${key}: ${messages.join('; ')}`);
Expand Down Expand Up @@ -211,7 +232,7 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions =
async configResolved(config) {
viteMode = config.mode;
// `envDir: false` disables Vite's .env loading entirely — fall back to
// root so our own validate-against-merged-env still picks up shell vars.
// root so our own validation can still load project dotenv files.
envDir = typeof config.envDir === 'string' ? config.envDir : root;
await applyClientDefaultsToViteEnv(config);
// Warn when vite `define` shadows a schema key. The two systems coexist
Expand Down
9 changes: 6 additions & 3 deletions packages/void/src/shared/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
* shipping the developer's shell environment (PATH, HOME, etc.) into
* `wrangler.json` or the prerender Miniflare instance.
*/
export function filterLoadedEnv(loaded: Record<string, string>): Record<string, string> {
const shellKeys = new Set(Object.keys(process.env));
export function filterLoadedEnv(
loaded: Record<string, string>,
shellEnv: Readonly<Record<string, string | undefined>> = process.env,
): Record<string, string> {
const shellKeys = new Set(Object.keys(shellEnv));
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(loaded)) {
if (!shellKeys.has(key) || process.env[key] !== value) {
if (!shellKeys.has(key) || shellEnv[key] !== value) {
env[key] = value;
}
}
Expand Down
22 changes: 19 additions & 3 deletions packages/void/test/integration/analog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import spawn from 'cross-spawn';
import { join } from 'pathslash';
import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';
import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts';
import {
ENV_VALIDATION_TEST_KEY,
captureChildOutput,
getPort,
installEnvValidationFixture,
killChild,
waitForEnvValidation,
waitForServer,
} from '../../../../test/integration-helpers.ts';

const ROOT = join(import.meta.dirname, '..', '..', '..', '..');
const PLAYGROUND = 'playground/analog';
Expand Down Expand Up @@ -95,11 +103,14 @@ describe('playground/analog build', () => {

describe('playground/analog dev', () => {
let child: ReturnType<typeof spawn>;
let cleanupEnvFixture: (() => void) | undefined;
let DEV_PORT: number;
let DEV_BASE: string;
let readDevOutput = () => '';

beforeAll(async () => {
const pg = PLAYGROUND_ROOT;
cleanupEnvFixture = installEnvValidationFixture(pg);
rmSync(join(pg, '.void'), { recursive: true, force: true });
rmSync(join(pg, '.wrangler'), { recursive: true, force: true });
removeWranglerConfig(pg);
Expand Down Expand Up @@ -128,16 +139,21 @@ describe('playground/analog dev', () => {
},
},
);

child.stderr!.on('data', () => {});
readDevOutput = captureChildOutput(child);

await waitForServer(DEV_BASE, 30_000);
await waitForEnvValidation();
});

afterAll(async () => {
if (child) {
await killChild(child);
}
cleanupEnvFixture?.();
});

it('accepts required env values from .env.local without warning', () => {
expect(readDevOutput()).not.toContain(`${ENV_VALIDATION_TEST_KEY} is required but missing`);
});

it('syncs wrangler.jsonc with bindings from void.json', () => {
Expand Down
21 changes: 20 additions & 1 deletion packages/void/test/integration/astro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import { existsSync, readFileSync, rmSync } from 'node:fs';
import spawn from 'cross-spawn';
import { join } from 'pathslash';
import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';
import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts';
import {
ENV_VALIDATION_TEST_KEY,
captureChildOutput,
getPort,
installEnvValidationFixture,
killChild,
waitForEnvValidation,
waitForServer,
} from '../../../../test/integration-helpers.ts';

const ROOT = join(import.meta.dirname, '..', '..', '..', '..');
const PLAYGROUND = 'playground/astro';
Expand Down Expand Up @@ -47,6 +55,8 @@ describe('playground/astro build', () => {

describe('playground/astro dev', () => {
let child: ReturnType<typeof spawn>;
let cleanupEnvFixture: (() => void) | undefined;
const readDevOutputs: Array<() => string> = [];
let stderr = '';

const startDev = async () => {
Expand Down Expand Up @@ -75,6 +85,7 @@ describe('playground/astro dev', () => {
},
},
);
readDevOutputs.push(captureChildOutput(child));

child.stderr!.on('data', (chunk: Buffer) => {
stderr += String(chunk);
Expand All @@ -85,6 +96,7 @@ describe('playground/astro dev', () => {

beforeAll(async () => {
const pg = join(ROOT, PLAYGROUND);
cleanupEnvFixture = installEnvValidationFixture(pg);
rmSync(join(pg, '.void'), { recursive: true, force: true });
rmSync(join(pg, '.wrangler'), { recursive: true, force: true });
removeWranglerConfig(pg);
Expand All @@ -95,12 +107,19 @@ describe('playground/astro dev', () => {

// Second boot: adapter reads synced config so runtime bindings are available.
await startDev();
await waitForEnvValidation();
});

afterAll(async () => {
if (child) {
await killChild(child);
}
cleanupEnvFixture?.();
});

it('accepts required env values from .env.local without warning', () => {
const output = readDevOutputs.map((readOutput) => readOutput()).join('\n');
expect(output).not.toContain(`${ENV_VALIDATION_TEST_KEY} is required but missing`);
});

it('syncs wrangler config with compat flag and inferred bindings', () => {
Expand Down
20 changes: 19 additions & 1 deletion packages/void/test/integration/nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import { existsSync, readFileSync, rmSync } from 'node:fs';
import spawn from 'cross-spawn';
import { join } from 'pathslash';
import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';
import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts';
import {
ENV_VALIDATION_TEST_KEY,
captureChildOutput,
getPort,
installEnvValidationFixture,
killChild,
waitForEnvValidation,
waitForServer,
} from '../../../../test/integration-helpers.ts';

const ROOT = join(import.meta.dirname, '..', '..', '..', '..');
const PLAYGROUND = 'playground/nuxt';
Expand Down Expand Up @@ -38,11 +46,14 @@ describe(
'playground/nuxt dev',
() => {
let child: ReturnType<typeof spawn>;
let cleanupEnvFixture: (() => void) | undefined;
let readDevOutput = () => '';
let stderr = '';

beforeAll(
async () => {
const pg = join(ROOT, PLAYGROUND);
cleanupEnvFixture = installEnvValidationFixture(pg);
rmSync(join(pg, '.void'), { recursive: true, force: true });
rmSync(join(pg, '.wrangler'), { recursive: true, force: true });
removeWranglerConfig(pg);
Expand Down Expand Up @@ -71,13 +82,15 @@ describe(
},
},
);
readDevOutput = captureChildOutput(child);

child.stderr!.on('data', (chunk: Buffer) => {
stderr += String(chunk);
});

const serverTimeout = process.platform === 'win32' ? 60_000 : 30_000;
await waitForServer(DEV_BASE, serverTimeout);
await waitForEnvValidation();
},
process.platform === 'win32' ? 75_000 : 45_000,
);
Expand All @@ -86,6 +99,11 @@ describe(
if (child) {
await killChild(child);
}
cleanupEnvFixture?.();
});

it('accepts required env values from .env.local without warning', () => {
expect(readDevOutput()).not.toContain(`${ENV_VALIDATION_TEST_KEY} is required but missing`);
});

it('syncs wrangler.jsonc with bindings from void.json', () => {
Expand Down
Loading