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 () => {