From 01b66234a28d61b32218a0baf356a4a8224c8eea Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Thu, 12 Nov 2026 00:34:18 +0900 Subject: [PATCH 1/3] fix(env): apply env.ts in TanStack Start, React Router and vinext Workers In a TanStack Start, React Router or vinext app, `env` from `void/env` ignored env.ts: a default read as undefined, a boolean stayed a string, and a missing required key went unnoticed. Only Void's own Worker entry loaded env.ts, so no schema was registered in a framework Worker. The Class A wrapper now imports env.ts ahead of the framework entry and awaits the new internal `_prepareEnv(env)` on every request, in dev and in builds. It validates every key, async validators included, and never rejects: a read of a key that failed throws that key's error, so a request that does not read it still runs, and so does a build-time prerender without production secrets. A read of a missing required key now throws rather than returning undefined. Unlike Void's own entry, an invalid value does not fail every request. SvelteKit, Nuxt, Analog and Astro Workers do not load env.ts yet. `_prepareEnv` and `_validateEnvOnce` share one validation, which now keeps a result for each key, and the results stay with the env object that was validated. A D1 app's framework reads outside a request scope, through the `cloudflare:workers` env, another object. Such a read gets the latest validation's result, failures included, while that env holds the raw value the result came from. A validation in which a validator threw serves its reads and runs again on the next call. `void deploy` bundles a framework app's cron and queue jobs apart from the app, with a copy of `void/env` of their own. They would see the schema that the app registers and fail to read async keys, so the jobs of an app with env.ts now prepare the env through the `_prepareEnv` that the registering copy publishes on the env registry. Results by env object move to that registry, so every copy reads them. The latest validation moves to a new registry field, `latestValidation`: its shape changed, and two Void versions in one process share the registry. Co-Authored-By: Claude Opus 5.5 --- docs/integrations/frameworks/overview.md | 1 + packages/void/AGENTS.md | 1 + packages/void/src/cli/wrapper.ts | 14 + packages/void/src/plugin-framework-entry.ts | 31 +- packages/void/src/runtime/env-public.ts | 313 +++++++++++------- packages/void/src/shared/env-registry.ts | 8 + .../test/integration/react-router.test.ts | 5 + .../test/integration/tanstack-start.test.ts | 5 + .../void/test/integration/vinext-app.test.ts | 5 + .../test/integration/vinext-pages.test.ts | 5 + packages/void/test/unit/env-schema.test.ts | 207 +++++++++++- .../test/unit/plugin-class-a-wrapper.test.ts | 205 ++++++++++++ packages/void/test/unit/wrapper.test.ts | 154 +++++++-- playground/react-router/app/routes.ts | 1 + playground/react-router/app/routes/api-env.ts | 3 + playground/react-router/env.ts | 6 + playground/tanstack-start/env.ts | 6 + .../tanstack-start/src/routes/api/env.ts | 10 + playground/vinext-app/app/api/env/route.ts | 7 + playground/vinext-app/env.ts | 6 + playground/vinext-pages/env.ts | 6 + playground/vinext-pages/pages/api/env.ts | 7 + test/check-playground-build-artifacts.mjs | 30 +- 23 files changed, 877 insertions(+), 159 deletions(-) create mode 100644 packages/void/src/shared/env-registry.ts create mode 100644 playground/react-router/app/routes/api-env.ts create mode 100644 playground/react-router/env.ts create mode 100644 playground/tanstack-start/env.ts create mode 100644 playground/tanstack-start/src/routes/api/env.ts create mode 100644 playground/vinext-app/app/api/env/route.ts create mode 100644 playground/vinext-app/env.ts create mode 100644 playground/vinext-pages/env.ts create mode 100644 playground/vinext-pages/pages/api/env.ts diff --git a/docs/integrations/frameworks/overview.md b/docs/integrations/frameworks/overview.md index 202de5560..4de89c077 100644 --- a/docs/integrations/frameworks/overview.md +++ b/docs/integrations/frameworks/overview.md @@ -36,6 +36,7 @@ With `voidPlugin()` added to the framework's Vite config, frameworks get most of - **Typed database:** query types come from your Drizzle schema. - **Migrations:** local D1 is updated on dev startup, and migrations are validated and deployed with `void deploy`. - **Runtime helpers:** `void/db`, `void/kv`, `void/storage`, and `void/ai` give you typed access to Cloudflare bindings in dev and production. +- **Environment variables:** in TanStack Start, React Router, and vinext apps, `env` from `void/env` returns the values of your [`env.ts`](../../guide/env-vars.md) schema in dev and production, with defaults, type conversions, and async validators applied. A missing or invalid value fails only the requests that read it. - **Auth:** Void-managed auth is not supported in framework mode yet. For now, use Better Auth's official integration for your framework. - **Cron jobs:** scheduled handlers via the `crons/` directory. - **Queue consumers:** typed producers and consumers via the `queues/` directory. diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 49af70172..cab19cd37 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -130,6 +130,7 @@ generated entries must preserve the module-boundary rules below. - **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void. For PostgreSQL and MySQL, `void/db` is one drizzle instance whose client looks up the current request's pool on every call. workerd ties a socket to the request that opened it, so a query built or prepared at module scope must run on the pool of the request that executes it, and building one must not create a pool (global scope has no request to own it). mysql2 starts its idle timer only when it builds a pool with `maxIdle` below `connectionLimit`, and that timer runs until the pool ends, so the MySQL pools set neither: each connection closes from its pool `release` event after 1 second idle (60 seconds for `createDb`). Inside a request, reading `db` builds that request's pool first, so a missing connection string throws its own error rather than drizzle's "Failed query". The client answers drizzle's pool checks (`instanceof Pool`, `'getConnection' in`) through `getPrototypeOf` and `has`, cached per isolate, so a transaction still holds one connection. Astro's adapter entry runs inside `withRuntimeEnv` for these dialects, like the Class A wrapper. - **Database provisioning adapters**: `void db connect ` saves an external database URL to `.env` and syncs a linked Void project; `--local-only` skips the sync. `void db connect --provider neon` provisions through the built-in Neon adapter using `NEON_API_KEY`. Third-party packages or local modules can export a `DatabaseProviderAdapter` built with `void/database-provider`; runtime database code remains provider-neutral. - **Environment model**: `env.ts` is the checked-in schema; root `.env` is the only dotenv file and is local-only. Production server keys use the remote secret store, while fixed-prefix `VITE_*` keys are public build-time values. The server runtime reads the same embedded public values for boot validation and SSR. Client folding must observe the final shared `config.env`; Vite runs `configResolved` hooks concurrently. Never add `.env.*` precedence, `.secret()`/`.public()` modifiers, or plaintext Worker vars for schema-declared server keys. +- **Framework env schema**: `env` has no schema in a Worker whose own modules have not loaded `env.ts`, whatever the Vite plugin loaded in its process, so it returns raw values. The Class A wrapper (TanStack Start, React Router, vinext) imports `env.ts` ahead of the framework entry and awaits `_prepareEnv(env)` on every request, in dev and build. Unlike `_validateEnvOnce`, it never rejects: a read of a key that failed throws that key's error, async validators included, so requests that do not read it and a build-time prerender without production secrets still run. Both keep one result per key, reuse a validation for the same raw values, and keep the results with the env object they received. Reads through another object (the `cloudflare:workers` env that a D1 app's framework reads outside a request scope, the remote dev copy) get the latest validation's result while they hold the raw value it came from; never copy results to that object, which can come to hold other values. A validation in which a validator threw serves its reads but runs again on the next call. Results live on the process-global registry because `void deploy` bundles cron and queue jobs apart from the app, with a copy of `void/env` of their own: the jobs call `registry.prepare`, the `_prepareEnv` of the copy that registered the schema (key in `shared/env-registry.ts`). SvelteKit, Nuxt, Analog and Astro Workers do not load `env.ts` yet. - **Reserved `__VOID_*` names**: `envSchemaPlugin.config` (every mode, with or without `env.ts`) and `void env check` reject `__VOID_*` names in the root `.env` or declared in `env.ts`. Do not try to strip them from dev `vars` instead: Wrangler reads `.env` itself and writes it over `vars`, so a strip cannot be enforced. Manual trigger auth is a compile-time literal (`__VOID_SERVE` from `CompileOptions.dev`, false in builds and `vite preview`): dev accepts only `x-void-dev-trigger`, builds use the proxy token or build key. Never key it on `import.meta.env.DEV`, which is true in a `NODE_ENV=development` build. - **Typed Durable State convention**: each default-exported `defineDurableState()` definition in `durable-objects/*.ts` becomes a filename-derived binding and worker class (`shopping-cart.ts` → `SHOPPING_CART` / `ShoppingCartDurableObject`). The generated entry calls the internal `__bind()` hook so named imports can use `State.get(name)` without touching `c.env`, exports `State.class`, and emits a stable `new_sqlite_classes` migration. `syncWranglerDurableObjects()` persists bindings and append-only class migration history in `void.lock.json` through the generated Cloudflare config; never regenerate migration order solely from the current sorted files. The generated class extends `DurableObject` from `cloudflare:workers` so namespace stubs expose real RPC methods; runtime state and helpers use JavaScript private fields and methods. Runtime operations are serialized and persisted after successful typed RPC, `fetch`, or `alarm` handlers; versioned value migrations run on load. This is native-Void + Cloudflare only and is supported by direct Cloudflare deploys. Typed `*.ws.ts` routes likewise use SQLite-backed classes; their root-config sync uses the comment-preserving writer, retains legacy `new_classes` histories, and appends a new migration tag only for new classes. - **Persistent resource names**: optional static `name` on Durable State and WebSocket definitions uses the same identity derivation as unnamed filenames/routes. Preserve unnamed defaults exactly. `void info` reports names and verified unmatched lock identities without executing source or writing files. Scanners use the shared AST reader; runtime validation imports only the Worker-safe `shared/resource-name.ts` leaf. Carry explicit names through generated metadata and `__bind()`; runtime mismatches fail instead of selecting an inferred replacement. WebSocket `key({ params })` is shared by namespace selection and hook `ctx.id`; absent keys keep the existing ordered `param=value` format. diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index e2a811951..4fb36948e 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -13,12 +13,14 @@ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { dirname, join, extname, toSlash } from 'pathslash'; import { build } from 'vite'; +import { findEnvFile } from '../codegen/env-types.ts'; import { getDatabaseDialect, readConfig, type DatabaseDialect } from '../config.ts'; import type { MigrationPlanBundle } from '../migrations/plan.ts'; import { voidDbBundlerPlugin } from '../plugin-drizzle.ts'; import { readProjectPaths } from '../project-paths.ts'; import { cloudflareQueueName, type CloudflareQueueNames } from '../queues/cloudflare-names.ts'; import { generateQueueResolverSource } from '../queues/dispatch.ts'; +import { ENV_REGISTRY_KEY } from '../shared/env-registry.ts'; import { WORKER_CONDITIONS } from './worker-conditions.ts'; import { transformWorkerRequireBundle } from './worker-require-transform.ts'; @@ -794,6 +796,12 @@ function generateHandlersSource( if (scoped) { lines.push(`import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)};`); } + // The app's entry loads env.ts when the Worker starts, and the copy of `void/env` that + // registers it publishes its `_prepareEnv`. Jobs bundle their own copy apart from the app, so + // they prepare the env through that one, and their reads serve the values it resolved. + const prepareEnv = findEnvFile(root) + ? ` await globalThis[Symbol.for(${JSON.stringify(ENV_REGISTRY_KEY)})]?.prepare?.(env);` + : undefined; // Scheduled handlers if (features.crons.length > 0) { @@ -808,6 +816,9 @@ function generateHandlersSource( ? 'async function __runScheduled(controller, env, ctx) {' : 'export async function handleScheduled(controller, env, ctx) {', ); + if (prepareEnv) { + lines.push(prepareEnv); + } lines.push(' const tasks = [];'); for (let i = 0; i < features.crons.length; i++) { lines.push(` if (${JSON.stringify(features.crons[i].crons)}.includes(controller.cron)) {`); @@ -858,6 +869,9 @@ function generateHandlersSource( ? 'async function __runQueue(batch, env, ctx) {' : 'export async function handleQueue(batch, env, ctx) {', ); + if (prepareEnv) { + lines.push(prepareEnv); + } lines.push(' const queueSuffix = batch.queue;'); lines.push(' const __queueIndex = __resolveQueueIndex(queueSuffix);'); lines.push(' if (__queueIndex === -1) {'); diff --git a/packages/void/src/plugin-framework-entry.ts b/packages/void/src/plugin-framework-entry.ts index 761a77176..ae392a04b 100644 --- a/packages/void/src/plugin-framework-entry.ts +++ b/packages/void/src/plugin-framework-entry.ts @@ -1,5 +1,6 @@ import { mkdirSync, readdirSync, statSync } from 'node:fs'; import { isAbsolute, join, resolve } from 'pathslash'; +import { findEnvFile } from './codegen/env-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { type DatabaseDialect, type NotFoundHandling } from './config.ts'; import { isViteBuildOrPreview } from './plugin-dev-config.ts'; @@ -130,6 +131,10 @@ export function getOriginalFrameworkEntry( * splitting, *before* constant folding removes the dead branch, so the * dispatch file (and its transitive user-cron/queue imports + dev token) * would still ship in the production bundle. + * + * With an `env.ts`, both shapes import it ahead of the framework entry and await `_prepareEnv` + * on every request, so `env` from `void/env` serves the schema's values. A key that does not + * validate fails only the requests that read it. */ export function generateClassATriggerWrapper( root: string, @@ -152,11 +157,16 @@ export function generateClassATriggerWrapper( // vinext resolves public assets after middleware itself. Retrying its 404s // through ASSETS would expose files deliberately hidden by middleware. const assetFallback = framework !== 'vinext-app' && framework !== 'vinext-pages'; - const fetchBody = isBuild - ? assetFallback - ? ` return serveWithAssets(request, env, await original.fetch(request, env, ctx));` - : ` return original.fetch(request, env, ctx);` - : ` if (request.method === "POST") { + const envFile = findEnvFile(root); + // Ahead of the dev trigger dispatch too: the cron and queue jobs it runs read the same values. + const prepareEnv = envFile ? ` await _prepareEnv(env);\n` : ''; + const fetchBody = + prepareEnv + + (isBuild + ? assetFallback + ? ` return serveWithAssets(request, env, await original.fetch(request, env, ctx));` + : ` return original.fetch(request, env, ctx);` + : ` if (request.method === "POST") { const pathname = new URL(request.url).pathname; if (pathname === "/__void/scheduled" || pathname === "/__void/queue") { const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))}); @@ -164,7 +174,14 @@ export function generateClassATriggerWrapper( if (response) return response; } } - return original.fetch(request, env, ctx);`; + return original.fetch(request, env, ctx);`); + // env.ts first, so its schema is registered before the framework's modules evaluate. A + // Worker whose own modules have not loaded env.ts has no schema, whatever the Vite plugin + // loaded in its process. `void/env` by package name, so the wrapper shares the copy that the + // app's own imports load. + const envImports = envFile + ? `import ${JSON.stringify(envFile)};\nimport { _prepareEnv } from "void/env";\n` + : ''; const scoped = dialect !== 'sqlite'; const fetchMethod = scoped ? ` fetch(request, env, ctx) { @@ -176,7 +193,7 @@ ${fetchBody.replace(/^/gm, ' ')} ${fetchBody} },`; const body = `// @ts-nocheck — Auto-generated by Void ${isBuild ? 'for vite build' : 'in dev'}. Do NOT edit. -import original from ${JSON.stringify(original)}; +${envImports}import original from ${JSON.stringify(original)}; export * from ${JSON.stringify(original)}; ${scoped ? 'import { withRuntimeEnv } from "void/_env";\n' : ''} export default { diff --git a/packages/void/src/runtime/env-public.ts b/packages/void/src/runtime/env-public.ts index a755087a4..1326a96ee 100644 --- a/packages/void/src/runtime/env-public.ts +++ b/packages/void/src/runtime/env-public.ts @@ -29,6 +29,7 @@ /// import { isUnmaskActive, redactMessage } from '../shared/env-mask.ts'; +import { ENV_REGISTRY_KEY } from '../shared/env-registry.ts'; import type { StandardSchemaV1 } from '../standard-schema.ts'; import { getRawRuntimeEnv, type RuntimeEnv } from './env-raw.ts'; import { getRuntimeEnv, isInternalBinding } from './env.ts'; @@ -36,33 +37,50 @@ import { getRuntimeEnv, isInternalBinding } from './env.ts'; type AnyValidator = StandardSchemaV1; type AnySchema = Record; -type BootValidationState = - | { ok: true; values: Map } - | { ok: false; error: EnvValidationError }; +/** How one schema key validated against its raw value. */ +type KeyResult = + | { ok: true; value: unknown } + | { ok: false; issues: Array } + | { ok: false; thrown: unknown }; -/** A completed whole-schema validation, with the raw value of each key that it checked. */ +/** A validation of every schema key, with the raw value that each key was validated against. */ type EnvValidation = { inputs: Map; - state: BootValidationState; + results: Map; + /** Why `_validateEnvOnce` rejects this validation, kept so that each call throws the same error. */ + error?: EnvValidationError; }; type EnvRegistryState = { schema: AnySchema | null; hasDefineEnvBeenCalled: boolean; /** - * The latest completed whole-schema validation. An env with the same raw values reuses it, - * also when each request brings a new env object. An env with other values is validated - * again rather than given values validated for another. `readKey` serves a key's value from - * here to an env that was not validated itself, while it holds the raw value that the value - * came from. + * The latest validation of every schema key. An env with the same raw values reuses it, also + * when each request brings a new env object, unless a validator threw in it. An env with + * other values is validated again rather than given results validated for another. `readKey` + * serves a key's result from here to an env that was not validated itself, while it holds + * the raw value that the result came from. */ - validation?: EnvValidation | null; + latestValidation?: EnvValidation | null; + /** + * Results by env object, from its validation and its reads. Replaced, not cleared, by + * defineEnv, so the results of an env hold no reference once the env is gone. They live here + * rather than in this module because one Worker can hold several copies of it: `void deploy` + * bundles a framework app's cron and queue jobs apart from the app. + */ + results?: WeakMap>; + /** + * `_prepareEnv` of the copy that registered the schema. Code bundled apart from the app + * prepares the env with it, so the public values embedded with the app are the ones validated. + */ + prepare?: (envValues: unknown) => Promise; }; // Tooling can load `void/env` through a different module instance than the Vite // plugin (source workspace, built package, or cache-busted HMR import). Keep the -// registry process-global so every instance observes the same schema. -const ENV_REGISTRY_SYMBOL = Symbol.for('void.env.registry'); +// registry process-global so every instance observes the same schema. Two Void +// versions in one process share it too: name a field anew when its shape changes. +const ENV_REGISTRY_SYMBOL = Symbol.for(ENV_REGISTRY_KEY); const registryGlobal = globalThis as typeof globalThis & { [ENV_REGISTRY_SYMBOL]?: EnvRegistryState; }; @@ -70,17 +88,16 @@ const registry = (registryGlobal[ENV_REGISTRY_SYMBOL] ??= { schema: null, hasDefineEnvBeenCalled: false, }); -// Replaced, not cleared, by defineEnv, so the cache of an env holds no reference once the env -// is gone. -let validatedCache = new WeakMap>(); - -function getValidatedCache(rawEnv: object): Map { - let cache = validatedCache.get(rawEnv); - if (!cache) { - cache = new Map(); - validatedCache.set(rawEnv, cache); + +/** The results of `rawEnv`, where `readKey` looks first. */ +function getEnvResults(rawEnv: object): Map { + const byEnv = (registry.results ??= new WeakMap()); + let results = byEnv.get(rawEnv); + if (!results) { + results = new Map(); + byEnv.set(rawEnv, results); } - return cache; + return results; } /** Vite embeds the same public build inputs into server and browser bundles. */ @@ -121,10 +138,11 @@ export function defineEnv(schema: S): InferEnv { // Flip the presence flag once and leave it on — even if a future call // swaps in an empty schema, we still know defineEnv was declared. registry.hasDefineEnvBeenCalled = true; - // Drop any cached entries from a prior schema (HMR / re-import). - validatedCache = new WeakMap(); - // Reset boot-time validation state so a re-imported schema is re-checked. - registry.validation = null; + // Drop results from a prior schema (HMR / re-import), in every copy of this module. + registry.results = new WeakMap(); + // Reset the latest validation so a re-imported schema is re-checked. + registry.latestValidation = null; + registry.prepare = _prepareEnv; return env as unknown as InferEnv; } @@ -137,32 +155,63 @@ export function defineEnv(schema: S): InferEnv { * other env that holds the same raw values. */ export async function _validateEnvOnce(envValues: unknown): Promise { + const validation = await validateEnv(envValues); + if (validation) { + assertValid(validation); + } +} + +/** + * Internal: validate every schema key against a framework Worker's env, from its generated + * entry, so that synchronous `env.X` reads serve defaults, converted values and async + * validators' results. Unlike `_validateEnvOnce`, this never rejects: a read of a key that + * failed throws that key's error, so a request that does not read it still runs, and so does a + * build-time prerender without production secrets. Validations are reused the same way. + */ +export async function _prepareEnv(envValues: unknown): Promise { + // Without an env, as in a prerender in Node, reads fail anyway. + if (typeof envValues !== 'object' || envValues === null) { + return; + } + await validateEnv(envValues); +} + +/** + * Validate every schema key for `envValues`, or reuse the latest validation of the same raw + * values, and keep the results with `envValues` for its reads. + */ +async function validateEnv(envValues: unknown): Promise { const schema = registry.schema; if (!schema) { - return; + return undefined; } const inputs = readSchemaInputs(schema, envValues); - const last = registry.validation; - let state: BootValidationState; - if (last && sameInputs(last.inputs, inputs)) { - state = last.state; - } else { + let validation = registry.latestValidation; + if (!validation || !isReusable(validation) || !sameInputs(validation.inputs, inputs)) { // Each call awaits its own validation until one completes. In workerd, a promise that // waits on another request's I/O never settles once that request is canceled. - state = await validateSchema(schema, inputs); + validation = { inputs, results: await validateKeys(schema, inputs) }; if (registry.schema !== schema) { // defineEnv registered another schema while this call waited. - return _validateEnvOnce(envValues); + return validateEnv(envValues); } - registry.validation = { inputs, state }; + registry.latestValidation = validation; } - if (!state.ok) { - throw state.error; + // Keep the results with the env they were validated for. The registry holds the latest + // validation only, and an env with other values can validate while requests with this env + // still run: Cloudflare may keep an isolate running when only its bindings change. + keepResults(envValues, validation.results); + return validation; +} + +/** A validator that threw may succeed when it runs again, as a lookup that failed can. */ +function isReusable(validation: EnvValidation): boolean { + for (const result of validation.results.values()) { + if ('thrown' in result) { + return false; + } } - // Keep the values with the env that passed. The registry holds the latest validation only, - // and an env with other values can validate while requests with this env still run: - // Cloudflare may keep an isolate running when only its bindings change. - seedValidated(envValues, state.values); + return true; } /** The raw value of each schema key that `readKey` would validate for this env. */ @@ -194,39 +243,71 @@ function sameInputs(a: Map, b: Map): boolean { return true; } -async function validateSchema( +/** + * Validate each schema key against its raw value, awaiting async validators. Never rejects: the + * issues of a key, or what its validator threw, become that key's result. + */ +async function validateKeys( schema: AnySchema, inputs: Map, -): Promise { - const report = await validateAllEnvWithValues(schema, Object.fromEntries(inputs)); - if (report.valid) { - return { ok: true, values: report.values }; +): Promise> { + const unmask = isUnmaskActive(); + const entries = await Promise.all( + Object.entries(schema).map(async ([key, validator]): Promise<[string, KeyResult]> => { + const raw = inputs.get(key); + try { + const result = await validator['~standard'].validate(raw); + if (result.issues) { + return [key, { ok: false, issues: redactIssues(raw, result.issues, unmask) }]; + } + return [key, { ok: true, value: result.value }]; + } catch (thrown) { + return [key, { ok: false, thrown }]; + } + }), + ); + return new Map(entries); +} + +/** Throw what fails the validation as a whole: what a validator threw, or every invalid key. */ +function assertValid(validation: EnvValidation): void { + if (validation.error) { + throw validation.error; } - const lines: Array = ['Env validation failed at worker boot:']; - for (const key of report.missing) { - lines.push(` • ${key} is required but missing`); + const missing: Array = []; + const invalid: Array = []; + for (const [key, result] of validation.results) { + if (result.ok) { + continue; + } + if ('thrown' in result) { + throw result.thrown; + } + if (isMissing(validation.inputs.get(key))) { + missing.push(` • ${key} is required but missing`); + } else { + // The messages are already redacted. + invalid.push(` • ${key}: ${result.issues.map((issue) => issue.message).join('; ')}`); + } } - for (const { key, messages } of report.invalid) { - // Messages are already redacted inside validateAllEnvWithValues. - lines.push(` • ${key}: ${messages.join('; ')}`); + if (missing.length > 0 || invalid.length > 0) { + const lines = ['Env validation failed at worker boot:', ...missing, ...invalid]; + validation.error = new EnvValidationError('__init__', [{ message: lines.join('\n') }]); + throw validation.error; } - return { - ok: false, - error: new EnvValidationError('__init__', [{ message: lines.join('\n') }]), - }; } -/** Seed the per-key cache of `rawEnv`, where `readKey` looks first, with values validated for it. */ -function seedValidated(rawEnv: unknown, values: Map): void { - if (typeof rawEnv !== 'object' || rawEnv === null || values.size === 0) { +/** Keep `results` with `rawEnv`, where its reads look first. */ +function keepResults(rawEnv: unknown, results: Map): void { + if (typeof rawEnv !== 'object' || rawEnv === null) { return; } - const cache = getValidatedCache(rawEnv); - for (const [key, value] of values) { + const kept = getEnvResults(rawEnv); + for (const [key, result] of results) { // Internal bindings stay hidden even when the schema declares them. A value that a read // already returned stays the same object. - if (!isInternalBinding(key, rawEnv as RuntimeEnv) && !cache.has(key)) { - cache.set(key, value); + if (!isInternalBinding(key, rawEnv as RuntimeEnv) && !kept.get(key)?.ok) { + kept.set(key, result); } } } @@ -315,24 +396,18 @@ function readKey(key: string): unknown { if (!validator) { return rawValue; } - // Cache identity must be the underlying env object. `getRuntimeEnv()` + // Results are keyed by the underlying env object. `getRuntimeEnv()` // returns a fresh Proxy on every call, so keying by it would miss. - const cache = getValidatedCache(rawEnv); - if (cache.has(key)) { - return cache.get(key); - } - // Validation seeds only the env object it receives. Reads also use objects it never - // received: the `cloudflare:workers` env outside a request scope, the remote dev copy. A - // validated value belongs to the raw value it came from, so the latest validation serves - // such an env while it holds that raw value. The value is not cached: this env can come to - // hold another. - const latest = registry.validation; - if ( - latest?.state.ok && - latest.state.values.has(key) && - Object.is(latest.inputs.get(key), rawValue) - ) { - return latest.state.values.get(key); + const results = getEnvResults(rawEnv); + const known = results.get(key) ?? latestResult(key, rawValue); + if (known) { + if (known.ok) { + return known.value; + } + if ('issues' in known) { + throw new EnvValidationError(key, known.issues); + } + throw known.thrown; } const result = validator['~standard'].validate(rawValue); if (result instanceof Promise) { @@ -346,21 +421,48 @@ function readKey(key: string): unknown { ); } if (result.issues) { - const unmask = isUnmaskActive(); - const redactedIssues = result.issues.map((i) => ({ - ...i, - message: redactMessage(rawValue, i.message, { unmask }), - })); - throw new EnvValidationError(key, redactedIssues); + throw new EnvValidationError(key, redactIssues(rawValue, result.issues, isUnmaskActive())); } - cache.set(key, result.value); + results.set(key, { ok: true, value: result.value }); return result.value; } +/** + * Validation keeps results only with the env object it receives. Reads also use objects it + * never received: the `cloudflare:workers` env outside a request scope, the remote dev copy. A + * result belongs to the raw value it came from, so the latest validation serves such an env + * while it holds that raw value. The result is not kept with this env: it can come to hold + * another value. + */ +function latestResult(key: string, rawValue: unknown): KeyResult | undefined { + const latest = registry.latestValidation; + if (!latest || !Object.is(latest.inputs.get(key), rawValue)) { + return undefined; + } + return latest.results.get(key); +} + +/** Issues whose messages no longer contain the raw value, unless unmasking is on. */ +function redactIssues( + raw: unknown, + issues: ReadonlyArray, + unmask: boolean, +): Array { + return issues.map((issue) => ({ + ...issue, + message: redactMessage(raw, issue.message, { unmask }), + })); +} + +/** A value that a required key counts as missing rather than invalid. */ +function isMissing(raw: unknown): boolean { + return raw === undefined || raw === null || raw === ''; +} + /** * Typed env proxy. Reads from the runtime env, validating declared keys * on first access and passing through bindings / unknown keys unchanged. - * Async validators must be resolved first by `_validateEnvOnce()`. + * Async validators must be resolved first by `_validateEnvOnce()` or `_prepareEnv()`. */ export const env = new Proxy({} as Record, { get(_target, key) { @@ -397,37 +499,20 @@ export type EnvValidationReport = { valid: boolean; missing: Array; invalid: Array<{ key: string; messages: Array }>; - /** - * Resolved values for keys that validated successfully. After async - * pre-validation they serve sync `env.X` reads. - */ + /** Resolved values for keys that validated successfully. */ values: Map; }; /** * Validate an env record (local dotenv, build inputs, or runtime bindings) against - * the registered schema. Used by the dev plugin (warnings), the deploy CLI - * (hard error), and `_validateEnvOnce` at worker boot. + * the registered schema. Used by the dev plugin (warnings) and the deploy CLI + * (hard error). * * Async: awaits any validators that return a `Promise` (zod async refines, * arktype narrows, etc.). Callers already run in async contexts. */ export async function validateAllEnv( envValues: Record, -): Promise { - if (!registry.schema) { - return { valid: true, missing: [], invalid: [], values: new Map() }; - } - return await validateAllEnvWithValues(registry.schema, envValues); -} - -/** - * Internal: shared core that validates a schema against values and also - * returns the resolved values (for sync reads). - */ -async function validateAllEnvWithValues( - schema: AnySchema, - envValues: Record, ): Promise { const report: EnvValidationReport = { valid: true, @@ -435,20 +520,22 @@ async function validateAllEnvWithValues( invalid: [], values: new Map(), }; + if (!registry.schema) { + return report; + } const unmask = isUnmaskActive(); - for (const [key, validator] of Object.entries(schema)) { + for (const [key, validator] of Object.entries(registry.schema)) { const raw = envValues[key]; const resultMaybe = validator['~standard'].validate(raw); const result = resultMaybe instanceof Promise ? await resultMaybe : resultMaybe; if (result.issues) { report.valid = false; - const isMissing = raw === undefined || raw === null || raw === ''; - if (isMissing) { + if (isMissing(raw)) { report.missing.push(key); } else { report.invalid.push({ key, - messages: result.issues.map((i) => redactMessage(raw, i.message, { unmask })), + messages: redactIssues(raw, result.issues, unmask).map((issue) => issue.message), }); } } else { diff --git a/packages/void/src/shared/env-registry.ts b/packages/void/src/shared/env-registry.ts new file mode 100644 index 000000000..7c6b6ba89 --- /dev/null +++ b/packages/void/src/shared/env-registry.ts @@ -0,0 +1,8 @@ +/** + * `Symbol.for` key of the process-global `void/env` registry. + * + * Code that is bundled apart from an app, such as the cron and queue jobs that `void deploy` + * compiles for a framework Worker, reaches the app's copy of `void/env` through this key. This + * module is a leaf, so both the runtime and the CLI can import it. + */ +export const ENV_REGISTRY_KEY = 'void.env.registry'; diff --git a/packages/void/test/integration/react-router.test.ts b/packages/void/test/integration/react-router.test.ts index 7d26f7d1b..097fdf8c8 100644 --- a/packages/void/test/integration/react-router.test.ts +++ b/packages/void/test/integration/react-router.test.ts @@ -54,6 +54,11 @@ describe('playground/react-router dev', { retry: 2 }, () => { } }); + it('applies env.ts to server routes', async () => { + const response = await fetch(`${DEV_BASE}/api/env`); + expect(await response.json()).toEqual({ SCHEMA_DEFAULT: 48213 }); + }); + 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/tanstack-start.test.ts b/packages/void/test/integration/tanstack-start.test.ts index 5b9fdf326..da3c75383 100644 --- a/packages/void/test/integration/tanstack-start.test.ts +++ b/packages/void/test/integration/tanstack-start.test.ts @@ -51,6 +51,11 @@ describe('playground/tanstack-start dev', () => { } }); + it('applies env.ts to server routes', async () => { + const response = await fetch(`${DEV_BASE}/api/env`); + expect(await response.json()).toEqual({ SCHEMA_DEFAULT: 48213 }); + }); + 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 d3b82b808..61f4ec9dd 100644 --- a/packages/void/test/integration/vinext-app.test.ts +++ b/packages/void/test/integration/vinext-app.test.ts @@ -42,6 +42,11 @@ describe('playground/vinext-app dev', () => { playground?.cleanup(); }); + it('applies env.ts to server routes', async () => { + const response = await fetch(`${DEV_BASE}/api/env`); + expect(await response.json()).toEqual({ SCHEMA_DEFAULT: 48213 }); + }); + 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-pages.test.ts b/packages/void/test/integration/vinext-pages.test.ts index 9bf555600..fc20c2619 100644 --- a/packages/void/test/integration/vinext-pages.test.ts +++ b/packages/void/test/integration/vinext-pages.test.ts @@ -45,6 +45,11 @@ describe('playground/vinext-pages dev', () => { } }); + it('applies env.ts to server routes', async () => { + const response = await fetch(`${DEV_BASE}/api/env`); + expect(await response.json()).toEqual({ SCHEMA_DEFAULT: 48213 }); + }); + it('renders home page with SSR', async () => { const response = await fetch(`${DEV_BASE}/`); expect(response.status).toBe(200); diff --git a/packages/void/test/unit/env-schema.test.ts b/packages/void/test/unit/env-schema.test.ts index 4b9f6fdb7..a4dd540ff 100644 --- a/packages/void/test/unit/env-schema.test.ts +++ b/packages/void/test/unit/env-schema.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, expect, beforeEach } from 'vite-plus/test'; +import { afterEach, describe, it, expect, beforeEach, vi } from 'vite-plus/test'; import { boolean, email, json, number, oneOf, string, url } from '../../src/runtime/env-helpers.ts'; import { defineEnv, @@ -8,9 +8,11 @@ import { _getRegisteredSchema, _getSchemaDefaults, _hasRegisteredSchema, + _prepareEnv, _validateEnvOnce, } from '../../src/runtime/env-public.ts'; import { getRuntimeBinding, requireRuntimeBinding, withRuntimeEnv } from '../../src/runtime/env.ts'; +import { ENV_REGISTRY_KEY } from '../../src/shared/env-registry.ts'; // Reset schema between tests so order doesn't leak state. function resetSchema() { @@ -638,6 +640,209 @@ describe('_validateEnvOnce — internal bindings stay hidden from void/env', () }); }); +/** An async validator that upper-cases a string, with the number of times it ran. */ +function asyncUpper() { + const calls = { count: 0 }; + const validator = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: async (value: unknown) => { + calls.count++; + await Promise.resolve(); + return typeof value === 'string' + ? { value: value.toUpperCase() } + : { issues: [{ message: 'must be a string' }] }; + }, + }, + }; + return { validator, calls }; +} + +describe('_prepareEnv (framework Worker entries)', () => { + beforeEach(resetSchema); + + it('serves defaults, converted values and async results to synchronous reads', async () => { + defineEnv({ PORT: number().default(48213), DEBUG: boolean(), TOKEN: asyncUpper().validator }); + const rawEnv = { DEBUG: 'false', TOKEN: 'sk_test' }; + await _prepareEnv(rawEnv); + withRuntimeEnv(rawEnv, () => { + expect([env.PORT, env.DEBUG, env.TOKEN]).toEqual([48213, false, 'SK_TEST']); + }); + }); + + it('resolves with invalid keys, and fails only the reads of those keys', async () => { + const crash = new Error('validator crashed'); + defineEnv({ + PORT: number().default(3000), + STRIPE_KEY: string(), + TOKEN: asyncUpper().validator, + CRASH: { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: async () => { + throw crash; + }, + }, + }, + }); + const rawEnv = {}; + await expect(_prepareEnv(rawEnv)).resolves.toBeUndefined(); + withRuntimeEnv(rawEnv, () => { + expect(env.PORT).toBe(3000); + expect(() => env.STRIPE_KEY).toThrow(EnvValidationError); + // A read cannot run an async validator, but it throws the failure that validation found. + expect(() => env.TOKEN).toThrow('Invalid env "TOKEN": must be a string'); + expect(() => env.CRASH).toThrow(crash); + }); + }); + + it('redacts the raw value from the failure of an async validator', async () => { + defineEnv({ + TOKEN: { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: async (value: unknown) => ({ + issues: [{ message: `rejected ${String(value)}` }], + }), + }, + }, + }); + const rawEnv = { TOKEN: 'sk_live_abc123' }; + await _prepareEnv(rawEnv); + withRuntimeEnv(rawEnv, () => { + expect(() => env.TOKEN).toThrow('Invalid env "TOKEN": rejected '); + }); + }); + + it('validates once for the same raw values, and again for other values', async () => { + const { validator, calls } = asyncUpper(); + defineEnv({ TOKEN: validator }); + await _prepareEnv({ TOKEN: 'a' }); + // A dev server can hand each request a new env object. + const same = { TOKEN: 'a' }; + await _prepareEnv(same); + expect(calls.count).toBe(1); + const other = { TOKEN: 'b' }; + await _prepareEnv(other); + expect(calls.count).toBe(2); + withRuntimeEnv(same, () => { + expect(env.TOKEN).toBe('A'); + }); + withRuntimeEnv(other, () => { + expect(env.TOKEN).toBe('B'); + }); + }); + + // A lookup that a validator makes can fail once and succeed for a later request. + it('runs a validator that threw again on the next call', async () => { + const lookupFailed = new Error('lookup failed'); + let calls = 0; + defineEnv({ + TOKEN: { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: async (value: unknown) => { + calls++; + if (calls === 1) { + throw lookupFailed; + } + return { value }; + }, + }, + }, + }); + const rawEnv = { TOKEN: 'sk_test' }; + await _prepareEnv(rawEnv); + withRuntimeEnv(rawEnv, () => { + expect(() => env.TOKEN).toThrow(lookupFailed); + }); + await _prepareEnv(rawEnv); + withRuntimeEnv(rawEnv, () => { + expect(env.TOKEN).toBe('sk_test'); + }); + expect(calls).toBe(2); + }); + + it('resolves without an env, as in a prerender in Node', async () => { + defineEnv({ STRIPE_KEY: string() }); + await expect(_prepareEnv(undefined)).resolves.toBeUndefined(); + }); + + // A D1 app's handler receives one env object, and its reads use another: the + // `cloudflare:workers` env. Nuxt's global env stands in for it here. + describe('outside a request scope', () => { + const globalScope = globalThis as { __env__?: unknown }; + + afterEach(() => { + delete globalScope.__env__; + }); + + it('serves the results, failures included, while that env holds the same values', async () => { + defineEnv({ + PORT: number().default(3000), + TOKEN: asyncUpper().validator, + SECRET: asyncUpper().validator, + }); + const outside = { TOKEN: 'sk_test' }; + globalScope.__env__ = outside; + await _prepareEnv({ TOKEN: 'sk_test' }); + expect(env.PORT).toBe(3000); + expect(env.TOKEN).toBe('SK_TEST'); + expect(() => env.SECRET).toThrow('Invalid env "SECRET": must be a string'); + outside.TOKEN = 'other'; + expect(() => env.TOKEN).toThrow(/Async validator for 'TOKEN'/); + }); + }); +}); + +describe('several copies of void/env in one Worker', () => { + // `void deploy` bundles a framework app's cron and queue jobs with a copy of their own. + const registry = () => + (globalThis as Record)[ + Symbol.for(ENV_REGISTRY_KEY) + ]; + + async function secondCopy() { + vi.resetModules(); + return await import('../../src/runtime/env-public.ts'); + } + + beforeEach(resetSchema); + + it('reads the values that the copy holding the schema prepared', async () => { + const copy = await secondCopy(); + expect(copy.defineEnv).not.toBe(defineEnv); + defineEnv({ TOKEN: asyncUpper().validator, PORT: number().default(48213) }); + const rawEnv = { TOKEN: 'sk_test' }; + await registry()?.prepare?.(rawEnv); + withRuntimeEnv(rawEnv, () => { + expect([copy.env.TOKEN, copy.env.PORT]).toEqual(['SK_TEST', 48213]); + }); + }); + + it('publishes the _prepareEnv of the copy that registered the schema', async () => { + const copy = await secondCopy(); + defineEnv({ KEY: string() }); + expect(registry()?.prepare).toBe(_prepareEnv); + copy.defineEnv({ KEY: string() }); + expect(registry()?.prepare).toBe(copy._prepareEnv); + }); + + it('forgets results in every copy when a schema is registered again', async () => { + const copy = await secondCopy(); + defineEnv({ PORT: number() }); + withRuntimeEnv({ PORT: '3000' }, () => { + expect(copy.env.PORT).toBe(3000); + defineEnv({ PORT: string() }); + expect(copy.env.PORT).toBe('3000'); + }); + }); +}); + describe('env proxy with async validators', () => { beforeEach(() => { resetSchema(); diff --git a/packages/void/test/unit/plugin-class-a-wrapper.test.ts b/packages/void/test/unit/plugin-class-a-wrapper.test.ts index 82f79a410..26433354a 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, rmSync, @@ -12,9 +13,11 @@ import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; import { toSlash } from 'pathslash'; import { createServer, normalizePath, type Plugin, type ResolvedConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { bundleEntry } from '../../src/cli/wrapper.ts'; import { getOriginalFrameworkEntry, hasVinextMiddleware, @@ -471,6 +474,208 @@ export default { ); }); +describe('Class A framework env schema', () => { + const ENV_TS = `import { defineEnv, number, string } from 'void/env'; + +// An async validator, like zod's async refine. +const upper = { + '~standard': { + version: 1, + vendor: 'test', + validate: async (value) => + typeof value === 'string' + ? { value: value.toUpperCase() } + : { issues: [{ message: 'TOKEN must be a string' }] }, + }, +}; + +export default defineEnv({ + STRIPE_KEY: string(), + SCHEMA_DEFAULT: number().default(48213), + TOKEN: upper, +}); +`; + + /** A TanStack app with env.ts, and its wrappers for dev and build. */ + async function createApp(voidConfig?: Record) { + const root = createTanStackRoot(voidConfig); + writeFileSync(join(root, 'env.ts'), ENV_TS); + linkVoidPackage(root); + const packageRoot = join(root, 'node_modules/@tanstack/react-start'); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync( + join(packageRoot, 'package.json'), + JSON.stringify({ type: 'module', exports: { './server-entry': './server-entry.js' } }), + ); + // Each path names the env key that the request reads. + writeFileSync( + join(packageRoot, 'server-entry.js'), + 'import { env } from "void/env";\n' + + 'export default { async fetch(request) {\n' + + ' const key = new URL(request.url).pathname.slice(1);\n' + + ' if (!key) return new Response("ok");\n' + + ' try { return Response.json({ [key]: env[key] }); }\n' + + ' catch (error) { return new Response(error.message, { status: 500 }); }\n' + + '} };\n', + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + const wrapperPlugin = voidPlugin().find( + (plugin) => plugin.name === 'void:framework-build-wrapper', + )!; + const wrapperPath = join(root, '.void', 'tanstack-start-trigger-wrapper.ts'); + const dev = readFileSync(wrapperPath, 'utf-8'); + callConfigResolved(wrapperPlugin, { + command: 'build', + build: { assetsDir: 'assets' } as ResolvedConfig['build'], + } as Partial); + return { root, wrapperPath, dev, build: readFileSync(wrapperPath, 'utf-8') }; + } + + const MISSING_VALUES = [ + { path: '/', expected: '200 ok' }, + { path: '/SCHEMA_DEFAULT', expected: '200 {"SCHEMA_DEFAULT":48213}' }, + { path: '/STRIPE_KEY', expected: expect.stringMatching(/^500 Invalid env "STRIPE_KEY"/) }, + { path: '/TOKEN', expected: '500 Invalid env "TOKEN": TOKEN must be a string' }, + ]; + const VALID_ENV = { STRIPE_KEY: 'sk_test', TOKEN: 'sk_token' }; + const VALID_VALUES = [ + { path: '/STRIPE_KEY', expected: '200 {"STRIPE_KEY":"sk_test"}' }, + { path: '/TOKEN', expected: '200 {"TOKEN":"SK_TOKEN"}' }, + { path: '/TOKEN', expected: '200 {"TOKEN":"SK_TOKEN"}' }, + ]; + + /** Answer each request in a Node process, with a new env object for each, as dev can. */ + function fetchInNode( + root: string, + wrapper: string, + requests: Array<{ path: string; env?: Record }>, + ): Array { + const path = join(root, '.void', 'run-wrapper.ts'); + writeFileSync(path, wrapper); + const runner = join(root, 'run.mjs'); + writeFileSync( + runner, + `const { default: worker } = await import(${JSON.stringify(pathToFileURL(path).href)});\n` + + 'const results = [];\n' + + `for (const { path, env } of ${JSON.stringify(requests)}) {\n` + + ' const response = await worker.fetch(new Request("https://example.test" + path), { ...env }, {});\n' + + ' results.push(response.status + " " + (await response.text()));\n' + + '}\n' + + 'process.stdout.write(JSON.stringify(results));\n', + ); + return JSON.parse(execFileSync(process.execPath, [runner], { cwd: root, encoding: 'utf8' })); + } + + /** Bundle a wrapper for workerd and answer each request there, with `bindings` as its env. */ + async function fetchInWorkerd( + root: string, + wrapperPath: string, + bindings: Record, + paths: Array, + ): Promise> { + const { outputDir, entryFilename } = await bundleEntry(root, wrapperPath); + const modules = readdirSync(outputDir) + .filter((file) => /\.m?js$/.test(file)) + .sort((a, b) => Number(b === entryFilename) - Number(a === entryFilename)) + .map((file) => ({ type: 'ESModule' as const, path: join(outputDir, file) })); + // A D1 framework Worker has `nodejs_als`, and no `nodejs_compat`. + const miniflare = new Miniflare( + convertV4MiniflareOptions({ + modulesRoot: outputDir, + modules, + compatibilityDate: '2026-02-24', + compatibilityFlags: ['nodejs_als'], + bindings, + }), + ); + try { + const results: Array = []; + for (const path of paths) { + const response = await miniflare.dispatchFetch(`https://example.test${path}`); + results.push(`${response.status} ${await response.text()}`); + } + return results; + } finally { + await miniflare.dispose(); + } + } + + it('imports env.ts ahead of the framework entry and prepares the env on every request', async () => { + const { root, dev, build } = await createApp(); + const imports = `import ${JSON.stringify(toSlash(join(root, 'env.ts')))}; +import { _prepareEnv } from "void/env"; +import original from "@tanstack/react-start/server-entry"; +export * from "@tanstack/react-start/server-entry"; +`; + expect(dev).toBe(`// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit. +${imports} +export default { + ...original, + async fetch(request, env, ctx) { + await _prepareEnv(env); + if (request.method === "POST") { + const pathname = new URL(request.url).pathname; + if (pathname === "/__void/scheduled" || pathname === "/__void/queue") { + const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))}); + const response = await handleDevTrigger(request, env, ctx); + if (response) return response; + } + } + return original.fetch(request, env, ctx); + }, +}; +`); + expect(build).toContain(`// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit. +${imports} +export default { + ...original, + async fetch(request, env, ctx) { + await _prepareEnv(env); + return serveWithAssets(request, env, await original.fetch(request, env, ctx)); + }, +}; +`); + }); + + // Reads in Node need a request scope, which a PostgreSQL app's wrapper opens. + it.each(['dev', 'build'] as const)( + 'serves env.ts values in the PostgreSQL %s wrapper and fails only the requests that read an invalid one', + async (mode) => { + const app = await createApp({ database: 'pg' }); + const requests = [ + ...MISSING_VALUES, + ...VALID_VALUES.map((request) => ({ ...request, env: VALID_ENV })), + ]; + expect(fetchInNode(app.root, app[mode], requests)).toEqual( + requests.map(({ expected }) => expected), + ); + }, + ); + + // A D1 app's framework fetch runs outside a request scope, so its reads use the + // `cloudflare:workers` env rather than the env object that the wrapper prepared. + it('serves env.ts values to a D1 app built for workerd', async () => { + const app = await createApp(); + expect( + await fetchInWorkerd( + app.root, + app.wrapperPath, + {}, + MISSING_VALUES.map(({ path }) => path), + ), + ).toEqual(MISSING_VALUES.map(({ expected }) => expected)); + expect( + await fetchInWorkerd( + app.root, + app.wrapperPath, + VALID_ENV, + VALID_VALUES.map(({ path }) => path), + ), + ).toEqual(VALID_VALUES.map(({ expected }) => expected)); + }); +}); + describe('Class A routing.notFound self-host warning', () => { /** Run a Class A `build` twice and return every line the plugin logged. */ async function buildTwiceWithNotFound(): Promise> { diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 6e4976902..af0f49bcf 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -1,5 +1,6 @@ import { once } from 'node:events'; import { + copyFileSync, cpSync, existsSync, mkdirSync, @@ -1161,8 +1162,55 @@ export default async function () { expect(handlers).toContain('export async function handleScheduled(controller, env, ctx) {'); expect(handlers).toContain('export async function handleQueue(batch, env, ctx) {'); expect(handlers).not.toContain('withRuntimeEnv'); + // Nor do the jobs of an app without env.ts prepare an env. + expect(handlers).not.toContain('prepare'); }); + /** Write `source` as a cron job and a queue consumer, both named `read-env`, that read `void/env`. */ + function writeReadEnvJobs(projectRoot: string, source: string): Partial { + mkdirSync(join(projectRoot, 'crons')); + writeFileSync(join(projectRoot, 'crons', 'read-env.ts'), source); + mkdirSync(join(projectRoot, 'queues')); + writeFileSync(join(projectRoot, 'queues', 'read-env.ts'), source); + mkdirSync(join(projectRoot, 'node_modules')); + symlinkSync( + join(import.meta.dirname, '..', '..'), + join(projectRoot, 'node_modules', 'void'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + return { + crons: [{ name: 'read-env', filePath: 'read-env.ts', crons: ['0 * * * *'] }], + queues: [{ name: 'read-env', filePath: 'read-env.ts', bindingName: 'QUEUE_READ_ENV' }], + }; + } + + /** Run the `read-env` cron job and queue consumer in workerd. Both succeed. */ + async function expectReadEnvJobsToPass( + workerDir: string, + options: Parameters[2], + ): Promise { + expect( + await fetchWrappedWorker( + workerDir, + { path: '/__void/scheduled', body: { cron: '0 * * * *', scheduledTime: Date.now() } }, + options, + ), + ).toEqual({ ok: true }); + expect( + await fetchWrappedWorker( + workerDir, + { + path: '/__void/queue', + body: { + queue: 'read-env', + messages: [{ id: 'one', timestamp: new Date().toISOString(), body: {}, attempts: 1 }], + }, + }, + options, + ), + ).toMatchObject({ ok: true }); + } + // A D1 framework Worker has only `nodejs_als`; PostgreSQL deploys add `nodejs_compat`, and run // jobs in a request scope. Under browser conditions, reading a server key from `void/env` throws. it.each([ @@ -1173,50 +1221,84 @@ export default async function () { async ({ config, compatibilityFlags }) => { const { workerDir, projectRoot } = createMigrationFixture(); writeFileSync(join(projectRoot, 'void.json'), JSON.stringify(config)); - const job = `import { env } from 'void/env'; + const jobs = writeReadEnvJobs( + projectRoot, + `import { env } from 'void/env'; export default async function () { if (env.STRIPE_KEY !== 'sk_test') throw new Error('unexpected STRIPE_KEY'); } -`; - mkdirSync(join(projectRoot, 'crons')); - writeFileSync(join(projectRoot, 'crons', 'read-env.ts'), job); - mkdirSync(join(projectRoot, 'queues')); - writeFileSync(join(projectRoot, 'queues', 'read-env.ts'), job); - mkdirSync(join(projectRoot, 'node_modules')); - symlinkSync( - join(import.meta.dirname, '..', '..'), - join(projectRoot, 'node_modules', 'void'), - process.platform === 'win32' ? 'junction' : 'dir', +`, ); - await generateWrapper(workerDir, 'index.mjs', projectRoot, { - ...base, - crons: [{ name: 'read-env', filePath: 'read-env.ts', crons: ['0 * * * *'] }], - queues: [{ name: 'read-env', filePath: 'read-env.ts', bindingName: 'QUEUE_READ_ENV' }], + await generateWrapper(workerDir, 'index.mjs', projectRoot, { ...base, ...jobs }); + + await expectReadEnvJobsToPass(workerDir, { + bindings: { STRIPE_KEY: 'sk_test' }, + compatibilityFlags, }); + }, + ); - const options = { bindings: { STRIPE_KEY: 'sk_test' }, compatibilityFlags }; - expect( - await fetchWrappedWorker( - workerDir, - { path: '/__void/scheduled', body: { cron: '0 * * * *', scheduledTime: Date.now() } }, - options, - ), - ).toEqual({ ok: true }); - expect( - await fetchWrappedWorker( - workerDir, - { - path: '/__void/queue', - body: { - queue: 'read-env', - messages: [{ id: 'one', timestamp: new Date().toISOString(), body: {}, attempts: 1 }], - }, - }, - options, - ), - ).toMatchObject({ ok: true }); + // The jobs bundle a copy of `void/env` of their own. The app's Worker loads env.ts and prepares + // the env, as the Class A wrapper does, and the jobs read what it resolved through the registry. + it.each([ + { database: 'D1', config: {}, compatibilityFlags: ['nodejs_als'] }, + { database: 'PostgreSQL', config: { database: 'pg' }, compatibilityFlags: ['nodejs_compat'] }, + ])( + 'gives $database cron and queue jobs the env.ts values that the app prepared', + async ({ config, compatibilityFlags }) => { + const { workerDir, projectRoot } = createMigrationFixture(); + writeFileSync(join(projectRoot, 'void.json'), JSON.stringify(config)); + const jobs = writeReadEnvJobs( + projectRoot, + `import { env } from 'void/env'; + +export default async function () { + if (env.SCHEMA_DEFAULT !== 48213 || env.TOKEN !== 'SK_TEST') { + throw new Error('unexpected env: ' + JSON.stringify([env.SCHEMA_DEFAULT, env.TOKEN])); + } +} +`, + ); + writeFileSync( + join(projectRoot, 'env.ts'), + `import { defineEnv, number } from 'void/env'; + +// An async validator, like zod's async refine. +const upper = { + '~standard': { + version: 1, + vendor: 'test', + validate: async (value) => ({ value: String(value).toUpperCase() }), + }, +}; + +export default defineEnv({ SCHEMA_DEFAULT: number().default(48213), TOKEN: upper }); +`, + ); + writeFileSync( + join(projectRoot, 'app.ts'), + `import './env.ts'; +import { _prepareEnv } from 'void/env'; + +export default { + async fetch(request, env) { + await _prepareEnv(env); + return new Response('app'); + }, +}; +`, + ); + const app = await bundleEntry(projectRoot, join(projectRoot, 'app.ts')); + copyFileSync(join(app.outputDir, app.entryFilename), join(workerDir, 'index.mjs')); + + await generateWrapper(workerDir, 'index.mjs', projectRoot, { ...base, ...jobs }); + + await expectReadEnvJobsToPass(workerDir, { + bindings: { TOKEN: 'sk_test' }, + compatibilityFlags, + }); }, ); diff --git a/playground/react-router/app/routes.ts b/playground/react-router/app/routes.ts index 6d8671b84..7c87f4f11 100644 --- a/playground/react-router/app/routes.ts +++ b/playground/react-router/app/routes.ts @@ -4,4 +4,5 @@ export default [ index('routes/home.tsx'), route('users', 'routes/users.tsx'), route('smoke', 'routes/smoke.tsx'), + route('api/env', 'routes/api-env.ts'), ] satisfies RouteConfig; diff --git a/playground/react-router/app/routes/api-env.ts b/playground/react-router/app/routes/api-env.ts new file mode 100644 index 000000000..42866418c --- /dev/null +++ b/playground/react-router/app/routes/api-env.ts @@ -0,0 +1,3 @@ +import { env } from 'void/env'; + +export const loader = () => Response.json({ SCHEMA_DEFAULT: env.SCHEMA_DEFAULT }); diff --git a/playground/react-router/env.ts b/playground/react-router/env.ts new file mode 100644 index 000000000..4f9eb59b7 --- /dev/null +++ b/playground/react-router/env.ts @@ -0,0 +1,6 @@ +import { defineEnv, number } from 'void/env'; + +// Only the schema supplies this default, so a Worker that ignores env.ts reads undefined. +export default defineEnv({ + SCHEMA_DEFAULT: number().default(48213), +}); diff --git a/playground/tanstack-start/env.ts b/playground/tanstack-start/env.ts new file mode 100644 index 000000000..4f9eb59b7 --- /dev/null +++ b/playground/tanstack-start/env.ts @@ -0,0 +1,6 @@ +import { defineEnv, number } from 'void/env'; + +// Only the schema supplies this default, so a Worker that ignores env.ts reads undefined. +export default defineEnv({ + SCHEMA_DEFAULT: number().default(48213), +}); diff --git a/playground/tanstack-start/src/routes/api/env.ts b/playground/tanstack-start/src/routes/api/env.ts new file mode 100644 index 000000000..d5ed738a1 --- /dev/null +++ b/playground/tanstack-start/src/routes/api/env.ts @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { env } from 'void/env'; + +export const Route = createFileRoute('/api/env')({ + server: { + handlers: { + GET: () => Response.json({ SCHEMA_DEFAULT: env.SCHEMA_DEFAULT }), + }, + }, +}); diff --git a/playground/vinext-app/app/api/env/route.ts b/playground/vinext-app/app/api/env/route.ts new file mode 100644 index 000000000..b9413d08d --- /dev/null +++ b/playground/vinext-app/app/api/env/route.ts @@ -0,0 +1,7 @@ +import { env } from 'void/env'; + +export const dynamic = 'force-dynamic'; + +export function GET() { + return Response.json({ SCHEMA_DEFAULT: env.SCHEMA_DEFAULT }); +} diff --git a/playground/vinext-app/env.ts b/playground/vinext-app/env.ts new file mode 100644 index 000000000..4f9eb59b7 --- /dev/null +++ b/playground/vinext-app/env.ts @@ -0,0 +1,6 @@ +import { defineEnv, number } from 'void/env'; + +// Only the schema supplies this default, so a Worker that ignores env.ts reads undefined. +export default defineEnv({ + SCHEMA_DEFAULT: number().default(48213), +}); diff --git a/playground/vinext-pages/env.ts b/playground/vinext-pages/env.ts new file mode 100644 index 000000000..4f9eb59b7 --- /dev/null +++ b/playground/vinext-pages/env.ts @@ -0,0 +1,6 @@ +import { defineEnv, number } from 'void/env'; + +// Only the schema supplies this default, so a Worker that ignores env.ts reads undefined. +export default defineEnv({ + SCHEMA_DEFAULT: number().default(48213), +}); diff --git a/playground/vinext-pages/pages/api/env.ts b/playground/vinext-pages/pages/api/env.ts new file mode 100644 index 000000000..d538a1528 --- /dev/null +++ b/playground/vinext-pages/pages/api/env.ts @@ -0,0 +1,7 @@ +import { env } from 'void/env'; + +type ApiResponse = { status(code: number): { json(body: unknown): void } }; + +export default function handler(_request: unknown, response: ApiResponse) { + response.status(200).json({ SCHEMA_DEFAULT: env.SCHEMA_DEFAULT }); +} diff --git a/test/check-playground-build-artifacts.mjs b/test/check-playground-build-artifacts.mjs index 71590b4ef..dc35b823a 100644 --- a/test/check-playground-build-artifacts.mjs +++ b/test/check-playground-build-artifacts.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; // Run after build:all, so integration shards do not rebuild these playgrounds. const artifacts = { @@ -48,4 +48,30 @@ checkBindings('tanstack-start', 'dist/server/wrangler.json', { cron: '0 * * * *' checkBindings('vinext-app', 'dist/server/wrangler.json'); checkBindings('vinext-pages', 'dist/ssr/wrangler.json'); -console.log('Verified metaframework build artifacts and inferred bindings.'); +// Each of these playgrounds' env.ts declares this default, and nothing else mentions it. A server +// bundle without it reads raw values: a bundler can drop an import kept only for its side effect. +const ENV_SCHEMA_MARKER = '48213'; +const envSchemaServerDirectories = { + 'react-router': 'build/server', + 'tanstack-start': 'dist/server', + 'vinext-app': 'dist/server', + 'vinext-pages': 'dist/ssr', +}; + +function containsMarker(directory) { + return readdirSync(directory).some((name) => { + const path = new URL(encodeURIComponent(name), `${directory.href}/`); + return statSync(path).isDirectory() + ? containsMarker(path) + : /\.m?js$/.test(name) && readFileSync(path, 'utf8').includes(ENV_SCHEMA_MARKER); + }); +} + +for (const [playground, directory] of Object.entries(envSchemaServerDirectories)) { + assert.ok( + containsMarker(artifactUrl(playground, directory)), + `${playground} does not bundle env.ts into its server output`, + ); +} + +console.log('Verified metaframework build artifacts, inferred bindings and env schemas.'); From 6eb218b57c4ef4c9b216d1d4c0f885737856a994 Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Sat, 14 Nov 2026 01:09:40 +0900 Subject: [PATCH 2/3] test(env): let the Class A wrapper env tests import Windows paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PostgreSQL dev and build wrapper tests run the generated wrapper in Node. The wrapper imports env.ts, and the build wrapper also the routing runtime, by absolute path, which Vite resolves. Node reads a Windows path such as `C:/…` or `C:\…` as a URL with the scheme `c:` and rejects it with ERR_UNSUPPORTED_ESM_URL_SCHEME, so both tests would fail in the Windows unit shard. The runner now registers a resolve hook that gives Node a file URL for such a path, so the tests still run the wrapper as it is generated. Co-Authored-By: Claude Opus 5.5 --- .../test/unit/plugin-class-a-wrapper.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/void/test/unit/plugin-class-a-wrapper.test.ts b/packages/void/test/unit/plugin-class-a-wrapper.test.ts index 26433354a..ff11746da 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -545,6 +545,21 @@ export default defineEnv({ { path: '/TOKEN', expected: '200 {"TOKEN":"SK_TOKEN"}' }, ]; + /** + * The wrappers import env.ts and the routing runtime by absolute path, which Vite resolves. + * Node reads a Windows path (`C:/…` or `C:\…`) as a URL with the scheme `c:` and rejects it + * with ERR_UNSUPPORTED_ESM_URL_SCHEME, so the runner gives Node a file URL for it. + */ + const WINDOWS_PATH_HOOK = String.raw`import { registerHooks } from 'node:module'; +import { pathToFileURL } from 'node:url'; +registerHooks({ + resolve(specifier, context, nextResolve) { + const windowsPath = /^[A-Za-z]:[\\/]/.test(specifier); + return nextResolve(windowsPath ? pathToFileURL(specifier).href : specifier, context); + }, +}); +`; + /** Answer each request in a Node process, with a new env object for each, as dev can. */ function fetchInNode( root: string, @@ -556,7 +571,8 @@ export default defineEnv({ const runner = join(root, 'run.mjs'); writeFileSync( runner, - `const { default: worker } = await import(${JSON.stringify(pathToFileURL(path).href)});\n` + + WINDOWS_PATH_HOOK + + `const { default: worker } = await import(${JSON.stringify(pathToFileURL(path).href)});\n` + 'const results = [];\n' + `for (const { path, env } of ${JSON.stringify(requests)}) {\n` + ' const response = await worker.fetch(new Request("https://example.test" + path), { ...env }, {});\n' + From fbcf5f066bf907a568f1afc89d09905ab55f791e Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Sat, 14 Nov 2026 01:34:01 +0900 Subject: [PATCH 3/3] test(env): check that the env registry publishes a function The test that the copy of `void/env` registering a schema publishes its `_prepareEnv` compared the registry's entry with that copy's export. Without `_prepareEnv`, both are undefined, so the test passed without the change it covers. It now also checks that the entry is a function. Co-Authored-By: Claude Opus 5.5 --- packages/void/test/unit/env-schema.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/void/test/unit/env-schema.test.ts b/packages/void/test/unit/env-schema.test.ts index a4dd540ff..357b2d9a0 100644 --- a/packages/void/test/unit/env-schema.test.ts +++ b/packages/void/test/unit/env-schema.test.ts @@ -827,6 +827,7 @@ describe('several copies of void/env in one Worker', () => { it('publishes the _prepareEnv of the copy that registered the schema', async () => { const copy = await secondCopy(); defineEnv({ KEY: string() }); + expect(registry()?.prepare).toBeTypeOf('function'); expect(registry()?.prepare).toBe(_prepareEnv); copy.defineEnv({ KEY: string() }); expect(registry()?.prepare).toBe(copy._prepareEnv);