From 964dd4c6a83fdfd9aed25d17db48d8c83ac7273a Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Wed, 11 Nov 2026 22:47:38 +0900 Subject: [PATCH] fix(env): serve async env.ts values to Void app requests A Void app could not read an env.ts key whose validator is async: every request that read it failed with "Async validator ... cannot be resolved synchronously", in dev and in production builds. The generated entry validates the env before it opens the request scope. _validateEnvOnce cached the resolved values under the env that reads use outside a scope, while reads in the scope look them up under the env the handler received. _validateEnvOnce now seeds the cache of the env it receives. A completed validation is reused for the same raw values, so a schema is still validated once per isolate, also when a dev server passes a new env object for each request. An env with other values is validated again rather than given values validated for another, and keeps its own values: Cloudflare may keep an isolate running when only its bindings change, so requests that hold the old and the new values can overlap. A read through an env that was never validated itself, such as the remote dev copy or the cloudflare:workers env outside a request scope, takes the value from the latest validation while that env holds the raw value the value came from. Only a completed validation is reused. In workerd, a promise that waits on the I/O of a canceled request never settles, so each call runs its own validation until one completes. A call that finds another schema registered after it waited validates again with that schema. The per-env caches no longer stay referenced after their env is gone. Co-Authored-By: Claude Fable 5.2 --- packages/void/src/runtime/env-public.ts | 174 ++++++++++----- packages/void/test/unit/env-schema.test.ts | 209 +++++++++++++++++- .../void/test/unit/routing-runtime.test.ts | 84 +++++++ 3 files changed, 414 insertions(+), 53 deletions(-) diff --git a/packages/void/src/runtime/env-public.ts b/packages/void/src/runtime/env-public.ts index ff8ca3a75..a755087a4 100644 --- a/packages/void/src/runtime/env-public.ts +++ b/packages/void/src/runtime/env-public.ts @@ -36,11 +36,27 @@ import { getRuntimeEnv, isInternalBinding } from './env.ts'; type AnyValidator = StandardSchemaV1; type AnySchema = Record; -type BootValidationState = { ok: true } | { ok: false; error: EnvValidationError }; +type BootValidationState = + | { ok: true; values: Map } + | { ok: false; error: EnvValidationError }; + +/** A completed whole-schema validation, with the raw value of each key that it checked. */ +type EnvValidation = { + inputs: Map; + state: BootValidationState; +}; + type EnvRegistryState = { schema: AnySchema | null; hasDefineEnvBeenCalled: boolean; - bootValidation: BootValidationState | null; + /** + * 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. + */ + validation?: EnvValidation | null; }; // Tooling can load `void/env` through a different module instance than the Vite @@ -53,18 +69,16 @@ const registryGlobal = globalThis as typeof globalThis & { const registry = (registryGlobal[ENV_REGISTRY_SYMBOL] ??= { schema: null, hasDefineEnvBeenCalled: false, - bootValidation: null, }); -const validatedCache = new WeakMap>(); -// WeakMap does not expose its values; keep a parallel list so defineEnv can clear them. -const cacheList: Array> = []; +// 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); - cacheList.push(cache); } return cache; } @@ -108,57 +122,85 @@ export function defineEnv(schema: S): InferEnv { // 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). - for (const map of cacheList) { - map.clear(); - } + validatedCache = new WeakMap(); // Reset boot-time validation state so a re-imported schema is re-checked. - registry.bootValidation = null; + registry.validation = null; return env as unknown as InferEnv; } /** - * Internal: validate the whole schema against the worker env once per isolate, - * from the generated worker entry. Catches remote-secret values the deploy CLI - * cannot see. Async validators are awaited here and their values cached, so - * later synchronous `env.X` reads can serve them. + * Internal: validate the whole schema against the worker env, from the generated + * worker entry. Catches remote-secret values the deploy CLI cannot see. A + * completed validation is reused for the same raw values; an env with other + * values is validated again. Async validators are awaited here, and their values + * serve later synchronous `env.X` reads of the env this call receives, and of any + * other env that holds the same raw values. */ export async function _validateEnvOnce(envValues: unknown): Promise { - if (registry.bootValidation) { - if (!registry.bootValidation.ok) { - throw registry.bootValidation.error; - } + const schema = registry.schema; + if (!schema) { return; } - if (!registry.schema) { - registry.bootValidation = { ok: true }; - return; + const inputs = readSchemaInputs(schema, envValues); + const last = registry.validation; + let state: BootValidationState; + if (last && sameInputs(last.inputs, inputs)) { + state = last.state; + } else { + // 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); + if (registry.schema !== schema) { + // defineEnv registered another schema while this call waited. + return _validateEnvOnce(envValues); + } + registry.validation = { inputs, state }; } + if (!state.ok) { + throw state.error; + } + // 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); +} + +/** The raw value of each schema key that `readKey` would validate for this env. */ +function readSchemaInputs(schema: AnySchema, envValues: unknown): Map { + const values = (typeof envValues === 'object' && envValues !== null ? envValues : {}) as Record< + string, + unknown + >; // Public values belong to this build, not to the remote secret store. Keep // server rendering and boot validation consistent with the browser bundle. - const values = { - ...((envValues ?? {}) as Record), - ...getClientEnvValues(), - }; - const report = await validateAllEnvWithValues(registry.schema, values); - if (report.valid) { - // Seed the per-key cache, keyed by the raw env as in `readKey`. - try { - const cacheKey = getRawRuntimeEnv() as object; - const cache = getValidatedCache(cacheKey); - for (const [key, value] of report.values) { - // Internal bindings stay hidden even when the schema declares them. - if (isInternalBinding(key, cacheKey as RuntimeEnv)) { - continue; - } - if (!cache.has(key)) { - cache.set(key, value); - } - } - } catch { - // No runtime env available — no proxy reads will happen, so no seeding needed. + const clientValues = getClientEnvValues(); + return new Map( + Object.keys(schema).map((key) => [ + key, + Object.hasOwn(clientValues, key) ? clientValues[key] : values[key], + ]), + ); +} + +function sameInputs(a: Map, b: Map): boolean { + if (a.size !== b.size) { + return false; + } + for (const [key, value] of a) { + if (!b.has(key) || !Object.is(b.get(key), value)) { + return false; } - registry.bootValidation = { ok: true }; - return; + } + return true; +} + +async function validateSchema( + schema: AnySchema, + inputs: Map, +): Promise { + const report = await validateAllEnvWithValues(schema, Object.fromEntries(inputs)); + if (report.valid) { + return { ok: true, values: report.values }; } const lines: Array = ['Env validation failed at worker boot:']; for (const key of report.missing) { @@ -168,9 +210,25 @@ export async function _validateEnvOnce(envValues: unknown): Promise { // Messages are already redacted inside validateAllEnvWithValues. lines.push(` • ${key}: ${messages.join('; ')}`); } - const error = new EnvValidationError('__init__', [{ message: lines.join('\n') }]); - registry.bootValidation = { ok: false, error }; - throw 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) { + return; + } + const cache = getValidatedCache(rawEnv); + for (const [key, value] of values) { + // 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); + } + } } /** Internal: framework-only access to the registered schema (for plugin/CLI). */ @@ -263,6 +321,19 @@ function readKey(key: string): unknown { 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 result = validator['~standard'].validate(rawValue); if (result instanceof Promise) { throw new Error( @@ -327,9 +398,8 @@ export type EnvValidationReport = { missing: Array; invalid: Array<{ key: string; messages: Array }>; /** - * Resolved values for keys that validated successfully. Used to seed the - * per-env cache after async pre-validation so sync `env.X` reads hit the - * cache. + * Resolved values for keys that validated successfully. After async + * pre-validation they serve sync `env.X` reads. */ values: Map; }; @@ -353,7 +423,7 @@ export async function validateAllEnv( /** * Internal: shared core that validates a schema against values and also - * returns the resolved values (for cache seeding). + * returns the resolved values (for sync reads). */ async function validateAllEnvWithValues( schema: AnySchema, diff --git a/packages/void/test/unit/env-schema.test.ts b/packages/void/test/unit/env-schema.test.ts index dd213cce1..4b9f6fdb7 100644 --- a/packages/void/test/unit/env-schema.test.ts +++ b/packages/void/test/unit/env-schema.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vite-plus/test'; +import { afterEach, describe, it, expect, beforeEach } from 'vite-plus/test'; import { boolean, email, json, number, oneOf, string, url } from '../../src/runtime/env-helpers.ts'; import { defineEnv, @@ -373,8 +373,215 @@ describe('_validateEnvOnce (boot-time worker check)', () => { EnvValidationError, ); }); + + describe('serving async values to reads', () => { + const upper = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: async (v: unknown) => { + await Promise.resolve(); + return { value: String(v).toUpperCase() }; + }, + }, + }; + + // Void's Worker entry validates before it opens the request scope that reads run in. + it('serves them in a request scope opened after validation', async () => { + defineEnv({ TOKEN: upper }); + const rawEnv = { TOKEN: 'sk_test' }; + await _validateEnvOnce(rawEnv); + withRuntimeEnv(rawEnv, () => { + expect(env.TOKEN).toBe('SK_TEST'); + }); + }); + + // Reads can use an object that validation never received, such as the remote dev copy. + it('serves them to another env object that holds the same values', async () => { + defineEnv({ TOKEN: upper }); + await _validateEnvOnce({ TOKEN: 'sk_test' }); + withRuntimeEnv({ TOKEN: 'sk_test' }, () => { + expect(env.TOKEN).toBe('SK_TEST'); + }); + }); + + it('serves each key by its own raw value', async () => { + defineEnv({ PLAIN: string(), TOKEN: upper }); + await _validateEnvOnce({ PLAIN: 'a', TOKEN: 'a' }); + // TOKEN holds another value: the validated one is not served, and it has no async value. + withRuntimeEnv({ PLAIN: 'a', TOKEN: 'b' }, () => { + expect(env.PLAIN).toBe('a'); + expect(() => env.TOKEN).toThrow(/Async validator for 'TOKEN'/); + }); + // Only PLAIN holds another value: TOKEN's raw value is the validated one. + withRuntimeEnv({ PLAIN: 'b', TOKEN: 'a' }, () => { + expect([env.PLAIN, env.TOKEN]).toEqual(['b', 'A']); + }); + }); + + // A validation is reused only for an env with the same values. + it('validates an env with other values again', async () => { + defineEnv({ PLAIN: string(), TOKEN: upper }); + for (const value of ['a', 'b']) { + const rawEnv = { PLAIN: value, TOKEN: value }; + await _validateEnvOnce(rawEnv); + withRuntimeEnv(rawEnv, () => { + expect([env.PLAIN, env.TOKEN]).toEqual([value, value.toUpperCase()]); + }); + } + }); + + // Cloudflare can keep an isolate running when only its bindings change, so a request that + // holds the old values can still run when one with the new values validates. + it('keeps serving an env after an env with other values validates', async () => { + defineEnv({ TOKEN: upper }); + const before = { TOKEN: 'a' }; + // Another object with the same values reuses the validation of the first. + const reused = { TOKEN: 'a' }; + const after = { TOKEN: 'b' }; + await _validateEnvOnce(before); + await _validateEnvOnce(reused); + await _validateEnvOnce(after); + for (const rawEnv of [before, reused]) { + withRuntimeEnv(rawEnv, () => { + expect(env.TOKEN).toBe('A'); + }); + } + withRuntimeEnv(after, () => { + expect(env.TOKEN).toBe('B'); + }); + }); + + it('validates again once a failing value changes', async () => { + defineEnv({ TOKEN: string() }); + await expect(_validateEnvOnce({})).rejects.toBeInstanceOf(EnvValidationError); + const fixed = { TOKEN: 'sk_test' }; + await expect(_validateEnvOnce(fixed)).resolves.toBeUndefined(); + withRuntimeEnv(fixed, () => { + expect(env.TOKEN).toBe('sk_test'); + }); + }); + + // Reads outside a request scope use an env of their own: in workerd, the + // `cloudflare:workers` env, which is not the object that handlers receive. + describe('outside a request scope', () => { + const globalScope = globalThis as { __env__?: unknown }; + + afterEach(() => { + delete globalScope.__env__; + }); + + it('serves them when that env holds the same values', async () => { + defineEnv({ TOKEN: upper }); + globalScope.__env__ = { TOKEN: 'sk_test' }; + await _validateEnvOnce({ TOKEN: 'sk_test' }); + expect(env.TOKEN).toBe('SK_TEST'); + }); + + it('does not serve them when that env holds other values', async () => { + defineEnv({ PLAIN: string(), TOKEN: upper }); + globalScope.__env__ = { PLAIN: 'other', TOKEN: 'other' }; + await _validateEnvOnce({ PLAIN: 'sk_test', TOKEN: 'sk_test' }); + expect(env.PLAIN).toBe('other'); + expect(() => env.TOKEN).toThrow(/Async validator for 'TOKEN'/); + }); + + it('stops serving them once that env holds another value', async () => { + defineEnv({ TOKEN: upper }); + const outside = { TOKEN: 'sk_test' }; + globalScope.__env__ = outside; + await _validateEnvOnce({ TOKEN: 'sk_test' }); + expect(env.TOKEN).toBe('SK_TEST'); + outside.TOKEN = 'other'; + expect(() => env.TOKEN).toThrow(/Async validator for 'TOKEN'/); + }); + }); + }); + + it('runs a validator that throws again on the next call', async () => { + let calls = 0; + const flaky = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: async (v: unknown) => { + calls++; + if (calls === 1) { + throw new Error('lookup failed'); + } + return { value: v }; + }, + }, + }; + defineEnv({ TOKEN: flaky }); + await expect(_validateEnvOnce({ TOKEN: 'sk_test' })).rejects.toThrow('lookup failed'); + await expect(_validateEnvOnce({ TOKEN: 'sk_test' })).resolves.toBeUndefined(); + expect(calls).toBe(2); + }); + + // In workerd, a validation that waits on the I/O of a canceled request never settles. + it('does not wait on a validation that another call left unfinished', async () => { + let calls = 0; + const stalls = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: (value: unknown) => { + calls++; + return calls === 1 ? new Promise(() => {}) : Promise.resolve({ value }); + }, + }, + }; + defineEnv({ TOKEN: stalls }); + void _validateEnvOnce({ TOKEN: 'sk_test' }); + await expect(settlesWithin(_validateEnvOnce({ TOKEN: 'sk_test' }), 1000)).resolves.toBe( + undefined, + ); + }); + + // HMR can register a new schema while a validation awaits. + it('validates with a schema registered while it waited', async () => { + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const tagged = (tag: string, wait?: Promise) => ({ + '~standard': { + version: 1 as const, + vendor: 'test', + validate: async (value: unknown) => { + await wait; + return { value: `${tag}:${String(value)}` }; + }, + }, + }); + defineEnv({ TOKEN: tagged('old', gate) }); + const rawEnv = { TOKEN: 'sk_test' }; + const validating = _validateEnvOnce(rawEnv); + defineEnv({ TOKEN: tagged('new') }); + release(); + await validating; + withRuntimeEnv(rawEnv, () => { + expect(env.TOKEN).toBe('new:sk_test'); + }); + }); }); +/** Rejects when `promise` is still pending after `ms`, rather than at the test timeout. */ +async function settlesWithin(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`still pending after ${ms}ms`)), ms); + }), + ]); + } finally { + clearTimeout(timer); + } +} + describe('_validateEnvOnce — internal bindings stay hidden from void/env', () => { beforeEach(() => { resetSchema(); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 9c3c2c169..10d922081 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -8,6 +8,8 @@ import { PAGES_PROTOCOL_VERSION } from '../../../edge/src/pages-protocol.ts'; import { defer } from '../../src/pages/defer.ts'; import { ValidationError } from '../../src/pages/protocol.ts'; import { decodePageData, serializePageData } from '../../src/pages/serialize.ts'; +import { _validateEnvOnce, defineEnv, env } from '../../src/runtime/env-public.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; import { defineHandler, defineMiddleware, type CloudContext } from '../../src/runtime/handler.ts'; import { createClientAssetTags, createManifestLoader } from '../../src/runtime/routing-assets.ts'; import { createErrorRenderer } from '../../src/runtime/routing-errors.ts'; @@ -556,6 +558,88 @@ describe('typed Worker fetch execution', () => { expect(staticNotFound).toHaveBeenCalledTimes(1); expect(validateEnv).toHaveBeenCalledTimes(1); }); + // An async Standard Schema validator, like zod's async refine. + const upper = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: async (value: unknown) => ({ value: String(value).toUpperCase() }), + }, + }; + // The entry validates before it opens the request scope that the app's reads run in. + it('serves an env.ts value with an async validator to app requests', async () => { + defineEnv({ TOKEN: upper }); + try { + const app = new Hono(); + app.get('/token', (c) => c.json({ token: env.TOKEN })); + const fetch = worker({ app, validateEnv: _validateEnvOnce, withRuntimeEnv }); + for (const rawEnv of [{ TOKEN: 'sk_test' }, { TOKEN: 'sk_test' }]) { + const response = await fetch(new Request('https://app.test/token'), rawEnv, context); + expect(await response.json()).toEqual({ token: 'SK_TEST' }); + } + } finally { + defineEnv({}); + } + }); + // Remote dev mode replaces the env with a copy before it opens the request scope. + it('serves an env.ts value with an async validator to remote dev requests', async () => { + defineEnv({ TOKEN: upper }); + try { + const app = new Hono(); + app.get('/token', (c) => c.json({ token: env.TOKEN })); + const fetch = worker({ + app, + validateEnv: _validateEnvOnce, + withRuntimeEnv, + remoteEnv: (original) => ({ ...original }), + }); + const response = await fetch( + new Request('https://app.test/token'), + { TOKEN: 'sk_test', __VOID_REMOTE: '1' }, + context, + ); + expect(await response.json()).toEqual({ token: 'SK_TEST' }); + } finally { + defineEnv({}); + } + }); + // Cloudflare can keep an isolate running when only its bindings change, so a request that + // holds the old values can overlap one that holds the new values. + it('serves each of two overlapping requests the async value of its own env', async () => { + defineEnv({ TOKEN: upper }); + try { + // Both requests validate before either reads. + let waiting = 0; + let release = () => {}; + const bothValidated = new Promise((resolve) => { + release = resolve; + }); + const app = new Hono(); + app.get('/token', async (c) => { + if (++waiting === 2) { + release(); + } + await bothValidated; + return c.json({ token: env.TOKEN }); + }); + const fetch = worker({ app, validateEnv: _validateEnvOnce, withRuntimeEnv }); + const responses = await Promise.all( + ['a', 'b'].map((value) => + fetch(new Request('https://app.test/token'), { TOKEN: value }, context), + ), + ); + expect( + await Promise.all( + responses.map(async (response) => [response.status, await response.text()]), + ), + ).toEqual([ + [200, '{"token":"A"}'], + [200, '{"token":"B"}'], + ]); + } finally { + defineEnv({}); + } + }); it('returns a getPrerenderPaths() error to the prerender runner', async () => { const fetch = worker({ collectPrerenderPaths: async () => {