From 54fa1234f9f1890ec4627b9a8cefee756eb7c86b Mon Sep 17 00:00:00 2001 From: mdong Date: Sun, 30 Aug 2026 16:27:01 +0800 Subject: [PATCH 1/5] test(framework): add integration env load test to framework --- package.json | 1 + packages/void/test/integration/analog.test.ts | 22 ++++++- packages/void/test/integration/astro.test.ts | 21 ++++++- packages/void/test/integration/nuxt.test.ts | 20 +++++- .../test/integration/react-router.test.ts | 20 +++++- .../void/test/integration/sveltekit.test.ts | 20 +++++- .../test/integration/tanstack-start.test.ts | 20 +++++- .../void/test/integration/vinext-app.test.ts | 61 ++++++++++++++++++- .../test/integration/vinext-pages.test.ts | 61 ++++++++++++++++++- pnpm-lock.yaml | 3 + test/integration-helpers.ts | 42 +++++++++++++ 11 files changed, 281 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 2aca8264b..277dc149e 100644 --- a/package.json +++ b/package.json @@ -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:", diff --git a/packages/void/test/integration/analog.test.ts b/packages/void/test/integration/analog.test.ts index e4e97117c..4bc4c2dfb 100644 --- a/packages/void/test/integration/analog.test.ts +++ b/packages/void/test/integration/analog.test.ts @@ -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'; @@ -95,11 +103,14 @@ describe('playground/analog build', () => { describe('playground/analog dev', () => { let child: ReturnType; + 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); @@ -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', () => { diff --git a/packages/void/test/integration/astro.test.ts b/packages/void/test/integration/astro.test.ts index bd4471621..75b2e15ac 100644 --- a/packages/void/test/integration/astro.test.ts +++ b/packages/void/test/integration/astro.test.ts @@ -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'; @@ -47,6 +55,8 @@ describe('playground/astro build', () => { describe('playground/astro dev', () => { let child: ReturnType; + let cleanupEnvFixture: (() => void) | undefined; + const readDevOutputs: Array<() => string> = []; let stderr = ''; const startDev = async () => { @@ -75,6 +85,7 @@ describe('playground/astro dev', () => { }, }, ); + readDevOutputs.push(captureChildOutput(child)); child.stderr!.on('data', (chunk: Buffer) => { stderr += String(chunk); @@ -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); @@ -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', () => { diff --git a/packages/void/test/integration/nuxt.test.ts b/packages/void/test/integration/nuxt.test.ts index a51bd158f..7cb93649a 100644 --- a/packages/void/test/integration/nuxt.test.ts +++ b/packages/void/test/integration/nuxt.test.ts @@ -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'; @@ -38,11 +46,14 @@ describe( 'playground/nuxt dev', () => { let child: ReturnType; + 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); @@ -71,6 +82,7 @@ describe( }, }, ); + readDevOutput = captureChildOutput(child); child.stderr!.on('data', (chunk: Buffer) => { stderr += String(chunk); @@ -78,6 +90,7 @@ describe( const serverTimeout = process.platform === 'win32' ? 60_000 : 30_000; await waitForServer(DEV_BASE, serverTimeout); + await waitForEnvValidation(); }, process.platform === 'win32' ? 75_000 : 45_000, ); @@ -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', () => { diff --git a/packages/void/test/integration/react-router.test.ts b/packages/void/test/integration/react-router.test.ts index fa2667ac9..c3516f012 100644 --- a/packages/void/test/integration/react-router.test.ts +++ b/packages/void/test/integration/react-router.test.ts @@ -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/react-router'; @@ -45,9 +53,12 @@ describe('playground/react-router build', () => { describe('playground/react-router dev', { retry: 2 }, () => { let child: ReturnType; + let cleanupEnvFixture: (() => void) | undefined; + let readDevOutput = () => ''; let stderr = ''; beforeAll(async () => { + cleanupEnvFixture = installEnvValidationFixture(join(ROOT, PLAYGROUND)); DEV_PORT = await getPort(); DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; @@ -72,23 +83,30 @@ describe('playground/react-router dev', { retry: 2 }, () => { }, }, ); + readDevOutput = captureChildOutput(child); child.stderr!.on('data', (chunk: Buffer) => { stderr += String(chunk); }); await waitForServer(DEV_BASE, 30_000); + await waitForEnvValidation(); }); afterAll(async () => { if (child) { await killChild(child); } + cleanupEnvFixture?.(); if (stderr.includes('error when starting dev server')) { throw new Error(stderr); } }); + it('accepts required env values from .env.local without warning', () => { + expect(readDevOutput()).not.toContain(`${ENV_VALIDATION_TEST_KEY} is required but missing`); + }); + it('renders home page with SSR', async () => { const response = await fetch(`${DEV_BASE}/`); expect(response.status).toBe(200); diff --git a/packages/void/test/integration/sveltekit.test.ts b/packages/void/test/integration/sveltekit.test.ts index aa194d3ce..1279aa693 100644 --- a/packages/void/test/integration/sveltekit.test.ts +++ b/packages/void/test/integration/sveltekit.test.ts @@ -3,7 +3,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/sveltekit'; @@ -33,10 +41,13 @@ describe('playground/sveltekit build', () => { describe('playground/sveltekit dev', () => { let child: ReturnType; + 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 }); writeFileSync(join(pg, 'wrangler.jsonc'), MINIMAL_WRANGLER); @@ -65,23 +76,30 @@ describe('playground/sveltekit dev', () => { }, }, ); + readDevOutput = captureChildOutput(child); child.stderr!.on('data', (chunk: Buffer) => { stderr += String(chunk); }); await waitForServer(DEV_BASE, 20_000); + await waitForEnvValidation(); }); afterAll(async () => { if (child) { await killChild(child); } + cleanupEnvFixture?.(); if (stderr.includes('error when starting dev server')) { throw new Error(stderr); } }); + 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 inferred bindings', () => { const wranglerConfig = JSON.parse( readFileSync(join(ROOT, `${PLAYGROUND}/wrangler.jsonc`), 'utf-8'), diff --git a/packages/void/test/integration/tanstack-start.test.ts b/packages/void/test/integration/tanstack-start.test.ts index ca49d2755..7d2063384 100644 --- a/packages/void/test/integration/tanstack-start.test.ts +++ b/packages/void/test/integration/tanstack-start.test.ts @@ -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/tanstack-start'; @@ -44,9 +52,12 @@ describe('playground/tanstack-start build', () => { describe('playground/tanstack-start dev', () => { let child: ReturnType; + let cleanupEnvFixture: (() => void) | undefined; + let readDevOutput = () => ''; let stderr = ''; beforeAll(async () => { + cleanupEnvFixture = installEnvValidationFixture(join(ROOT, PLAYGROUND)); DEV_PORT = await getPort(); DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; @@ -71,23 +82,30 @@ describe('playground/tanstack-start dev', () => { }, }, ); + readDevOutput = captureChildOutput(child); child.stderr!.on('data', (chunk: Buffer) => { stderr += String(chunk); }); await waitForServer(DEV_BASE, 20_000); + await waitForEnvValidation(); }); afterAll(async () => { if (child) { await killChild(child); } + cleanupEnvFixture?.(); if (stderr.includes('error when starting dev server')) { throw new Error(stderr); } }); + it('accepts required env values from .env.local without warning', () => { + expect(readDevOutput()).not.toContain(`${ENV_VALIDATION_TEST_KEY} is required but missing`); + }); + it('renders home page with SSR', async () => { const response = await fetch(`${DEV_BASE}/`); expect(response.status).toBe(200); diff --git a/packages/void/test/integration/vinext-app.test.ts b/packages/void/test/integration/vinext-app.test.ts index ae04db3dd..bd58f0534 100644 --- a/packages/void/test/integration/vinext-app.test.ts +++ b/packages/void/test/integration/vinext-app.test.ts @@ -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/vinext-app'; @@ -106,3 +114,54 @@ describe.skip('playground/vinext-app dev', () => { expect(html).toContain('ok'); }); }); + +describe('playground/vinext-app env validation', () => { + let child: ReturnType; + let cleanupEnvFixture: (() => void) | undefined; + let readDevOutput = () => ''; + + beforeAll(async () => { + cleanupEnvFixture = installEnvValidationFixture(join(ROOT, PLAYGROUND)); + DEV_PORT = await getPort(); + DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; + + child = spawn( + 'vp', + [ + 'run', + '--filter', + `./${PLAYGROUND}`, + 'dev', + '--host', + '127.0.0.1', + '--port', + String(DEV_PORT), + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + readDevOutput = captureChildOutput(child); + + await waitForServer(DEV_BASE, 30_000, { + isAlive: () => child.exitCode === null && child.signalCode === null, + }); + 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`); + }); +}); diff --git a/packages/void/test/integration/vinext-pages.test.ts b/packages/void/test/integration/vinext-pages.test.ts index d44968666..b78bfdedf 100644 --- a/packages/void/test/integration/vinext-pages.test.ts +++ b/packages/void/test/integration/vinext-pages.test.ts @@ -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/vinext-pages'; @@ -106,3 +114,54 @@ describe.skip('playground/vinext-pages dev', () => { expect(html).toContain('ok'); }); }); + +describe('playground/vinext-pages env validation', () => { + let child: ReturnType; + let cleanupEnvFixture: (() => void) | undefined; + let readDevOutput = () => ''; + + beforeAll(async () => { + cleanupEnvFixture = installEnvValidationFixture(join(ROOT, PLAYGROUND)); + DEV_PORT = await getPort(); + DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; + + child = spawn( + 'vp', + [ + 'run', + '--filter', + `./${PLAYGROUND}`, + 'dev', + '--host', + '127.0.0.1', + '--port', + String(DEV_PORT), + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + readDevOutput = captureChildOutput(child); + + await waitForServer(DEV_BASE, 20_000, { + isAlive: () => child.exitCode === null && child.signalCode === null, + }); + 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`); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19fa62a3e..19fa5b720 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: eslint-plugin-react-hooks: specifier: ^7.1.1 version: 7.1.1(eslint@9.39.4(jiti@2.7.0)) + pathslash: + specifier: 'catalog:' + version: 0.1.0 playwright: specifier: ^1.61.1 version: 1.61.1 diff --git a/test/integration-helpers.ts b/test/integration-helpers.ts index abc3c9775..1cb822d53 100644 --- a/test/integration-helpers.ts +++ b/test/integration-helpers.ts @@ -1,6 +1,48 @@ import { execSync, type spawn } from 'node:child_process'; import { once } from 'node:events'; +import { existsSync, rmSync, writeFileSync } from 'node:fs'; import { createServer, type Server } from 'node:net'; +import { join } from 'pathslash'; + +export const ENV_VALIDATION_TEST_KEY = 'VOID_ENV_FRAMEWORK_TEST'; + +export function installEnvValidationFixture(root: string): () => void { + const schemaPath = join(root, 'env.ts'); + const localEnvPath = join(root, '.env.local'); + if (existsSync(schemaPath) || existsSync(localEnvPath)) { + throw new Error(`Env validation fixture would overwrite an existing file in ${root}`); + } + + writeFileSync( + schemaPath, + `import { defineEnv, string } from 'void/env';\n\n` + + `export default defineEnv({\n` + + ` ${ENV_VALIDATION_TEST_KEY}: string(),\n` + + `});\n`, + ); + writeFileSync(localEnvPath, `${ENV_VALIDATION_TEST_KEY}=present\n`); + + return () => { + rmSync(schemaPath, { force: true }); + rmSync(localEnvPath, { force: true }); + }; +} + +export function captureChildOutput(child: ReturnType): () => string { + let output = ''; + const capture = (chunk: Buffer) => { + output += String(chunk); + }; + child.stdout?.on('data', capture); + child.stderr?.on('data', capture); + return () => output; +} + +export async function waitForEnvValidation(): Promise { + // envSchemaPlugin intentionally validates 100ms after the server starts + // listening. Leave enough headroom for framework startup work and CI load. + await new Promise((resolve) => setTimeout(resolve, 1_000)); +} /** * Timeout multiplier for Windows CI runners, which are 2–3× slower From 62647ec4e8b839c070907e23c122b496094298f2 Mon Sep 17 00:00:00 2001 From: mdong Date: Sun, 30 Aug 2026 16:53:19 +0800 Subject: [PATCH 2/5] fix(void): validation looks at .env & shell --- packages/void/src/plugin-env-schema.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/void/src/plugin-env-schema.ts b/packages/void/src/plugin-env-schema.ts index d767a247a..88586e31f 100644 --- a/packages/void/src/plugin-env-schema.ts +++ b/packages/void/src/plugin-env-schema.ts @@ -5,7 +5,6 @@ import { findEnvFile, generateEnvTypes } from './codegen/env-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { loadUserEnvFile } from './host/load-user-env.ts'; import { voidLog, voidWarn } from './log.ts'; -import { filterLoadedEnv } from './shared/env.ts'; type RegisteredSchemaModule = { _getRegisteredSchema(): Record< @@ -150,7 +149,10 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = return; } const { loadEnv } = await import('vite'); - const merged = filterLoadedEnv(loadEnv(viteMode, envDir, '')); + // Validation checks only registered schema keys, so use Vite's effective + // environment directly. Filtering is reserved for values that will be + // serialized into worker bindings, where shell pollution must be removed. + const merged = loadEnv(viteMode, envDir, ''); const report = await validateAllEnv(merged as Record); if (report.valid) { return; From b662c6c8ea2a1bdc6f2c463b1cc55c0244500d8a Mon Sep 17 00:00:00 2001 From: mdong Date: Sun, 30 Aug 2026 17:13:49 +0800 Subject: [PATCH 3/5] fix(env): revert filtering to allow for cloudflare deploy --- packages/void/src/plugin-env-schema.ts | 19 ++- packages/void/src/shared/env.ts | 9 +- .../integration/plugin-env-schema.test.ts | 132 ++++++++++++++++++ packages/void/test/unit/env-filtering.test.ts | 20 +++ 4 files changed, 170 insertions(+), 10 deletions(-) diff --git a/packages/void/src/plugin-env-schema.ts b/packages/void/src/plugin-env-schema.ts index 88586e31f..7ff2548f0 100644 --- a/packages/void/src/plugin-env-schema.ts +++ b/packages/void/src/plugin-env-schema.ts @@ -5,6 +5,7 @@ import { findEnvFile, generateEnvTypes } from './codegen/env-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { loadUserEnvFile } from './host/load-user-env.ts'; import { voidLog, voidWarn } from './log.ts'; +import { filterLoadedEnv } from './shared/env.ts'; type RegisteredSchemaModule = { _getRegisteredSchema(): Record< @@ -70,6 +71,10 @@ function resolveEnvPrefixes(prefix: ResolvedConfig['envPrefix']): ReadonlyArray< } export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = {}): Plugin { + // Some framework plugins copy dotenv values into process.env during their + // config hooks. Capture the actual shell before those hooks run so validation + // can distinguish framework-loaded values from ambient shell-only values. + const initialShellEnv = { ...process.env }; const outDir = join(root, '.void'); let envFile: string | null = null; let viteMode = 'development'; @@ -149,17 +154,17 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = return; } const { loadEnv } = await import('vite'); - // Validation checks only registered schema keys, so use Vite's effective - // environment directly. Filtering is reserved for values that will be - // serialized into worker bindings, where shell pollution must be removed. - const merged = loadEnv(viteMode, envDir, ''); - const report = await validateAllEnv(merged as Record); + // Match the Worker binding surface: exclude values inherited from the + // initial shell, but retain dotenv values that framework hooks copied into + // process.env after this plugin was constructed. + const merged = filterLoadedEnv(loadEnv(viteMode, envDir, ''), initialShellEnv); + const report = await validateAllEnv(merged); if (report.valid) { return; } const lines: Array = ['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('; ')}`); @@ -213,7 +218,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 diff --git a/packages/void/src/shared/env.ts b/packages/void/src/shared/env.ts index d97ab2b54..878cff1e2 100644 --- a/packages/void/src/shared/env.ts +++ b/packages/void/src/shared/env.ts @@ -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): Record { - const shellKeys = new Set(Object.keys(process.env)); +export function filterLoadedEnv( + loaded: Record, + shellEnv: Readonly> = process.env, +): Record { + const shellKeys = new Set(Object.keys(shellEnv)); const env: Record = {}; 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; } } diff --git a/packages/void/test/integration/plugin-env-schema.test.ts b/packages/void/test/integration/plugin-env-schema.test.ts index 7d895b957..ad42f2612 100644 --- a/packages/void/test/integration/plugin-env-schema.test.ts +++ b/packages/void/test/integration/plugin-env-schema.test.ts @@ -50,6 +50,40 @@ async function runConfigResolved( } } +async function runConfigureServer(plugin: ReturnType): Promise { + const hook = plugin.configureServer as unknown; + const server = { + httpServer: undefined, + watcher: { + add() {}, + on() {}, + }, + config: {}, + ws: { + send() {}, + }, + }; + if (typeof hook === 'function') { + await (hook as (server: unknown) => unknown).call({}, server); + return; + } + if (hook && typeof hook === 'object' && 'handler' in hook) { + const handler = (hook as { handler: (server: unknown) => unknown }).handler; + await handler.call({}, server); + } +} + +async function waitFor(condition: () => boolean, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('Timed out waiting for asynchronous env validation'); +} + afterEach(() => { for (const root of roots.splice(0, roots.length)) { rmSync(root, { recursive: true, force: true }); @@ -256,4 +290,102 @@ describe('envSchemaPlugin auto-discovery', () => { expect(resolved.env).toEqual({ PUBLIC_TITLE: 'Public' }); }); + + it('treats initial shell-only schema values as missing during validation', async () => { + const key = 'VOID_ENV_SCHEMA_SHELL_ONLY_TEST'; + const previous = process.env[key]; + process.env[key] = 'from-shell'; + + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + const plugin = envSchemaPlugin(root); + + try { + await runConfig(plugin); + const { defineEnv } = await import('../../src/runtime/env-public.ts'); + const { string } = await import('../../src/runtime/env-helpers.ts'); + defineEnv({ [key]: string() }); + await runConfigResolved(plugin, { + mode: 'development', + envDir: root, + envPrefix: 'VITE_', + env: {}, + define: {}, + }); + + const warnSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await runConfigureServer(plugin); + await waitFor(() => + warnSpy.mock.calls.some((call) => + String(call[0] ?? '').includes(`${key} is required but missing`), + ), + ); + const output = warnSpy.mock.calls.map((call) => String(call[0] ?? '')).join('\n'); + expect(output).toContain('add it to .env.local'); + expect(output).not.toContain('set in your shell'); + } finally { + warnSpy.mockRestore(); + } + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + }); + + it('validates values copied into process.env after plugin construction', async () => { + const key = 'VOID_ENV_SCHEMA_FRAMEWORK_DOTENV_TEST'; + const missingKey = 'VOID_ENV_SCHEMA_VALIDATION_SENTINEL'; + const previous = process.env[key]; + const previousMissing = process.env[missingKey]; + delete process.env[key]; + delete process.env[missingKey]; + + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + const plugin = envSchemaPlugin(root); + process.env[key] = 'from-framework-dotenv'; + + try { + await runConfig(plugin); + const { defineEnv } = await import('../../src/runtime/env-public.ts'); + const { string } = await import('../../src/runtime/env-helpers.ts'); + defineEnv({ [key]: string(), [missingKey]: string() }); + await runConfigResolved(plugin, { + mode: 'development', + envDir: root, + envPrefix: 'VITE_', + env: {}, + define: {}, + }); + + const warnSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await runConfigureServer(plugin); + await waitFor(() => + warnSpy.mock.calls.some((call) => + String(call[0] ?? '').includes(`${missingKey} is required but missing`), + ), + ); + const output = warnSpy.mock.calls.map((call) => String(call[0] ?? '')).join('\n'); + expect(output).not.toContain(`${key} is required but missing`); + } finally { + warnSpy.mockRestore(); + } + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + if (previousMissing === undefined) { + delete process.env[missingKey]; + } else { + process.env[missingKey] = previousMissing; + } + } + }); }); diff --git a/packages/void/test/unit/env-filtering.test.ts b/packages/void/test/unit/env-filtering.test.ts index f559ad796..a7cc46976 100644 --- a/packages/void/test/unit/env-filtering.test.ts +++ b/packages/void/test/unit/env-filtering.test.ts @@ -5,6 +5,26 @@ import { withRuntimeEnv, requireRuntimeBinding, } from '../../src/runtime/env.ts'; +import { filterLoadedEnv } from '../../src/shared/env.ts'; + +describe('loaded env filtering', () => { + it('filters initial shell values but retains values loaded after the baseline snapshot', () => { + const loaded = { + SHELL_ONLY: 'from-shell', + FRAMEWORK_DOTENV: 'from-dotenv', + SHELL_OVERRIDDEN: 'from-dotenv', + }; + const initialShellEnv = { + SHELL_ONLY: 'from-shell', + SHELL_OVERRIDDEN: 'from-shell', + }; + + expect(filterLoadedEnv(loaded, initialShellEnv)).toEqual({ + FRAMEWORK_DOTENV: 'from-dotenv', + SHELL_OVERRIDDEN: 'from-dotenv', + }); + }); +}); describe('internal binding filtering (real implementation)', () => { const testEnv = { From 92ff9ab07275bd36eede348075602907e94254f3 Mon Sep 17 00:00:00 2001 From: mdong Date: Fri, 4 Sep 2026 10:38:10 +0800 Subject: [PATCH 4/5] fix(env): align with worker vars --- packages/void/src/index.ts | 2 +- packages/void/src/plugin-env-schema.ts | 22 ++++++--- .../integration/plugin-env-schema.test.ts | 49 ++++++++++++++++++- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 1af8731a5..7fe6408df 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -923,7 +923,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, { diff --git a/packages/void/src/plugin-env-schema.ts b/packages/void/src/plugin-env-schema.ts index 7ff2548f0..9bb8e54b3 100644 --- a/packages/void/src/plugin-env-schema.ts +++ b/packages/void/src/plugin-env-schema.ts @@ -31,6 +31,15 @@ export type EnvSchemaPluginOptions = { * "skip if already set" semantics. */ onDefaults?: (defaults: Record) => void; + /** + * 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; @@ -71,10 +80,7 @@ function resolveEnvPrefixes(prefix: ResolvedConfig['envPrefix']): ReadonlyArray< } export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = {}): Plugin { - // Some framework plugins copy dotenv values into process.env during their - // config hooks. Capture the actual shell before those hooks run so validation - // can distinguish framework-loaded values from ambient shell-only values. - const initialShellEnv = { ...process.env }; + const initialShellEnv = options.frameworkOwnsRuntimeEnv ? { ...process.env } : undefined; const outDir = join(root, '.void'); let envFile: string | null = null; let viteMode = 'development'; @@ -154,10 +160,10 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = return; } const { loadEnv } = await import('vite'); - // Match the Worker binding surface: exclude values inherited from the - // initial shell, but retain dotenv values that framework hooks copied into - // process.env after this plugin was constructed. - const merged = filterLoadedEnv(loadEnv(viteMode, envDir, ''), initialShellEnv); + // Framework-owned runtimes retain dotenv values copied into process.env + // after plugin construction. Void-owned runtimes filter against the current + // process.env, matching envPlugin's worker-vars binding path. + const merged = filterLoadedEnv(loadEnv(viteMode, envDir, ''), initialShellEnv ?? process.env); const report = await validateAllEnv(merged); if (report.valid) { return; diff --git a/packages/void/test/integration/plugin-env-schema.test.ts b/packages/void/test/integration/plugin-env-schema.test.ts index ad42f2612..c706f712c 100644 --- a/packages/void/test/integration/plugin-env-schema.test.ts +++ b/packages/void/test/integration/plugin-env-schema.test.ts @@ -336,7 +336,7 @@ describe('envSchemaPlugin auto-discovery', () => { } }); - it('validates values copied into process.env after plugin construction', async () => { + it('validates post-construction process.env values for framework-owned runtimes', async () => { const key = 'VOID_ENV_SCHEMA_FRAMEWORK_DOTENV_TEST'; const missingKey = 'VOID_ENV_SCHEMA_VALIDATION_SENTINEL'; const previous = process.env[key]; @@ -346,7 +346,7 @@ describe('envSchemaPlugin auto-discovery', () => { const root = createTempRoot(); writeFileSync(join(root, 'env.ts'), 'export default {};\n'); - const plugin = envSchemaPlugin(root); + const plugin = envSchemaPlugin(root, { frameworkOwnsRuntimeEnv: true }); process.env[key] = 'from-framework-dotenv'; try { @@ -388,4 +388,49 @@ describe('envSchemaPlugin auto-discovery', () => { } } }); + + it('excludes post-construction process.env values for Void-owned runtimes', async () => { + const key = 'VOID_ENV_SCHEMA_POST_CONSTRUCTION_SHELL_TEST'; + const previous = process.env[key]; + delete process.env[key]; + + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + const plugin = envSchemaPlugin(root); + process.env[key] = 'from-earlier-plugin'; + + try { + await runConfig(plugin); + const { defineEnv } = await import('../../src/runtime/env-public.ts'); + const { string } = await import('../../src/runtime/env-helpers.ts'); + defineEnv({ [key]: string() }); + await runConfigResolved(plugin, { + mode: 'development', + envDir: root, + envPrefix: 'VITE_', + env: {}, + define: {}, + }); + + const warnSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await runConfigureServer(plugin); + await waitFor(() => + warnSpy.mock.calls.some((call) => + String(call[0] ?? '').includes(`${key} is required but missing`), + ), + ); + const output = warnSpy.mock.calls.map((call) => String(call[0] ?? '')).join('\n'); + expect(output).toContain(`${key} is required but missing`); + } finally { + warnSpy.mockRestore(); + } + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + }); }); From 1bc1df4e231ceb9a988b0111331948ddb426913e Mon Sep 17 00:00:00 2001 From: mdong Date: Fri, 4 Sep 2026 11:09:35 +0800 Subject: [PATCH 5/5] fix(env): handle class a frameworks --- packages/void/src/index.ts | 20 ++++++-- packages/void/src/plugin-env-schema.ts | 14 ++++-- .../integration/plugin-env-schema.test.ts | 50 +++++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 7fe6408df..51422d331 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -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); @@ -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 | undefined; const envPlugin: Plugin = { name: 'void:env', api: { @@ -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.`, @@ -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, @@ -1714,7 +1723,7 @@ export function voidPlugin(options?: { return [ restartPlugin(), envPlugin, - envSchemaPlugin(root), + envSchemaPlugin(root, { getValidationEnv: () => loadedUserEnv }), ...pagesDevCssAssetsPlugin(root, devTriggerToken), routingPlugin(root, { isAuthEnabled: () => false, @@ -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, diff --git a/packages/void/src/plugin-env-schema.ts b/packages/void/src/plugin-env-schema.ts index 9bb8e54b3..07b2081ba 100644 --- a/packages/void/src/plugin-env-schema.ts +++ b/packages/void/src/plugin-env-schema.ts @@ -31,6 +31,12 @@ export type EnvSchemaPluginOptions = { * "skip if already set" semantics. */ onDefaults?: (defaults: Record) => 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 | 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 @@ -161,9 +167,11 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = } const { loadEnv } = await import('vite'); // Framework-owned runtimes retain dotenv values copied into process.env - // after plugin construction. Void-owned runtimes filter against the current - // process.env, matching envPlugin's worker-vars binding path. - const merged = filterLoadedEnv(loadEnv(viteMode, envDir, ''), initialShellEnv ?? 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; diff --git a/packages/void/test/integration/plugin-env-schema.test.ts b/packages/void/test/integration/plugin-env-schema.test.ts index c706f712c..740fc63d2 100644 --- a/packages/void/test/integration/plugin-env-schema.test.ts +++ b/packages/void/test/integration/plugin-env-schema.test.ts @@ -389,6 +389,56 @@ describe('envSchemaPlugin auto-discovery', () => { } }); + it('validates the Void-loaded env snapshot after process.env is mutated', async () => { + const key = 'VOID_ENV_SCHEMA_WORKER_ENV_TEST'; + const missingKey = 'VOID_ENV_SCHEMA_WORKER_ENV_SENTINEL'; + const previous = process.env[key]; + delete process.env[key]; + + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + const loadedUserEnv = { [key]: 'from-dotenv' }; + const plugin = envSchemaPlugin(root, { getValidationEnv: () => loadedUserEnv }); + + try { + await runConfig(plugin); + const { defineEnv } = await import('../../src/runtime/env-public.ts'); + const { string } = await import('../../src/runtime/env-helpers.ts'); + defineEnv({ [key]: string(), [missingKey]: string() }); + await runConfigResolved(plugin, { + mode: 'development', + envDir: root, + envPrefix: 'VITE_', + env: {}, + define: {}, + }); + + // Simulate a later framework/plugin copying the dotenv value into the + // process environment after Void captured its worker-vars snapshot. + process.env[key] = loadedUserEnv[key]; + + const warnSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await runConfigureServer(plugin); + await waitFor(() => + warnSpy.mock.calls.some((call) => + String(call[0] ?? '').includes(`${missingKey} is required but missing`), + ), + ); + const output = warnSpy.mock.calls.map((call) => String(call[0] ?? '')).join('\n'); + expect(output).not.toContain(`${key} is required but missing`); + } finally { + warnSpy.mockRestore(); + } + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + }); + it('excludes post-construction process.env values for Void-owned runtimes', async () => { const key = 'VOID_ENV_SCHEMA_POST_CONSTRUCTION_SHELL_TEST'; const previous = process.env[key];