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..357b2d9a0 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,210 @@ 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).toBeTypeOf('function'); + 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..ff11746da 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,224 @@ 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"}' }, + ]; + + /** + * 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, + 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, + 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' + + ' 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.');