From f10c6773a5ee02da72497253c19a8d3c5e1339b3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 16:48:49 +0800 Subject: [PATCH 01/31] feat(config): add ai.gateway config field Add a top-level `ai` object to void.json config with an optional `gateway` string field. Introduces the `AiConfig` type, wires `ai?: AiConfig` into `VoidConfig`, validates the block (rejecting non-objects, unknown nested keys, and empty/non-string gateway), and adds the matching top-level `ai` property to schema.json. This is the config surface only; later tasks emit the __VOID_AI_GATEWAY worker var and consume it in the AI runtime. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/schema.json | 11 +++++++++++ packages/void/src/config.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/void/schema.json b/packages/void/schema.json index 03e162fb6..a0c1e29c9 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -363,6 +363,17 @@ "type": "string", "enum": ["pg"], "description": "Database backend. Omit for D1 (SQLite, default). Set to \"pg\" for PostgreSQL via Hyperdrive." + }, + "ai": { + "type": "object", + "description": "AI configuration. Route Workers AI inference through your own Cloudflare AI Gateway.", + "properties": { + "gateway": { + "type": "string", + "description": "Cloudflare AI Gateway name/slug to route ai.provider().fetch() through your own account." + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index 5bad1fc52..e59c790f0 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -55,6 +55,11 @@ export type AuthConfig = { providers?: Array; }; +/** AI configuration — route Workers AI inference through your own Cloudflare AI Gateway. */ +export type AiConfig = { + gateway?: string; +}; + /** Shape of the `void.json` config file. All fields optional. */ export type VoidConfig = { $schema?: string; @@ -69,6 +74,7 @@ export type VoidConfig = { remote?: boolean; worker?: WranglerConfig; database?: 'pg'; + ai?: AiConfig; }; const CONFIG_FILE = 'void.json'; @@ -881,6 +887,27 @@ export function readConfig(root: string): VoidConfig { } } + // Validate ai + if (obj.ai !== undefined) { + if (typeof obj.ai !== 'object' || obj.ai === null || Array.isArray(obj.ai)) { + throw new Error(`config: "ai" in ${CONFIG_FILE} must be an object.`); + } + + const aiCfg = obj.ai as Record; + for (const key of Object.keys(aiCfg)) { + if (key !== 'gateway') { + throw new Error(`config: Unknown key 'ai.${key}' in ${CONFIG_FILE}. Valid: gateway.`); + } + } + + if ( + aiCfg.gateway !== undefined && + (typeof aiCfg.gateway !== 'string' || aiCfg.gateway.length === 0) + ) { + throw new Error(`config: "ai.gateway" in ${CONFIG_FILE} must be a non-empty string.`); + } + } + return obj as VoidConfig; } From 3c2b52cfbc96a0ddd1242c0016ebbcf07f981848 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 16:54:23 +0800 Subject: [PATCH 02/31] feat(bindings): inject Workers AI binding + AI gateway var for self-host builds Add the exported pure helper `shouldBindWorkersAi` and wire it into `mergeBindings`: on real builds (`command === 'build'`) inject an `{ binding: 'AI' }` Workers AI binding into the built wrangler config, and emit a `__VOID_AI_GATEWAY` worker var when `config.ai.gateway` is set. Build-gated so dev/miniflare (which has no local Workers AI and uses the P2 __VOID_TOKEN proxy path) never binds env.AI. The `!resolvedAi` guard is load-bearing since mergeBindings' return is defu-merged over the resolved root-wrangler config and wins, preventing clobbering a user's own `ai` binding. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/index.ts | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 1dbec4f99..44949440f 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -343,6 +343,26 @@ function hasBinding(list: Array<{ binding: string }> | undefined, name: string): return Array.isArray(list) && list.some((b) => b.binding === name); } +/** + * Decide whether to inject a Workers AI binding into the built wrangler config. + * + * Build-only: dev/miniflare has no local Workers AI, so serve must never bind + * `env.AI` (dev uses the P2 `__VOID_TOKEN` proxy path instead). + * + * `!resolvedAi` is load-bearing: mergeBindings' return is defu-merged over the + * resolved root-wrangler config and wins, so skipping when the user already + * declared an `ai` binding avoids clobbering theirs. `!resultAi` is harmless + * defense-in-depth (nothing else in mergeBindings sets `result.ai`). + */ +export function shouldBindWorkersAi( + command: string, + needsAI: boolean, + resolvedAi: unknown, + resultAi: unknown, +): boolean { + return needsAI && command === 'build' && !resultAi && !resolvedAi; +} + const CLOUDFLARE_BUILTIN_IMPORT = /^cloudflare:/; const SANDBOX_SDK_IMPORT = '@cloudflare/sandbox'; const SANDBOX_SDK_IMPORT_FILTER = /^@cloudflare\/sandbox$/; @@ -981,6 +1001,34 @@ export function voidPlugin(options?: { result.r2_buckets = [{ binding: r2Name, bucket_name: 'default' }]; } + // Workers AI — self-host only. The managed deploy ignores dist/wrangler.json, + // so this binding only takes effect on a real `wrangler deploy`. Build-gated + // because dev/miniflare has no local Workers AI (dev uses the P2 + // __VOID_TOKEN proxy path). The `ai` config is a single object (not an array) + // and needs no external id, so it is safe to inject unconditionally here. + // Guard rationale: mergeBindings' return is defu-merged over `resolved` and + // wins, so `!resolvedAi` is load-bearing (prevents clobbering a user's own + // root-wrangler `ai` binding); `!resultAi` is harmless defense-in-depth + // (nothing else in mergeBindings ever sets `result.ai`). + if ( + shouldBindWorkersAi( + viteCommand, + bindings.needsAI, + (resolved as { ai?: unknown }).ai, + (result as { ai?: unknown }).ai, + ) + ) { + (result as Record).ai = { binding: 'AI' }; + } + + // AI Gateway id → runtime var (harmless in serve; only read by the P3 direct + // backend that routes Workers AI inference through the user's own Gateway). + if (config.ai?.gateway) { + const vars = (result.vars as Record) ?? {}; + vars.__VOID_AI_GATEWAY = config.ai.gateway; + result.vars = vars; + } + // Queues — add producer + consumer bindings for local dev if (queues.length > 0) { (result as Record).queues = { From cdee22424c1767df189544d963bcffe51840e5cc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 17:09:15 +0800 Subject: [PATCH 03/31] feat(ai): route ai.provider() through user AI Gateway on self-host P3 self-host (direct env.AI) previously threw on ai.provider().fetch(). Refactor the inline P3 object into makeDirectBackend, read the __VOID_AI_GATEWAY worker var, and make providerFetch route through the user's own Cloudflare AI Gateway (getUrl(provider)) with their own provider secret. providerFetch always returns a Response (501 no gateway, 400 unsafe path / bad key, 502 getUrl failure) and passes the gateway Response through directly. Port providerAuthDefaults, gatewayEndpointUrl, isUnsafeProviderPath and add a real toDirectBody (real BodyInit + content-type, not the proxy envelope). No self-host metering header. Delete unsupportedProviderFetch; broaden the final "Not available" error to mention binding Workers AI. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/runtime/ai.ts | 150 ++++++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 17 deletions(-) diff --git a/packages/void/src/runtime/ai.ts b/packages/void/src/runtime/ai.ts index 011bbf11d..a6ebec86d 100644 --- a/packages/void/src/runtime/ai.ts +++ b/packages/void/src/runtime/ai.ts @@ -11,6 +11,10 @@ const PROXY_URL = 'https://proxy.void.cloud/ai'; const STAGING_PROXY_URL = 'https://proxy.staging.void.cloud/ai'; const VOID_FILE_MARKER = '__voidFile'; +const PROVIDER_AUTH_DEFAULTS: Record = { + 'google-ai-studio': { header: 'x-goog-api-key', prefix: '' }, +}; + type SerializedFileInput = { [VOID_FILE_MARKER]: true; data: string; @@ -317,6 +321,62 @@ function getProxyUrl(runtimeEnv: RuntimeEnv): string { return runtimeEnv.__VOID_STAGING ? STAGING_PROXY_URL : PROXY_URL; } +function providerAuthDefaults(provider: string): { header: string; prefix: string } { + return PROVIDER_AUTH_DEFAULTS[provider] ?? { header: 'authorization', prefix: 'Bearer ' }; +} + +function gatewayEndpointUrl(baseUrl: string, endpoint: string): string { + const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + const normalizedEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint; + return `${normalizedBase}/${normalizedEndpoint}`; +} + +function isUnsafeProviderPath(path: string): boolean { + if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(path) || path.includes('\\')) { + return true; + } + + const pathname = path.split(/[?#]/, 1)[0] ?? ''; + for (const segment of pathname.split('/')) { + try { + if (decodeURIComponent(segment) === '..') { + return true; + } + } catch { + return true; + } + } + + return false; +} + +/** + * Build a real outgoing HTTP body for the user's own AI Gateway (P3 self-host). + * + * Unlike `serializeProviderBody`/`providerRequestBody` (which build the proxy + * envelope decoded on the proxy side), this emits an actual `BodyInit` + + * content-type for a direct `fetch` to the gateway. + */ +function toDirectBody(body: unknown): { body: BodyInit | undefined; contentType?: string } { + if (body === undefined || body === null) { + return { body: undefined }; + } + if (typeof body === 'string') { + return { body }; + } + if ( + body instanceof ReadableStream || + body instanceof FormData || + body instanceof URLSearchParams || + body instanceof Blob || + body instanceof ArrayBuffer || + isBinaryView(body) + ) { + return { body: body as BodyInit }; + } + return { body: JSON.stringify(body), contentType: 'application/json' }; +} + function resolveBackend(): AiBackend { const runtimeEnv = getRawRuntimeEnv(); @@ -336,21 +396,16 @@ function resolveBackend(): AiBackend { // Priority 3: Direct AI binding (user's own CF account) const directAi = runtimeEnv.AI as Ai | undefined; if (directAi) { - return { - run: directAi.run.bind(directAi), - async image(model, inputs, options) { - const { runOptions } = splitImageOptions(options); - return responseFromResult( - await directAi.run(model as keyof AiModels, inputs as never, runOptions as AiOptions), - ); - }, - providerFetch: unsupportedProviderFetch, - models: directAi.models.bind(directAi), - toMarkdown: directAi.toMarkdown?.bind(directAi) ?? unsupported('toMarkdown'), - }; + return makeDirectBackend( + directAi, + runtimeEnv, + runtimeEnv.__VOID_AI_GATEWAY as string | undefined, + ); } - throw new Error('ai: Not available. Run `void auth login` and `void project link` first.'); + throw new Error( + 'ai: Not available. Run `void auth login` and `void project link` first, or bind Workers AI (env.AI) for self-hosted deploys.', + ); } function unsupported(method: string): () => never { @@ -359,10 +414,6 @@ function unsupported(method: string): () => never { }; } -function unsupportedProviderFetch(): Promise { - throw new Error('ai: ai.provider().fetch() is not supported in this environment.'); -} - async function providerRequestBody( provider: string, path: string, @@ -502,6 +553,71 @@ function makeHttpBackend(token: string, projectId: string, runtimeEnv: RuntimeEn }; } +function makeDirectBackend( + directAi: Ai, + runtimeEnv: RuntimeEnv, + gatewayId: string | undefined, +): AiBackend { + return { + run: directAi.run.bind(directAi), + async image(model, inputs, options) { + const { runOptions } = splitImageOptions(options); + return responseFromResult( + await directAi.run(model as keyof AiModels, inputs as never, runOptions as AiOptions), + ); + }, + async providerFetch(provider, path, init, options) { + if (!gatewayId) { + return Response.json( + { + error: + 'ai: ai.provider().fetch() needs an AI Gateway when self-hosting. Set "ai": { "gateway": "" } in void.json.', + }, + { status: 501 }, + ); + } + if (isUnsafeProviderPath(path)) { + return Response.json( + { error: `ai: provider path '${path}' must be relative.` }, + { status: 400 }, + ); + } + let providerKey: string; + try { + providerKey = resolveProviderKey(provider, runtimeEnv, options); + } catch (e) { + return Response.json({ error: (e as Error).message }, { status: 400 }); + } + let baseUrl: string; + try { + baseUrl = await directAi.gateway(gatewayId).getUrl(provider); + } catch (e) { + return Response.json( + { error: `ai: gateway '${gatewayId}': ${(e as Error).message}` }, + { status: 502 }, + ); + } + const { body, contentType } = toDirectBody(init?.body); + const headers = new Headers(init?.headers); + const defaults = providerAuthDefaults(provider); + headers.set( + options?.apiKeyHeader ?? defaults.header, + `${options?.apiKeyPrefix ?? defaults.prefix}${providerKey}`, + ); + if (contentType && !headers.has('content-type')) { + headers.set('content-type', contentType); + } + return fetch(gatewayEndpointUrl(baseUrl, path), { + method: init?.method ?? 'POST', + headers, + body, + }); + }, + models: directAi.models.bind(directAi), + toMarkdown: directAi.toMarkdown?.bind(directAi) ?? unsupported('toMarkdown'), + }; +} + // Lazy-init backend on first access let _backend: AiBackend | undefined; function backend(): AiBackend { From a45dfb82335ebb8d52b7772fa2b661e2b7fb98c4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 17:29:11 +0800 Subject: [PATCH 04/31] test(ai): cover self-host AI binding, gateway providerFetch, config validation Add P3 direct-backend tests (gateway routing, provider auth, no metering, 501/400/502 error paths, JSON + multipart body serialization), top-level `ai` config validation, and kitchen-sink build assertions for the injected `ai` binding + `__VOID_AI_GATEWAY` var (relaxing the __VOID_ leak filter). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integration/kitchen-sink-build.test.ts | 17 +- packages/void/test/unit/ai.test.ts | 286 ++++++++++++++++++ packages/void/test/unit/config.test.ts | 63 ++++ playground/kitchen-sink/void.json | 3 + 4 files changed, 368 insertions(+), 1 deletion(-) diff --git a/packages/void/test/integration/kitchen-sink-build.test.ts b/packages/void/test/integration/kitchen-sink-build.test.ts index 5ba25ade1..97026d8ee 100644 --- a/packages/void/test/integration/kitchen-sink-build.test.ts +++ b/packages/void/test/integration/kitchen-sink-build.test.ts @@ -97,10 +97,25 @@ describe('playground/kitchen-sink build', () => { it('does not leak __VOID_* credentials into dist/ssr/wrangler.json', () => { const wranglerConfig = JSON.parse(readFileSync(join(DIST, 'ssr', 'wrangler.json'), 'utf-8')); const vars = (wranglerConfig.vars ?? {}) as Record; - const leakedKeys = Object.keys(vars).filter((key) => key.startsWith('__VOID_')); + // `__VOID_AI_GATEWAY` is a legitimate, non-credential runtime var emitted from + // `ai.gateway` in void.json (asserted below) — exclude it from the leak filter. + const leakedKeys = Object.keys(vars).filter( + (key) => key.startsWith('__VOID_') && key !== '__VOID_AI_GATEWAY', + ); expect(leakedKeys).toEqual([]); }); + it('binds Workers AI and emits the AI Gateway var for self-host deploys', () => { + const wranglerConfig = JSON.parse(readFileSync(join(DIST, 'ssr', 'wrangler.json'), 'utf-8')); + // kitchen-sink imports `void/ai` at request time, so the build infers + // `needsAI` and injects the Workers AI binding on `command === 'build'`. + expect(wranglerConfig.ai).toEqual({ binding: 'AI' }); + // `ai.gateway` in playground/kitchen-sink/void.json becomes the runtime var + // read by the P3 direct backend. + const vars = (wranglerConfig.vars ?? {}) as Record; + expect(vars.__VOID_AI_GATEWAY).toBe('kitchen-sink-gw'); + }); + it('keeps non-internal .env.production vars in dist/ssr/wrangler.json', () => { const wranglerConfig = JSON.parse(readFileSync(join(DIST, 'ssr', 'wrangler.json'), 'utf-8')); const vars = (wranglerConfig.vars ?? {}) as Record; diff --git a/packages/void/test/unit/ai.test.ts b/packages/void/test/unit/ai.test.ts index f750b0ef3..412faf78c 100644 --- a/packages/void/test/unit/ai.test.ts +++ b/packages/void/test/unit/ai.test.ts @@ -1,6 +1,29 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vite-plus/test'; import type { ai } from '../../src/runtime/ai.ts'; +/** + * Build a fake Workers AI binding (`env.AI`) for the P3 self-host backend. + * + * `gateway(id)` returns an object whose `getUrl(provider)` mock resolves to a + * realistic AI Gateway URL derived from BOTH the gateway id and provider, so + * the appended provider path can be asserted end-to-end. + */ +function makeDirectAi( + opts: { getUrl?: (id: string, provider: string) => Promise; run?: () => unknown } = {}, +) { + const getUrlImpl = + opts.getUrl ?? + (async (id: string, provider: string) => + `https://gateway.ai.cloudflare.com/v1/acct123/${id}/${provider}`); + const run = vi.fn(async (..._args: Array) => (opts.run ? opts.run() : { ok: true })); + const models = vi.fn(async () => ['@cf/meta/llama']); + const toMarkdown = vi.fn(async () => []); + const gateway = vi.fn((id: string) => ({ + getUrl: vi.fn((provider: string) => getUrlImpl(id, provider)), + })); + return { AI: { run, gateway, models, toMarkdown }, run, gateway }; +} + describe('runtime ai', () => { afterEach(() => { vi.restoreAllMocks(); @@ -327,4 +350,267 @@ describe('runtime ai', () => { }, ); }); + + // P3 self-host backend: only `env.AI` is bound (no Void proxy/token). Inference + // runs directly against the user's Workers AI binding, and `ai.provider().fetch` + // routes through the user's OWN AI Gateway using a project provider secret. + describe('direct AI backend (self-host, env.AI only)', () => { + it('selects the direct backend when only env.AI is present (ai.run → directAi.run)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + // Any accidental fall-through to the Void proxy would hit fetch — assert it doesn't. + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response('proxy-should-not-be-used', { status: 500 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run({ AI: direct.AI }, async () => { + const result = await ai.run('@cf/meta/llama', { prompt: 'Hi' }); + expect(result).toEqual({ ok: true }); + }); + + expect(direct.run).toHaveBeenCalledWith('@cf/meta/llama', { prompt: 'Hi' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('provider().fetch hits the user gateway with provider auth and no metering header', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ id: 'chatcmpl_direct' }), { + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + const response = await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o', messages: [{ role: 'user', content: 'Hello' }] }, + }); + // Gateway response passes through untouched. + expect(await response.json()).toEqual({ id: 'chatcmpl_direct' }); + }, + ); + + // Resolved the URL from the user's OWN gateway (awaited getUrl), not the Void proxy. + expect(direct.gateway).toHaveBeenCalledWith('my-gw'); + expect(direct.gateway.mock.results[0].value.getUrl).toHaveBeenCalledWith('openai'); + + expect(fetchMock).toHaveBeenCalledOnce(); + const url = fetchMock.mock.calls[0][0]; + const init = fetchMock.mock.calls[0][1]!; + expect(String(url).endsWith('/my-gw/openai/chat/completions')).toBe(true); + expect(init.method).toBe('POST'); + + const headers = new Headers(init.headers); + expect(headers.get('authorization')).toBe('Bearer sk-test'); + expect(headers.get('content-type')).toBe('application/json'); + // Self-host path must NOT attach managed metering / projectId headers. + expect(headers.get('cf-aig-metadata-projectId')).toBeNull(); + expect(headers.get('cf-aig-metadata')).toBeNull(); + + // Real provider payload — NOT the proxy envelope ({ action, provider, providerKey, ... }). + const sent = JSON.parse(init.body as string); + expect(sent).toEqual({ model: 'gpt-4o', messages: [{ role: 'user', content: 'Hello' }] }); + expect(sent).not.toHaveProperty('action'); + expect(sent).not.toHaveProperty('provider'); + expect(sent).not.toHaveProperty('providerKey'); + }); + + it('resolves to 501 when no AI gateway is configured', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run({ AI: direct.AI, OPENAI_API_KEY: 'sk-test' }, async () => { + const response = await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o' }, + }); + expect(response.status).toBe(501); + const body = (await response.json()) as { error: string }; + expect(body.error).toMatch(/gateway/i); + expect(body.error).toContain('void.json'); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(direct.gateway).not.toHaveBeenCalled(); + }); + + it('resolves to 400 naming the missing provider secret', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + // Gateway present + safe path, but OPENAI_API_KEY is absent. + await envContext.run({ AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw' }, async () => { + const response = await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o' }, + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('OPENAI_API_KEY'); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + // Key check happens before the gateway lookup. + expect(direct.gateway).not.toHaveBeenCalled(); + }); + + it('resolves to 502 when the gateway getUrl lookup rejects', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi({ + getUrl: async () => { + throw new Error('gateway 404 not found'); + }, + }); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + const response = await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o' }, + }); + expect(response.status).toBe(502); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('my-gw'); + }, + ); + + expect(direct.gateway).toHaveBeenCalledWith('my-gw'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('resolves to 400 for an unsafe absolute provider path', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + const response = await ai + .provider('openai') + .fetch('https://evil.example.com/v1/chat', { body: { model: 'gpt-4o' } }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toMatch(/must be relative/); + }, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + // Path is rejected before any gateway lookup or network call. + expect(direct.gateway).not.toHaveBeenCalled(); + }); + + it('serializes a direct JSON body as real application/json (not the proxy envelope)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + await ai.provider('openai').fetch('/chat/completions', { + body: { model: 'gpt-4o', messages: [{ role: 'user', content: 'Hello' }] }, + }); + }, + ); + + const init = fetchMock.mock.calls[0][1]!; + expect(typeof init.body).toBe('string'); + expect(new Headers(init.headers).get('content-type')).toBe('application/json'); + expect(JSON.parse(init.body as string)).toEqual({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + }); + }); + + it('forwards a direct multipart/form-data body untouched (fetch sets the boundary)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => new Response('ok'), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', OPENAI_API_KEY: 'sk-test' }, + async () => { + const form = new FormData(); + form.set('model', 'gpt-image-1.5'); + form.set('prompt', 'change the background'); + form.set('image', new Blob(['png-bytes'], { type: 'image/png' }), 'source.png'); + + await ai.provider('openai').fetch('/images/edits', { body: form }); + }, + ); + + const init = fetchMock.mock.calls[0][1]!; + // The self-host path passes FormData straight through — fetch itself sets + // the multipart content-type + boundary, so we must NOT force content-type + // and must NOT base64-envelope the parts like the managed proxy does. + expect(init.body).toBeInstanceOf(FormData); + expect(new Headers(init.headers).get('content-type')).toBeNull(); + expect(new Headers(init.headers).get('authorization')).toBe('Bearer sk-test'); + + const sentForm = init.body as FormData; + expect(sentForm.get('model')).toBe('gpt-image-1.5'); + expect(sentForm.get('prompt')).toBe('change the background'); + const image = sentForm.get('image'); + expect(image).toBeInstanceOf(Blob); + expect(await (image as Blob).text()).toBe('png-bytes'); + }); + }); }); diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index 9446cf6a2..a4e99e0ba 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -444,6 +444,69 @@ describe('readConfig', () => { }); }); +describe('top-level ai config validation', () => { + it('accepts an ai.gateway string', () => { + setup(JSON.stringify({ ai: { gateway: 'my-gateway' } })); + const config = readConfig(tmpDir); + expect(config.ai?.gateway).toBe('my-gateway'); + cleanup(); + }); + + it('accepts an empty top-level ai object', () => { + setup(JSON.stringify({ ai: {} })); + const config = readConfig(tmpDir); + expect(config.ai).toEqual({}); + cleanup(); + }); + + it('throws when ai is not an object (string)', () => { + setup(JSON.stringify({ ai: 'my-gateway' })); + expect(() => readConfig(tmpDir)).toThrow('"ai" in void.json must be an object'); + cleanup(); + }); + + it('throws when ai is an array', () => { + setup(JSON.stringify({ ai: ['my-gateway'] })); + expect(() => readConfig(tmpDir)).toThrow('"ai" in void.json must be an object'); + cleanup(); + }); + + it('throws when ai.gateway is not a string', () => { + setup(JSON.stringify({ ai: { gateway: 123 } })); + expect(() => readConfig(tmpDir)).toThrow( + '"ai.gateway" in void.json must be a non-empty string', + ); + cleanup(); + }); + + it('throws when ai.gateway is an empty string', () => { + setup(JSON.stringify({ ai: { gateway: '' } })); + expect(() => readConfig(tmpDir)).toThrow( + '"ai.gateway" in void.json must be a non-empty string', + ); + cleanup(); + }); + + it('rejects an unknown ai.* key', () => { + setup(JSON.stringify({ ai: { gateway: 'gw', provider: 'openai' } })); + expect(() => readConfig(tmpDir)).toThrow("Unknown key 'ai.provider'"); + cleanup(); + }); + + it('allows ai.gateway and inference.bindings.ai to coexist', () => { + setup( + JSON.stringify({ + ai: { gateway: 'my-gw' }, + inference: { bindings: { ai: true } }, + }), + ); + const config = readConfig(tmpDir); + expect(config.ai?.gateway).toBe('my-gw'); + expect(config.inference?.bindings?.ai).toBe(true); + cleanup(); + }); +}); + describe('readConfig migration errors', () => { it('throws when auth is not an object', () => { setup(JSON.stringify({ auth: ['github'] })); diff --git a/playground/kitchen-sink/void.json b/playground/kitchen-sink/void.json index a0c527e2b..67f72f7cf 100644 --- a/playground/kitchen-sink/void.json +++ b/playground/kitchen-sink/void.json @@ -5,5 +5,8 @@ }, "auth": { "providers": ["email"] + }, + "ai": { + "gateway": "kitchen-sink-gw" } } From 2e09808b05cf7a16079239536a400f37c6f7290a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 17:41:52 +0800 Subject: [PATCH 05/31] refactor(isr): extract shared collectRevalidate/hasRevalidate Move the per-page revalidate merge out of the void deploy CLI into a pure module packages/void/src/isr-config.ts so the managed deploy path and the future self-host build path cannot diverge. collectRevalidate takes the VoidConfig + already-computed PageScanResult and returns the same merged map deploy.ts produced before (byte-identical). Also adds hasRevalidate presence helper for the self-host build path. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/deploy.ts | 33 ++------------ packages/void/src/isr-config.ts | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 29 deletions(-) create mode 100644 packages/void/src/isr-config.ts diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 4f1e5146f..3e6a72793 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -33,6 +33,7 @@ import { import { scanJobsSync } from '../crons/scan.ts'; import { writeDrizzleConfig } from '../drizzle/config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; +import { collectRevalidate } from '../isr-config.ts'; import { voidWarn } from '../log.ts'; import { collectMigrations } from '../migrations/collect.ts'; import { assertJournalCoherence, validateMigrations } from '../migrations/validate.ts'; @@ -1717,40 +1718,14 @@ async function runFullDeploy( ? true : Boolean(validateSsrEntry(root, paths)) || (hasPages && deployConfig.output !== 'static'); - // Resolve revalidate config from void.json + per-page .server.ts exports - const vcRevalidate = readConfig(root).routing?.revalidate; - let revalidate: number | Record | undefined; - if (vcRevalidate != null) { - revalidate = vcRevalidate; - } - // Merge per-page revalidate exports (pages mode) + // Resolve revalidate config from void.json + per-page .server.ts exports. + // The page scan is also reused by the fully-static optimization below. const pageScan = existsSync(paths.pagesDir) ? routeScan.pages.pages.length > 0 ? routeScan.pages : await scanPages(root, { output: deployConfig.output, paths }) : null; - if (pageScan) { - const perPageRules: Record = {}; - for (const page of pageScan.pages) { - if (page.revalidate != null) { - perPageRules[page.pattern] = page.revalidate; - } - } - if (Object.keys(perPageRules).length > 0) { - if (typeof revalidate === 'number') { - // Global number → pathmap with "*" fallback, per-page entries first - revalidate = Object.fromEntries([...Object.entries(perPageRules), ['*', revalidate]]); - } else if (typeof revalidate === 'object') { - // Per-page entries first (higher precedence in first-match) - revalidate = Object.fromEntries([ - ...Object.entries(perPageRules), - ...Object.entries(revalidate), - ]); - } else { - revalidate = perPageRules; - } - } - } + const revalidate = collectRevalidate(deployConfig, pageScan); // Fully-static optimization: if output: "static" and all pages are prerendered // with no backend features, deploy as a static site (no worker needed) diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts new file mode 100644 index 000000000..2c6b649ca --- /dev/null +++ b/packages/void/src/isr-config.ts @@ -0,0 +1,78 @@ +import type { VoidConfig } from './config.ts'; +import type { PageScanResult } from './pages/types.ts'; + +/** + * Whether a resolved `revalidate` value represents an active ISR configuration. + * + * Mirrors the presence check used when writing the deploy manifest + * (`packages/void/src/cli/package.ts`): `null`/`undefined`/`0`/`{}` are treated + * as "no ISR", a positive number or a non-empty path map is treated as "ISR on". + */ +export function hasRevalidate(r: unknown): boolean { + if (r == null) { + return false; + } + if (typeof r === 'number') { + return r > 0; + } + if (typeof r === 'object') { + return Object.keys(r as object).length > 0; + } + return false; +} + +/** + * Merge the app's `void.json` `routing.revalidate` config with per-page + * `export const revalidate` rules discovered by the page scan, producing the + * final revalidate map that gets baked into the deploy manifest (managed + * `void deploy`) or the self-hosted worker build. + * + * This is the single source of truth for the merge so the managed and + * self-host build paths cannot diverge. Behaviour is preserved verbatim from + * the original inline logic in `void deploy`: + * + * - Config value seeds the result (`number | Record | undefined`). + * - Per-page rules (page pattern → revalidate seconds) are collected from the + * already-computed page scan (`null` when the app has no pages dir). + * - When there are per-page rules: + * - config is a plain number → path map with per-page entries first and the + * config number applied to `"*"` as a fallback; + * - config is an object map → per-page entries first (higher precedence in + * first-match ordering), then the config entries; + * - config is absent → the per-page rules alone. + * - Empty results (no config, no per-page rules) return `undefined`. + */ +export function collectRevalidate( + config: VoidConfig, + pageScan: PageScanResult | null, +): number | Record | undefined { + const vcRevalidate = config.routing?.revalidate; + let revalidate: number | Record | undefined; + if (vcRevalidate != null) { + revalidate = vcRevalidate; + } + // Merge per-page revalidate exports (pages mode) + if (pageScan) { + const perPageRules: Record = {}; + for (const page of pageScan.pages) { + if (page.revalidate != null) { + perPageRules[page.pattern] = page.revalidate; + } + } + if (Object.keys(perPageRules).length > 0) { + if (typeof revalidate === 'number') { + // Global number → pathmap with "*" fallback, per-page entries first + revalidate = Object.fromEntries([...Object.entries(perPageRules), ['*', revalidate]]); + } else if (typeof revalidate === 'object') { + // Per-page entries first (higher precedence in first-match) + revalidate = Object.fromEntries([ + ...Object.entries(perPageRules), + ...Object.entries(revalidate), + ]); + } else { + revalidate = perPageRules; + } + } + } + return revalidate; +} From 52f2a7289c2381584a0ef593a23d1430fb407d90 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 18:05:50 +0800 Subject: [PATCH 06/31] feat(isr): add single-tenant worker ISR cache module Port the platform dispatch ISR cache ladder into a single new runtime module for self-hosted (`vite build && wrangler deploy`) Void workers, collapsed from multi-tenant to single-tenant: - packIsrValue/unpackIsrValue, isStale, serializeHeaders, shouldBypassIsr, resolveRevalidateTtl, isrCacheResponse, resolvePathTtl + matchLiteralPattern/LRU, page-data extraction, and response builders ported from dispatch/isr.ts + index.ts. - readIsrCache/writeIsrCache/deleteIsrCache collapsed to (env, pathname[, resp, ttl]); deployment id derived from env.__VOID_ISR_DEPLOYMENT_ID (same source as the revalidate() purge path). No projectId, no #rw= rewrite variant, no variant cap. - Collapsed keys: isrKey / htmlEdgeKey / jsonEdgeKey. - handleIsr orchestrator + background revalidation (X-VoidPages stripped on re-render via a fresh cloned Request). - Every ISR_CACHE (KV) and caches.default op wrapped to fail open: read/hit failures bail to a live render, write failures swallowed. - writeIsrCache keeps the synchronous resp.clone() before any await. Wired into the worker entry in Task 7; behavior tested in Task 11. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/runtime/isr-cache.ts | 762 +++++++++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 packages/void/src/runtime/isr-cache.ts diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts new file mode 100644 index 000000000..7995a984d --- /dev/null +++ b/packages/void/src/runtime/isr-cache.ts @@ -0,0 +1,762 @@ +/** + * Single-tenant ISR (incremental static regeneration) cache ladder for the + * self-hosted Void worker (`vite build && wrangler deploy`). + * + * On managed Void, ISR lives in the dispatch worker that sits IN FRONT of the + * user worker. Self-host has no dispatch worker, so this module ports that + * caching ladder INTO the user worker, collapsed from multi-tenant to a single + * deployment: + * + * - No `projectId`, no cross-tenant metering. + * - No `#rw=` rewrite variant, no query-string variant fanout, no variant cap. + * - The deployment id is baked at build time as a compile-time literal and + * surfaced on `env.__VOID_ISR_DEPLOYMENT_ID` (Task 7). The KV helpers read + * it from `env` (matching the `revalidate()` purge path in `runtime/isr.ts`), + * while `handleIsr` uses the `deploymentId` option for the edge-cache keys — + * the two are the same value. + * + * Fail-open contract: every `env.ISR_CACHE` (KV) and `caches.default` operation + * is wrapped so a misconfigured or erroring cache degrades to a live render, + * never a 500. Read/hit-path failures bail (caller renders live); write-path + * failures are swallowed. + * + * Worker-only: no `lib.dom` types — globals come from `@cloudflare/workers-types`. + * + * Ported from `void-platform/packages/dispatch` (`isr.ts`, `index.ts`, + * `pattern.ts`) and `void-platform/packages/isr-shared`. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Minimal env surface this module needs. Both fields are optional so a worker + * with no ISR binding (or a caller that only knows the deployment id) still + * typechecks and fails open at runtime. + */ +export interface IsrCacheEnv { + ISR_CACHE?: KVNamespace; + __VOID_ISR_DEPLOYMENT_ID?: string; +} + +export type IsrCacheEntry = Readonly<{ + body: ReadableStream; + status: number; + headers: Record; + createdAt: number; + ttl: number; +}>; + +export type IsrCacheMetadata = Readonly<{ + /** HTTP status code */ + s: number; + /** createdAt (ms) */ + c: number; + /** TTL (seconds) */ + t: number; +}>; + +/** Renders a live response for a request (worker dispatch + asset serving). */ +export type RenderFn = (request: Request) => Promise; + +export type HandleIsrOptions = Readonly<{ + revalidate: number | Record | undefined; + deploymentId: string; +}>; + +// --------------------------------------------------------------------------- +// Path normalization (ported from isr-shared/src/index.ts) +// --------------------------------------------------------------------------- + +/** + * Build the normalized path segment for an ISR-cached page. + * Root path `/` maps to `__index__` to avoid trailing-slash ambiguity. + */ +export function normalizeIsrPath(pathname: string): string { + let normalized = pathname; + if (normalized === '/') { + normalized = '__index__'; + } else { + // Strip leading slash + normalized = normalized.startsWith('/') ? normalized.slice(1) : normalized; + // Strip trailing slash + if (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + } + return normalized; +} + +/** + * Inverse of `normalizeIsrPath`: turn a normalized segment back into a + * pathname starting with `/`. + */ +export function denormalizeIsrPath(normalized: string): string { + return normalized === '__index__' ? '/' : `/${normalized}`; +} + +/** + * Canonicalize an inbound pathname to the form used inside edge cache URLs. + * Equivalent to `denormalizeIsrPath(normalizeIsrPath(pathname))` — strips any + * trailing slash except for root. + */ +export function canonicalIsrPathname(pathname: string): string { + return denormalizeIsrPath(normalizeIsrPath(pathname)); +} + +// --------------------------------------------------------------------------- +// Collapsed single-tenant cache keys +// --------------------------------------------------------------------------- + +/** + * KV cache key for an ISR-cached page. Deployment-scoped so cached HTML cannot + * outlive the hashed assets referenced by the deployment that produced it + * (a fresh `wrangler deploy` mints a new deployment id → cold cache). + */ +export function isrKey(deploymentId: string, pathname: string): string { + return `void-isr/${deploymentId}/${normalizeIsrPath(pathname)}`; +} + +/** + * Edge-cache (`caches.default`) key for the HTML variant. The public URL stays + * clean; this internal key carries the deployment id so a new deploy never + * reads HTML produced by an older deploy. + */ +export function htmlEdgeKey(host: string, pathname: string, id: string): string { + return `https://${host}${canonicalIsrPathname(pathname)}?__void_isr=${id}`; +} + +/** + * Edge-cache key for the JSON (pages-protocol) variant. Separated from HTML by + * the `&__void=json` marker. + */ +export function jsonEdgeKey(host: string, pathname: string, id: string): string { + return `${htmlEdgeKey(host, pathname, id)}&__void=json`; +} + +// --------------------------------------------------------------------------- +// Literal path-pattern matching + LRU (ported from dispatch/src/pattern.ts) +// --------------------------------------------------------------------------- + +const MAX_CACHE = 256; +const literalRegexCache = new Map(); + +/** + * Match a pattern against a pathname using LITERAL semantics — no named + * captures. Only `*` is special (matches any characters including `/`); every + * other character (including `:`) is treated as a literal. Malformed patterns + * yield `false` rather than throwing. + */ +export function matchLiteralPattern(pattern: string, pathname: string): boolean { + let regex = literalRegexCache.get(pattern); + if (regex) { + // LRU touch — re-inserting moves the entry to the most-recently-used end. + literalRegexCache.delete(pattern); + literalRegexCache.set(pattern, regex); + } else { + try { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*'); + regex = new RegExp(`^${escaped}$`); + } catch { + return false; + } + if (literalRegexCache.size >= MAX_CACHE) { + const first = literalRegexCache.keys().next(); + if (!first.done) { + literalRegexCache.delete(first.value); + } + } + literalRegexCache.set(pattern, regex); + } + try { + return regex.test(pathname); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Pack / unpack (ported verbatim from dispatch/src/isr.ts) +// --------------------------------------------------------------------------- + +/** + * Pack response headers + body into a single ArrayBuffer. + * Format: [4 bytes uint32 LE header length][UTF-8 header JSON][raw body] + */ +export function packIsrValue(headers: Record, body: ArrayBuffer): ArrayBuffer { + const headerJson = new TextEncoder().encode(JSON.stringify(headers)); + const buffer = new ArrayBuffer(4 + headerJson.byteLength + body.byteLength); + const view = new DataView(buffer); + view.setUint32(0, headerJson.byteLength, true); // little-endian + new Uint8Array(buffer, 4, headerJson.byteLength).set(headerJson); + new Uint8Array(buffer, 4 + headerJson.byteLength).set(new Uint8Array(body)); + return buffer; +} + +/** + * Unpack headers + body from a packed ArrayBuffer. + * Returns null if the buffer is too small or malformed. + */ +export function unpackIsrValue( + buffer: ArrayBuffer, +): { headers: Record; body: ArrayBuffer } | null { + if (buffer.byteLength < 4) { + return null; + } + const view = new DataView(buffer); + const headerLen = view.getUint32(0, true); + if (buffer.byteLength < 4 + headerLen) { + return null; + } + try { + const headerBytes = new Uint8Array(buffer, 4, headerLen); + const headers: Record = JSON.parse(new TextDecoder().decode(headerBytes)); + const body = buffer.slice(4 + headerLen); + return { headers, body }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// KV cache (single-tenant; fail-open on every ISR_CACHE op) +// --------------------------------------------------------------------------- + +/** + * Read an ISR cache entry from KV. Returns null on cache miss, malformed + * metadata, or ANY KV error (fail-open → caller renders live). The KV key is + * deployment-scoped via `env.__VOID_ISR_DEPLOYMENT_ID` (see module header). + */ +export async function readIsrCache( + env: IsrCacheEnv, + pathname: string, +): Promise { + const kv = env.ISR_CACHE; + if (!kv) { + return null; + } + const key = isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', pathname); + let value: ArrayBuffer | null; + let metadata: IsrCacheMetadata | null; + try { + const result = await kv.getWithMetadata(key, 'arrayBuffer'); + value = result.value; + metadata = result.metadata; + } catch { + // Fail open — a broken ISR_CACHE must degrade to a live render, never 500. + return null; + } + if (!value || !metadata) { + return null; + } + + const unpacked = unpackIsrValue(value); + if (!unpacked) { + return null; + } + + if (!metadata.c || !metadata.t) { + return null; + } + + return { + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(unpacked.body)); + controller.close(); + }, + }), + status: metadata.s, + headers: unpacked.headers, + createdAt: metadata.c, + ttl: metadata.t, + }; +} + +/** + * Write an ISR cache entry to KV. The `response.clone()` is taken SYNCHRONOUSLY + * before any `await` so a caller that consumes `response.body` for the client + * response (after kicking us off via `ctx.waitUntil`) cannot race the body + * away. Any KV error is swallowed (fail-open on the write path). + */ +export async function writeIsrCache( + env: IsrCacheEnv, + pathname: string, + response: Response, + ttl: number, +): Promise { + const kv = env.ISR_CACHE; + if (!kv) { + return; + } + // Clone synchronously (no await before this) — the clone has its own + // independent body; the original is free to be consumed elsewhere. + const responseClone = response.clone(); + const headers = serializeHeaders(responseClone); + const status = responseClone.status; + + try { + const body = await responseClone.arrayBuffer(); + const packed = packIsrValue(headers, body); + await kv.put(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', pathname), packed, { + metadata: { + s: status, + c: Date.now(), + t: ttl, + } satisfies IsrCacheMetadata, + }); + } catch { + // Fail open — swallow write errors so a broken ISR_CACHE never 500s. + } +} + +/** + * Delete an ISR cache entry from KV. Used when a worker permanently opts out + * via `x-revalidate: 0`. Any KV error is swallowed (fail-open). + */ +export async function deleteIsrCache(env: IsrCacheEnv, pathname: string): Promise { + const kv = env.ISR_CACHE; + if (!kv) { + return; + } + try { + await kv.delete(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', pathname)); + } catch { + // Fail open. + } +} + +// --------------------------------------------------------------------------- +// Cache-entry helpers (ported verbatim from dispatch/src/isr.ts) +// --------------------------------------------------------------------------- + +/** Check if a cached entry is stale. */ +export function isStale(entry: IsrCacheEntry): boolean { + return Date.now() - entry.createdAt > entry.ttl * 1000; +} + +/** Build a Response from an ISR cache entry. */ +export function isrCacheResponse(entry: IsrCacheEntry, stale: boolean): Response { + const headers = new Headers(entry.headers); + headers.set('x-isr-cache', stale ? 'STALE' : 'HIT'); + + // Edge-cacheable: s-maxage controls CDN TTL based on remaining ISR freshness. + // Browser always revalidates (max-age=0) so users see updates after edge expiry. + if (stale) { + // Stale: short edge TTL while background revalidation runs. + // Cap at entry.ttl so short-TTL configs don't serve stale longer than their ISR window. + const staleTtl = Math.min(10, entry.ttl); + headers.set('Cache-Control', `public, s-maxage=${staleTtl}, max-age=0, must-revalidate`); + } else { + const remainingSeconds = Math.max( + 0, + Math.floor((entry.createdAt + entry.ttl * 1000 - Date.now()) / 1000), + ); + headers.set( + 'Cache-Control', + `public, s-maxage=${remainingSeconds}, max-age=0, must-revalidate`, + ); + } + + return new Response(entry.body, { + status: entry.status, + headers, + }); +} + +/** + * Read the `x-revalidate` header from a worker response. + * Returns the override TTL, or the default. A value of 0 means "do not cache". + */ +export function resolveRevalidateTtl(response: Response, defaultTtl: number): number { + const header = response.headers.get('x-revalidate'); + if (header === null) { + return defaultTtl; + } + + const value = parseInt(header, 10); + if (isNaN(value) || value < 0) { + return defaultTtl; + } + return value; +} + +function acceptsEventStream(request: Request): boolean { + return ( + request.headers + .get('Accept') + ?.toLowerCase() + .split(',') + .some((part) => part.split(';', 1)[0]?.trim() === 'text/event-stream') ?? false + ); +} + +/** + * ISR should be bypassed for requests with credentials and EventSource + * requests. Pages protocol navigation (X-VoidPages) uses a separate JSON edge + * cache. + */ +export function shouldBypassIsr(request: Request): boolean { + return ( + request.headers.has('Cookie') || + request.headers.has('Authorization') || + acceptsEventStream(request) + ); +} + +/** + * Resolve the ISR TTL for a given pathname. + * Accepts the `revalidate` config (number, object, or undefined). For the + * object form, the first matching pattern wins. + */ +export function resolvePathTtl( + revalidate: number | Record | undefined, + pathname: string, +): number { + if (revalidate == null) { + return 0; + } + if (typeof revalidate === 'number') { + return revalidate; + } + for (const [pattern, ttl] of Object.entries(revalidate)) { + if (matchLiteralPattern(pattern, pathname)) { + return ttl; + } + } + return 0; +} + +/** Extract response headers as a plain object, skipping ISR-managed headers. */ +const SKIP_HEADERS = new Set(['cache-control', 'x-isr-cache', 'x-void-page-data', 'set-cookie']); + +export function serializeHeaders(response: Response): Record { + const result: Record = {}; + + response.headers.forEach((value, key) => { + if (!SKIP_HEADERS.has(key.toLowerCase())) { + result[key] = value; + } + }); + + return result; +} + +// --------------------------------------------------------------------------- +// Page-data extraction for the JSON edge variant (ported from dispatch/src/isr.ts) +// --------------------------------------------------------------------------- + +const PAGE_DATA_SCRIPT_RE = + /]*\bid=(["'])__VOID_PAGE_DATA__\1[^>]*>([\s\S]*?)<\/script>/i; + +export function normalizePageDataForJsonCache(raw: string): string | null { + try { + const parsed = JSON.parse(raw) as { deferred?: unknown; deferredKeys?: unknown }; + if (parsed.deferred !== undefined || parsed.deferredKeys !== undefined) { + return null; + } + return raw; + } catch { + return null; + } +} + +export function extractPageDataFromHtml(html: string): string | null { + const match = html.match(PAGE_DATA_SCRIPT_RE); + if (!match) { + return null; + } + return normalizePageDataForJsonCache(match[2]!); +} + +// --------------------------------------------------------------------------- +// Response builders (ported from dispatch/src/index.ts:61–101) +// --------------------------------------------------------------------------- + +const CLIENT_REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate'; + +export function clientRevalidateResponse(response: Response): Response { + const res = new Response(response.body, response); + res.headers.set('Cache-Control', CLIENT_REVALIDATE_CACHE_CONTROL); + return res; +} + +export function isEventStreamResponse(response: Response): boolean { + return ( + response.headers.get('content-type')?.toLowerCase().split(';', 1)[0]?.trim() === + 'text/event-stream' + ); +} + +function jsonCacheResponse(pageData: string, ttl: number, state: 'HIT' | 'MISS'): Response { + return new Response(pageData, { + headers: { + 'content-type': 'application/json', + 'Cache-Control': `public, s-maxage=${ttl}, max-age=0, must-revalidate`, + 'x-isr-cache': state, + }, + }); +} + +/** + * Populate the JSON edge cache from a response's embedded page data (the + * `x-void-page-data` header or the `__VOID_PAGE_DATA__` inline script). No-ops + * when there is no extractable page data. Any cache error is swallowed + * (fail-open) so this is always safe to fire inside `ctx.waitUntil`. + */ +export async function populateJsonEdgeCache( + cache: Cache, + cacheKey: string, + response: Response, + ttl: number, + state: 'HIT' | 'MISS', +): Promise { + try { + const fromHeader = response.headers.get('x-void-page-data'); + const pageData = fromHeader + ? normalizePageDataForJsonCache(fromHeader) + : extractPageDataFromHtml(await response.clone().text()); + if (!pageData) { + return; + } + await cache.put(cacheKey, jsonCacheResponse(pageData, ttl, state)); + } catch { + // Fail open — a broken edge cache must never 500. + } +} + +// --------------------------------------------------------------------------- +// Fail-open edge-cache wrappers +// --------------------------------------------------------------------------- + +/** + * The per-colo edge cache (`caches.default`). Accessed through a cast because + * this package has both `@types/node` and `@cloudflare/workers-types` in scope, + * and Node's ambient `CacheStorage` (which lacks `.default`) otherwise shadows + * the Workers one on the `caches` global. + */ +function edgeCache(): Cache { + return (caches as unknown as { default: Cache }).default; +} + +async function safeCachePut(cache: Cache, key: string, response: Response): Promise { + try { + await cache.put(key, response); + } catch { + // Fail open. + } +} + +async function safeCacheDelete(cache: Cache, key: string): Promise { + try { + await cache.delete(key); + } catch { + // Fail open. + } +} + +// --------------------------------------------------------------------------- +// Background revalidation (ported from dispatch/src/index.ts:1206–1295, +// stripped of getWorker / applyWorkerFallbackRules / maybeServeWfpAssetFallback +// / rebuildRequest — single-tenant re-renders through the `render` closure) +// --------------------------------------------------------------------------- + +/** + * Build a new Request with the `X-VoidPages` header removed. Headers on an + * existing Request are immutable, so we must clone into a fresh Request. ISR + * only runs for GET, so there is no body to preserve. + */ +function cloneWithoutVoidPages(request: Request): Request { + const headers = new Headers(request.headers); + headers.delete('X-VoidPages'); + return new Request(request, { headers }); +} + +/** + * Revalidate an ISR page in the background. Re-renders as HTML (X-VoidPages + * stripped), writes fresh HTML to KV + HTML edge cache, and populates the JSON + * edge cache from embedded page data. Errors are caught and logged — serve + * stale, don't retry. `ttl === 0` means the worker permanently opted out: + * delete the KV entry and both edge variants. + */ +async function revalidateInBackground( + env: IsrCacheEnv, + request: Request, + pathname: string, + defaultTtl: number, + keys: { htmlKey: string; jsonKey: string }, + render: RenderFn, +): Promise { + try { + const cleanRequest = cloneWithoutVoidPages(request); + const response = await render(cleanRequest); + if (isEventStreamResponse(response)) { + return; + } + const ttl = resolveRevalidateTtl(response, defaultTtl); + const cache = edgeCache(); + if (ttl > 0 && response.ok) { + await writeIsrCache(env, pathname, response, ttl); + // Update HTML edge cache with the fresh response. + const headers = new Headers(response.headers); + headers.set('x-isr-cache', 'HIT'); + headers.set('Cache-Control', `public, s-maxage=${ttl}, max-age=0, must-revalidate`); + headers.delete('set-cookie'); + headers.delete('x-void-page-data'); + await safeCachePut( + cache, + keys.htmlKey, + new Response(response.clone().body, { + status: response.status, + headers, + }), + ); + await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, 'HIT'); + } else if (ttl === 0) { + // Worker permanently opted out — remove KV + both edge variants. + await deleteIsrCache(env, pathname); + await safeCacheDelete(cache, keys.htmlKey); + await safeCacheDelete(cache, keys.jsonKey); + } + } catch (err) { + console.error('ISR revalidation failed:', err); + } +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +/** + * Run the ISR caching ladder for a request. + * + * Returns a cached/rendered `Response` when ISR applies, or `null` when the + * caller should render live itself (request not ISR-eligible, or a read/hit + * cache op failed open). + * + * Ladder: + * - Bail (`null`) if not GET, `shouldBypassIsr`, or the path has no ISR TTL. + * - JSON (X-VoidPages) path: edge match → hit serves `clientRevalidateResponse`; + * miss renders JSON and populates the JSON edge cache in the background. + * - HTML path: L1 edge match → L2 KV. Hit serves `isrCacheResponse` + * (stale hits also queue a background revalidation). Miss renders live and, + * when cacheable, writes KV + JSON edge + HTML edge in the background. + */ +export async function handleIsr( + request: Request, + env: IsrCacheEnv, + ctx: ExecutionContext, + options: HandleIsrOptions, + render: RenderFn, +): Promise { + const { revalidate, deploymentId } = options; + + if (request.method !== 'GET') { + return null; + } + if (shouldBypassIsr(request)) { + return null; + } + + const url = new URL(request.url); + const pathname = url.pathname; + const isrTtl = resolvePathTtl(revalidate, pathname); + if (isrTtl <= 0) { + return null; + } + + // Keep the KV helpers (which derive the deployment id from env — matching the + // `revalidate()` purge path) aligned with the edge keys built here. Task 7 + // already sets this at the top of the fetch handler; the `??=` makes the + // module correct in isolation when a caller only passes the option. + env.__VOID_ISR_DEPLOYMENT_ID ??= deploymentId; + + const host = url.hostname; + const cache = edgeCache(); + + // Pages protocol: JSON edge cache only (cold colo re-renders JSON). + if (request.headers.has('X-VoidPages')) { + const jsonKey = jsonEdgeKey(host, pathname, deploymentId); + let jsonCached: Response | undefined; + try { + jsonCached = await cache.match(jsonKey); + } catch { + return null; // fail open → caller renders live + } + if (jsonCached) { + return clientRevalidateResponse(jsonCached); + } + // Miss — render JSON, populate the JSON edge cache in the background. + const response = await render(request); + if (!isEventStreamResponse(response)) { + const ttl = resolveRevalidateTtl(response, isrTtl); + if (ttl > 0 && response.ok) { + ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, 'MISS')); + } + } + return response; + } + + // HTML path: L1 edge → L2 KV → live render. + const htmlKey = htmlEdgeKey(host, pathname, deploymentId); + const jsonKey = jsonEdgeKey(host, pathname, deploymentId); + + // L1: edge cache (per-colo, sub-ms). + let edgeCached: Response | undefined; + try { + edgeCached = await cache.match(htmlKey); + } catch { + return null; // fail open → caller renders live + } + if (edgeCached) { + return clientRevalidateResponse(edgeCached); + } + + // L2: KV (global, authoritative). `readIsrCache` fails open to null. + const cached = await readIsrCache(env, pathname); + if (cached) { + const stale = isStale(cached); + if (stale) { + ctx.waitUntil( + revalidateInBackground(env, request, pathname, isrTtl, { htmlKey, jsonKey }, render), + ); + } + const cacheResponse = isrCacheResponse(cached, stale); + // Warm the edge cache from KV; client consumes the original body. + ctx.waitUntil(safeCachePut(cache, htmlKey, cacheResponse.clone())); + return clientRevalidateResponse(cacheResponse); + } + + // Miss: render live, cache in the background (only when cacheable). + const response = await render(request); + if (isEventStreamResponse(response)) { + return response; + } + const ttl = resolveRevalidateTtl(response, isrTtl); + if (ttl > 0 && response.ok) { + // Every clone below is taken synchronously (before `response.body` is read + // for the client) so no consumer races the body away. + ctx.waitUntil(writeIsrCache(env, pathname, response, ttl)); + ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, 'MISS')); + + const cacheHeaders = new Headers(response.headers); + cacheHeaders.set('Cache-Control', `public, s-maxage=${ttl}, max-age=0, must-revalidate`); + cacheHeaders.set('x-isr-cache', 'MISS'); + cacheHeaders.delete('x-void-page-data'); + cacheHeaders.delete('set-cookie'); + const cacheResponse = new Response(response.clone().body, { + status: response.status, + headers: cacheHeaders, + }); + ctx.waitUntil(safeCachePut(cache, htmlKey, cacheResponse)); + + const clientHeaders = new Headers(cacheHeaders); + clientHeaders.set('Cache-Control', CLIENT_REVALIDATE_CACHE_CONTROL); + return new Response(response.body, { + status: response.status, + headers: clientHeaders, + }); + } + return response; +} From 93f050f055cb8f04ab341f91e1855faab0656b15 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 18:30:23 +0800 Subject: [PATCH 07/31] fix(isr): drop dead JSON-miss edge populate + clarify cf/JSON comments The JSON (X-VoidPages) miss path called populateJsonEdgeCache, but a pages-protocol JSON response carries no x-void-page-data header and no __VOID_PAGE_DATA__ script, so it always no-ops there. Remove the dead call (and its now-unused locals) so the JSON miss just renders and returns live JSON, matching the platform dispatch behavior. The JSON edge variant is still warmed as a side-effect of the HTML render path. Also clarify comments: handleIsr's JSON-path docstring, and a note at cloneWithoutVoidPages that the Request-OBJECT form preserves request.cf. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/runtime/isr-cache.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts index 7995a984d..1f183f8bd 100644 --- a/packages/void/src/runtime/isr-cache.ts +++ b/packages/void/src/runtime/isr-cache.ts @@ -570,6 +570,10 @@ async function safeCacheDelete(cache: Cache, key: string): Promise { function cloneWithoutVoidPages(request: Request): Request { const headers = new Headers(request.headers); headers.delete('X-VoidPages'); + // Construct from the Request OBJECT (not a string URL): workerd copies the + // `cf` blob from the input Request, so `request.cf` is preserved on the clone. + // Do not "simplify" this into `new Request(request.url, { headers })` — the + // string-URL form drops `cf`. return new Request(request, { headers }); } @@ -638,7 +642,9 @@ async function revalidateInBackground( * Ladder: * - Bail (`null`) if not GET, `shouldBypassIsr`, or the path has no ISR TTL. * - JSON (X-VoidPages) path: edge match → hit serves `clientRevalidateResponse`; - * miss renders JSON and populates the JSON edge cache in the background. + * miss renders live JSON and returns it — the JSON path never writes the edge + * cache. The JSON edge variant is warmed only as a side-effect of the HTML + * render path (cold colo re-renders JSON). * - HTML path: L1 edge match → L2 KV. Hit serves `isrCacheResponse` * (stale hits also queue a background revalidation). Miss renders live and, * when cacheable, writes KV + JSON edge + HTML edge in the background. @@ -687,15 +693,11 @@ export async function handleIsr( if (jsonCached) { return clientRevalidateResponse(jsonCached); } - // Miss — render JSON, populate the JSON edge cache in the background. - const response = await render(request); - if (!isEventStreamResponse(response)) { - const ttl = resolveRevalidateTtl(response, isrTtl); - if (ttl > 0 && response.ok) { - ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, 'MISS')); - } - } - return response; + // Miss — render live JSON and return it. The JSON path never writes the edge + // cache: a pages-protocol JSON response carries no embedded page data, so the + // JSON edge variant is warmed only as a side-effect of the HTML render path + // (cold colo re-renders JSON). Matches the platform dispatch behavior. + return render(request); } // HTML path: L1 edge → L2 KV → live render. From 85df4d7c878447c10971ef751c1fa263c2891ea3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 18:44:00 +0800 Subject: [PATCH 08/31] fix(isr): fail open on corrupt KV cache entries (never 500) Validate ISR cache entries at the readIsrCache choke point so the hit path can never throw a 500 from a corrupt/tampered/schema-drifted KV entry. Reject non-integer/out-of-range statuses, non-finite/non-positive createdAt & ttl, and non-constructible headers -> render live. Also wrap the L2 KV-hit response construction in handleIsr in a belt-and-suspenders try/catch that falls open on any throw. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/runtime/isr-cache.ts | 51 +++++++++++++++++++++----- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts index 1f183f8bd..761b32a1c 100644 --- a/packages/void/src/runtime/isr-cache.ts +++ b/packages/void/src/runtime/isr-cache.ts @@ -256,7 +256,30 @@ export async function readIsrCache( return null; } - if (!metadata.c || !metadata.t) { + // Validate at this single choke point so `isrCacheResponse` — the only consumer + // of the returned entry — can never throw when it builds a Response from it. A + // corrupt/tampered/schema-drifted KV entry degrades to a live render, never 500. + + // Status must be an integer in the valid HTTP Response range; otherwise + // `new Response(body, { status })` throws. + if (!Number.isInteger(metadata.s) || metadata.s < 200 || metadata.s > 599) { + return null; + } + // createdAt (ms) and TTL (s) must be finite positive numbers — rejects + // NaN/Infinity/<=0 (tightens the previous truthy-only check). + if ( + !Number.isFinite(metadata.c) || + metadata.c <= 0 || + !Number.isFinite(metadata.t) || + metadata.t <= 0 + ) { + return null; + } + // Headers must be constructible; an invalid header name/value would otherwise + // throw later in `isrCacheResponse`'s `new Headers(entry.headers)`. + try { + new Headers(unpacked.headers); + } catch { return null; } @@ -718,16 +741,24 @@ export async function handleIsr( // L2: KV (global, authoritative). `readIsrCache` fails open to null. const cached = await readIsrCache(env, pathname); if (cached) { - const stale = isStale(cached); - if (stale) { - ctx.waitUntil( - revalidateInBackground(env, request, pathname, isrTtl, { htmlKey, jsonKey }, render), - ); + // Belt-and-suspenders: `readIsrCache` already validates the entry so the + // Response constructors below can't throw, but the never-500 fail-open + // contract must hold even if a future change slips a throwable past that + // choke point. On any throw, fall open (null → caller renders live). + try { + const stale = isStale(cached); + if (stale) { + ctx.waitUntil( + revalidateInBackground(env, request, pathname, isrTtl, { htmlKey, jsonKey }, render), + ); + } + const cacheResponse = isrCacheResponse(cached, stale); + // Warm the edge cache from KV; client consumes the original body. + ctx.waitUntil(safeCachePut(cache, htmlKey, cacheResponse.clone())); + return clientRevalidateResponse(cacheResponse); + } catch { + return null; // fail open → caller renders live } - const cacheResponse = isrCacheResponse(cached, stale); - // Warm the edge cache from KV; client consumes the original body. - ctx.waitUntil(safeCachePut(cache, htmlKey, cacheResponse.clone())); - return clientRevalidateResponse(cacheResponse); } // Miss: render live, cache in the background (only when cacheable). From a433548804249de9f9aed466a26eff652c3910f4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 19:02:26 +0800 Subject: [PATCH 09/31] feat(isr): wire ISR into worker entry with baked deployment id Emit compile-time ISR consts (__VOID_ISR_REVALIDATE, literal __VOID_ISR_DEPLOYMENT_ID) beside __VOID_BUILD_KEY; add optional `isr` param to compileWorkerEntry that wraps the render path with handleIsr, captures deployment id + host on env before dispatch, and hides ISR_CACHE from route handlers' c.env. ISR-absent output is byte- identical to before (modulo the ISR_CACHE filter). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/router/compile.ts | 77 ++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index cc935bafd..246eeca6d 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -740,6 +740,13 @@ export function compileRouteTable( ssr: SsrCompileOptions | null, options: CompileOptions, pages: PagesCompileOptions | null = null, + /** + * Merged ISR revalidate config (number | Record) baked into + * the route table as `__VOID_ISR_REVALIDATE`. Task 8 supplies the real value; + * `undefined` (the default) emits `= undefined;` so non-ISR builds are + * unaffected — the const is only imported/used when the entry wires ISR. + */ + mergedRevalidate?: number | Record, ): string { const lines: Array = []; const auth = options.auth; @@ -1707,6 +1714,14 @@ window.__vite_plugin_react_preamble_installed__ = true; lines.push( `export const __VOID_DEV_TRIGGER_TOKEN = ${JSON.stringify(options.devTriggerToken ?? crypto.randomUUID())};`, ); + // Baked ISR config. `__VOID_ISR_REVALIDATE` is the merged revalidate map/number + // (or `undefined`). `__VOID_ISR_DEPLOYMENT_ID` MUST be a COMPILE-TIME LITERAL — + // stringified here at build time exactly like `__VOID_BUILD_KEY` above — so every + // isolate of a deployment shares one id (edge-cache keys, KV keys, and the + // `revalidate()` purge path must all agree). A runtime `crypto.randomUUID()` in the + // emitted source would mint a fresh id per isolate and break cache reads + purge. + lines.push(`export const __VOID_ISR_REVALIDATE = ${JSON.stringify(mergedRevalidate)};`); + lines.push(`export const __VOID_ISR_DEPLOYMENT_ID = ${JSON.stringify(crypto.randomUUID())};`); lines.push(''); lines.push(`export default app;`); @@ -2416,6 +2431,15 @@ export function compileWorkerEntry( sandbox?: boolean | { bindingName?: string; className?: string; runtimePath?: string }, serveSpaFallback = false, live?: boolean | { bindingName?: string; className?: string }, + /** + * When present, wraps the render path with the self-host ISR cache ladder. + * `runtimePath` is the import specifier for `handleIsr` (Task 10 export). The + * deployment id is NOT passed here — the single source of truth is the + * compile-time `__VOID_ISR_DEPLOYMENT_ID` const imported from the route table. + * When absent, the render path is emitted byte-identical to today (zero + * behavioral change for managed/dev/no-ISR builds). + */ + isr?: { runtimePath: string }, ): string { const remotePath = remote?.remotePath; const remoteBindingNames = JSON.stringify(remote?.bindingNames); @@ -2466,12 +2490,18 @@ export function compileWorkerEntry( // Filter internal platform bindings so user code cannot access them via c.env. // The original env is preserved for framework internals (withRuntimeEnv, etc.). // Prefixes derived from INTERNAL_BINDING_PREFIXES (shared with env.ts). - const prefixCheck = INTERNAL_BINDING_PREFIXES.map( - (p) => `prop.startsWith(${JSON.stringify(p)})`, - ).join(' || '); - const prefixCheckNeg = INTERNAL_BINDING_PREFIXES.map( - (p) => `!key.startsWith(${JSON.stringify(p)})`, - ).join(' && '); + // `ISR_CACHE` (the self-host ISR KV binding) is hidden by exact name too — it + // carries no user-facing prefix but must not leak into route handlers' c.env + // (matches the managed platform). `handleIsr` reads the raw env, so hiding it + // from userEnv is safe. + const prefixCheck = [ + ...INTERNAL_BINDING_PREFIXES.map((p) => `prop.startsWith(${JSON.stringify(p)})`), + 'prop === "ISR_CACHE"', + ].join(' || '); + const prefixCheckNeg = [ + ...INTERNAL_BINDING_PREFIXES.map((p) => `!key.startsWith(${JSON.stringify(p)})`), + 'key !== "ISR_CACHE"', + ].join(' && '); const filterBindingsHelper = ` function __filterInternalBindings(env) { return new Proxy(env, { @@ -2498,29 +2528,52 @@ function __filterInternalBindings(env) { const assetHelper = compileAssetFallbackHelper(assetsDir, serveSpaFallback); + // ISR wiring (self-host only; injected by Task 8 for non-managed builds). When + // `isr` is absent every fragment below is an empty string, so the generated + // fetch handler is byte-identical to the pre-ISR output. + const isrImport = isr + ? `import { handleIsr } from ${JSON.stringify(isr.runtimePath)};\nimport { __VOID_ISR_REVALIDATE, __VOID_ISR_DEPLOYMENT_ID } from 'virtual:void-routes';\n` + : ''; + // Capture deployment id + host on the raw env at the top of fetch(), before + // dispatch — always, so `revalidate()` (which reads the raw env) can see them + // even on requests that never hit the ISR render ladder. + const isrEnvCapture = isr + ? ` env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n env.__VOID_ISR_HOST ??= new URL(request.url).hostname;\n` + : ''; + // Render site: unchanged single line when ISR is off; otherwise the render + // closure is threaded through the ISR cache ladder (only when ISR_CACHE binds). + const renderBlock = isr + ? ` const __render = async (req) => serveWithAssets(req, env, await app.fetch(req, userEnv, ctx)); + if (env.ISR_CACHE) { + const __isr = await handleIsr(request, env, ctx, { revalidate: __VOID_ISR_REVALIDATE, deploymentId: __VOID_ISR_DEPLOYMENT_ID }, __render); + if (__isr) return __isr; + } + return __render(request);` + : ` return serveWithAssets(request, env, await app.fetch(request, userEnv, ctx));`; + const fetchHandler = remotePath ? `async (request, env, ctx) => { ${envValidateCall} const ws = await __voidHandleWebSocket(request, env); if (ws) return ws; if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall}; - return __withRawEnv(env, () => { +${isrEnvCapture} return __withRawEnv(env, () => { const userEnv = __filterInternalBindings(env); return withRuntimeEnv(env, async () => { const internal = await __handleInternal(request, env, ctx); if (internal) return internal; - return serveWithAssets(request, env, await app.fetch(request, userEnv, ctx)); +${renderBlock} }); }); }` : `async (request, env, ctx) => { ${envValidateCall} const ws = await __voidHandleWebSocket(request, env); if (ws) return ws; - return __withRawEnv(env, () => { +${isrEnvCapture} return __withRawEnv(env, () => { const userEnv = __filterInternalBindings(env); return withRuntimeEnv(env, async () => { const internal = await __handleInternal(request, env, ctx); if (internal) return internal; - return serveWithAssets(request, env, await app.fetch(request, userEnv, ctx)); +${renderBlock} }); }); }`; @@ -2559,7 +2612,7 @@ ${envValidateCall} const userEnv = __filterInternalBindings(env); if (hasQueues) { return `${sandboxImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __voidHandleWebSocket, __withRawEnv, __processQueue${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; -${runtimeEnvImport}${envSchemaImport}${remoteImport}${filterBindingsHelper}${assetHelper} +${runtimeEnvImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} ${internalRouteHelper} ${scheduledHandler} async function queue(batch, env, ctx) { @@ -2589,7 +2642,7 @@ ${sandboxExport} export default { fetch: ${fetchHandler}, scheduled: handleScheduled, queue };`; } return `${sandboxImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __voidHandleWebSocket, __withRawEnv${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; -${runtimeEnvImport}${envSchemaImport}${remoteImport}${filterBindingsHelper}${assetHelper} +${runtimeEnvImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} ${internalRouteHelper} ${scheduledHandler} ${webSocketClasses.map((name) => `export { ${name} };`).join('\n')} From d3143e851b39e30cdc836fba50be3e898771535b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 19:12:23 +0800 Subject: [PATCH 10/31] build(void): export #self/runtime/isr-cache Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/package.json | 1 + packages/void/vite.config.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/void/package.json b/packages/void/package.json index 3ded854e1..6436c42c3 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -38,6 +38,7 @@ "#self/runtime/live-server": "./dist/runtime/live-server.mjs", "#self/runtime/fetch": "./dist/runtime/fetch.mjs", "#self/runtime/fetch-stream": "./dist/runtime/fetch-stream.mjs", + "#self/runtime/isr-cache": "./dist/runtime/isr-cache.mjs", "#self/runtime/migration-handler": "./dist/runtime/migration-handler.mjs", "#self/runtime/migration-handler-pg": "./dist/runtime/migration-handler-pg.mjs", "#self/runtime/ws-server": "./dist/runtime/ws-server.mjs" diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index a8ad8abe5..af499397e 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -110,6 +110,7 @@ export default defineConfig({ 'src/runtime/queues.ts', 'src/runtime/storage.ts', 'src/runtime/isr.ts', + 'src/runtime/isr-cache.ts', 'src/runtime/log.ts', 'src/runtime/ai.ts', 'src/runtime/sandbox.ts', From 214490b3a9007029da6b0219ee07dede641ceb14 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 19:23:53 +0800 Subject: [PATCH 11/31] feat(isr): emit ISR into the worker entry on non-managed builds only Thread the merged revalidate map into `compileRouteTable` and pass the `isr` runtime wiring into `compileWorkerEntry`, but only on a real (non-managed) production CF `vite build`. The gate reads `resolvedConfig.command` (set in configResolved, available at buildStart) and stays off for dev (`serve`), non-CF targets, managed `void deploy` (which sets `VOID_DEPLOY_PROJECT_ID`), and apps without revalidate config. Route table and worker entry derive from the same `emitIsr` decision so the baked `__VOID_ISR_REVALIDATE` / `__VOID_ISR_DEPLOYMENT_ID` consts and the entry's `handleIsr` wiring are present or absent together within one build. The ISR runtime path is resolved lazily, only when ISR is emitted. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/plugin-routing.ts | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index f9b845f87..51b8958ba 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -17,6 +17,7 @@ import { generateQueueTypes } from './codegen/queue-types.ts'; import { generateRouteTypes } from './codegen/route-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { readConfig, getDatabaseDialect } from './config.ts'; +import { collectRevalidate, hasRevalidate } from './isr-config.ts'; import { readProjectPaths } from './project-paths.ts'; import { compileRouteTable, @@ -292,6 +293,14 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug let resolvedConfig: import('vite').ResolvedConfig | null = null; + // Merged ISR revalidate map for the CURRENT build, gated by `emitIsr` (see + // buildStart). The `load` hook threads this into `compileRouteTable` so the + // baked `__VOID_ISR_REVALIDATE` const and the worker entry's `handleIsr` + // wiring derive from the SAME decision within one build. `undefined` whenever + // ISR is not emitted (dev, managed `void deploy`, non-CF target, or no + // revalidate config), keeping today's behavior byte-identical. + let isrRevalidate: number | Record | undefined; + function resolveAuthClientPath(config: import('vite').ResolvedConfig): string { if (config.plugins.some((plugin) => plugin.name === 'void-react:pages')) { return explicitAuthClientPaths.react; @@ -520,6 +529,26 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ); writeFileIfChanged(join(outDir, 'index.ts'), compileNodeServer(options.target)); } else { + // ISR gate. Only a real (non-managed) production CF `vite build` bakes + // ISR into the worker: managed `void deploy` sets VOID_DEPLOY_PROJECT_ID + // (the platform owns ISR then), dev (`command === 'serve'`) never emits + // it, and non-CF targets took the branch above. Read the command from + // `resolvedConfig` (set in configResolved, available here) — not a stale + // captured value. + const merged = collectRevalidate(readConfig(root), scan!.pages); + const emitIsr = + !options.target && + resolvedConfig?.command === 'build' && + !process.env.VOID_DEPLOY_PROJECT_ID && + hasRevalidate(merged); + // Route table + worker entry must agree within one build: the load hook + // bakes `__VOID_ISR_REVALIDATE`/`__VOID_ISR_DEPLOYMENT_ID` from + // `isrRevalidate`, and the entry's `handleIsr` wiring imports those same + // consts. Both derive from `emitIsr` so they are present or absent together. + isrRevalidate = emitIsr ? merged : undefined; + // Resolve the ISR runtime path LAZILY — only when ISR is actually emitted + // — so non-ISR/managed/dev builds never depend on it. + const isr = emitIsr ? { runtimePath: resolveFile('#self/runtime/isr-cache') } : undefined; writeFileIfChanged( join(outDir, 'entry.ts'), compileWorkerEntry( @@ -538,6 +567,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug : false, shouldServeSpaFallback(), options.live, + isr, ), ); } @@ -598,6 +628,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ssr, compileOptions, getPagesCompileOptions(), + isrRevalidate, ); }, }, From 057a44e39c7e32a7b936613f9110269698ff0dfe Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 19:32:21 +0800 Subject: [PATCH 12/31] feat(isr): purge own KV + edge cache on self-host revalidate() Replace the local-dev no-op fallthrough in revalidate() with a self-host purge that deletes the entry from the worker's own ISR_CACHE (KV) and caches.default edge variants, using the key builders from isr-cache.ts. The managed __VOID_PROXY branch is unchanged (still forwards to the platform proxy). Stays a silent no-op when neither proxy nor (ISR_CACHE + __VOID_ISR_DEPLOYMENT_ID) is configured. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/runtime/isr.ts | 45 +++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/void/src/runtime/isr.ts b/packages/void/src/runtime/isr.ts index a3071ce01..dc06fd602 100644 --- a/packages/void/src/runtime/isr.ts +++ b/packages/void/src/runtime/isr.ts @@ -1,4 +1,5 @@ import { getRawRuntimeEnv } from './env-raw.ts'; +import { denormalizeIsrPath, htmlEdgeKey, isrKey, jsonEdgeKey } from './isr-cache.ts'; export type RevalidateOptions = { paths?: Array; @@ -8,9 +9,10 @@ export type RevalidateOptions = { /** * Revalidate ISR-cached pages on demand. * - * In production, calls the platform proxy via service binding which forwards - * to the API for R2 deletion + edge cache purge. In local dev, this is a - * no-op (no ISR cache to purge). + * On managed Void, calls the platform proxy via service binding which forwards + * to the API for R2 deletion + edge cache purge. On self-host, purges this + * worker's own ISR_CACHE (KV) entries plus the caches.default edge variants. + * When neither is configured (e.g. local dev with no ISR), this is a no-op. * * @example * ```ts @@ -27,7 +29,42 @@ export async function revalidate(options: RevalidateOptions): Promise { const rawEnv = getRawRuntimeEnv(); const proxy = rawEnv.__VOID_PROXY as Fetcher | undefined; if (!proxy) { - // Local dev — no ISR cache to purge, no-op + // Self-host — no platform proxy. Purge this worker's own ISR cache: KV + // entries (global, authoritative) plus the caches.default edge variants. + // When ISR_CACHE / deployment id are absent (e.g. local dev with no ISR + // binding), this stays a silent no-op — matching the previous behavior. + const kv = rawEnv.ISR_CACHE as KVNamespace | undefined; + const id = rawEnv.__VOID_ISR_DEPLOYMENT_ID as string | undefined; + if (!kv || !id) { + return; + } + // Cast because this package has both @types/node and @cloudflare/workers-types + // in scope, and Node's ambient CacheStorage (which lacks `.default`) otherwise + // shadows the Workers one on the `caches` global — same pattern as isr-cache.ts. + const cache = (caches as unknown as { default: Cache }).default; + const host = rawEnv.__VOID_ISR_HOST as string | undefined; + const purge = async (path: string): Promise => { + await kv.delete(isrKey(id, path)); + if (host) { + await cache.delete(htmlEdgeKey(host, path, id)); + await cache.delete(jsonEdgeKey(host, path, id)); + } + }; + if (options.all) { + const prefix = `void-isr/${id}/`; + let cursor: string | undefined; + do { + const list = await kv.list({ prefix, cursor }); + for (const key of list.keys) { + await purge(denormalizeIsrPath(key.name.slice(prefix.length))); + } + cursor = list.list_complete ? undefined : list.cursor; + } while (cursor); + } else { + for (const path of options.paths ?? []) { + await purge(path); + } + } return; } From 9c919be53055abb33ee0866573ac50b15fd7bac9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 19:55:24 +0800 Subject: [PATCH 13/31] test(isr): cover cache ladder, revalidate purge, entry emit, config Adds unit tests for the self-host ISR feature (Tasks 5-9): - isr-cache.test.ts: key builders, pack/unpack, isStale, resolveRevalidateTtl, isrCacheResponse, and the handleIsr cache ladder (MISS/HIT/STALE, JSON edge miss+hit, HTML edge hit), including corrupt-KV fail-open (returns null + degrades handleIsr to a live render), getWithMetadata throw fail-open, no-write on the JSON path, X-VoidPages-stripped background re-render, and the x-revalidate:0 purge. - isr.test.ts: revalidate() managed proxy branch + self-host KV/edge purge (paths + all:true cursor loop) + unconfigured no-op. - isr-config.test.ts: hasRevalidate + collectRevalidate merge rules. - compile.test.ts: compileWorkerEntry ISR wiring + compileRouteTable baked __VOID_ISR_DEPLOYMENT_ID literal / __VOID_ISR_REVALIDATE. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/test/unit/compile.test.ts | 132 ++++ packages/void/test/unit/isr-cache.test.ts | 780 +++++++++++++++++++++ packages/void/test/unit/isr-config.test.ts | 94 +++ packages/void/test/unit/isr.test.ts | 219 ++++++ 4 files changed, 1225 insertions(+) create mode 100644 packages/void/test/unit/isr-cache.test.ts create mode 100644 packages/void/test/unit/isr-config.test.ts create mode 100644 packages/void/test/unit/isr.test.ts diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index e174bcb0f..5e63ae58c 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -2837,3 +2837,135 @@ describe('emitted __compilePattern / __matchPattern runtime', () => { } }); }); + +describe('compileWorkerEntry — ISR wiring', () => { + it('is unchanged without the isr param (single render line, no handleIsr)', () => { + const code = compileWorkerEntry(); + expect(code).toContain( + 'return serveWithAssets(request, env, await app.fetch(request, userEnv, ctx));', + ); + expect(code).not.toContain('handleIsr'); + // The `if (env.ISR_CACHE)` render guard is only emitted when ISR is wired. + expect(code).not.toContain('if (env.ISR_CACHE)'); + expect(code).not.toContain('__VOID_ISR_REVALIDATE'); + }); + + it('wires the ISR cache ladder with the two baked imports when the isr param is present', () => { + const code = compileWorkerEntry( + false, // hasQueues + undefined, // remote + 'assets', // assetsDir + [], // webSocketClasses + 'void/_env', // runtimeEnvPath + undefined, // envSchemaModule + undefined, // sandbox + false, // serveSpaFallback + undefined, // live + { runtimePath: 'void/isr' }, + ); + + // Baked import #1: handleIsr from the supplied runtime path. + expect(code).toContain('import { handleIsr } from "void/isr"'); + // Baked import #2: the two consts from the compiled route table (single source of truth). + expect(code).toContain( + "import { __VOID_ISR_REVALIDATE, __VOID_ISR_DEPLOYMENT_ID } from 'virtual:void-routes'", + ); + // Render path threaded through handleIsr, guarded on the ISR_CACHE binding. + expect(code).toContain('if (env.ISR_CACHE)'); + expect(code).toContain( + 'handleIsr(request, env, ctx, { revalidate: __VOID_ISR_REVALIDATE, deploymentId: __VOID_ISR_DEPLOYMENT_ID }, __render)', + ); + // Deployment id + host captured on the raw env so revalidate() can purge even + // on requests that never enter the render ladder. + expect(code).toContain('env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;'); + expect(code).toContain('env.__VOID_ISR_HOST ??= new URL(request.url).hostname;'); + }); +}); + +describe('compileRouteTable — baked ISR consts', () => { + const scan: ScanResult = { + routes: [], + middleware: [], + jobs: [], + queues: [], + pages: { pages: [], layouts: [], namedLayouts: [] }, + }; + + it('emits __VOID_ISR_DEPLOYMENT_ID as a compile-time string literal (never crypto.randomUUID())', () => { + const code = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + ); + // A quoted UUID literal — baked at build time, not a runtime call. + expect(code).toMatch(/export const __VOID_ISR_DEPLOYMENT_ID = "[0-9a-f-]{36}";/); + expect(code).not.toContain('__VOID_ISR_DEPLOYMENT_ID = crypto.randomUUID()'); + }); + + it('bakes a fresh deployment id per compilation (one shared id per deploy)', () => { + const a = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + ); + const b = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + ); + const idA = a.match(/__VOID_ISR_DEPLOYMENT_ID = "([^"]+)"/)![1]; + const idB = b.match(/__VOID_ISR_DEPLOYMENT_ID = "([^"]+)"/)![1]; + expect(idA).not.toBe(idB); + }); + + it('emits the merged revalidate config verbatim (undefined | number | path map)', () => { + const none = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + ); + expect(none).toContain('export const __VOID_ISR_REVALIDATE = undefined;'); + + const num = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + null, + 60, + ); + expect(num).toContain('export const __VOID_ISR_REVALIDATE = 60;'); + + const map = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + null, + { '/blog/*': 30 }, + ); + expect(map).toContain('export const __VOID_ISR_REVALIDATE = {"/blog/*":30};'); + }); +}); diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts new file mode 100644 index 000000000..64d6d77a5 --- /dev/null +++ b/packages/void/test/unit/isr-cache.test.ts @@ -0,0 +1,780 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + deleteIsrCache, + denormalizeIsrPath, + handleIsr, + htmlEdgeKey, + isrCacheResponse, + isrKey, + isStale, + jsonEdgeKey, + normalizeIsrPath, + packIsrValue, + readIsrCache, + resolveRevalidateTtl, + unpackIsrValue, + writeIsrCache, + type IsrCacheEntry, + type IsrCacheEnv, + type IsrCacheMetadata, + type RenderFn, +} from '../../src/runtime/isr-cache.ts'; + +// Collapsed self-host deployment id (baked as a compile-time literal on the real +// worker). Every key in this suite is scoped by it — `void-isr//...`. +const DPL = '11111111-1111-4111-8111-111111111111'; +const HOST = 'app.void.app'; +// A fixed, realistic epoch so `Date.now()` (faked) is deterministic. +const BASE = 1_700_000_000_000; + +// --------------------------------------------------------------------------- +// In-memory KV mock (getWithMetadata/put/delete/list with cursor pagination). +// Real behavior: `store` is the authoritative backing map, so assertions read +// actual cache contents rather than merely "the mock was called". +// --------------------------------------------------------------------------- + +type KvRecord = { value: ArrayBuffer; metadata: IsrCacheMetadata | null }; + +function createKvMock(opts: { throwOnGet?: boolean } = {}) { + const store = new Map(); + const getWithMetadata = vi.fn(async (key: string, _type: 'arrayBuffer') => { + if (opts.throwOnGet) { + throw new Error('KV unavailable'); + } + const rec = store.get(key); + return rec ? { value: rec.value, metadata: rec.metadata } : { value: null, metadata: null }; + }); + const put = vi.fn( + async (key: string, value: ArrayBuffer, options?: { metadata?: IsrCacheMetadata }) => { + store.set(key, { value, metadata: options?.metadata ?? null }); + }, + ); + const del = vi.fn(async (key: string) => { + store.delete(key); + }); + const list = vi.fn(async (o: { prefix?: string; cursor?: string; limit?: number } = {}) => { + const prefix = o.prefix ?? ''; + const all = [...store.keys()].filter((k) => k.startsWith(prefix)).sort(); + const limit = o.limit ?? 1000; + const start = o.cursor ? Number.parseInt(o.cursor, 10) : 0; + const slice = all.slice(start, start + limit); + const complete = start + limit >= all.length; + return { + keys: slice.map((name) => ({ name })), + list_complete: complete, + cursor: complete ? '' : String(start + limit), + }; + }); + const kv = { getWithMetadata, put, delete: del, list } as unknown as KVNamespace; + return { store, kv, getWithMetadata, put, delete: del, list }; +} + +function makeEnv(kv: KVNamespace): IsrCacheEnv { + return { ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }; +} + +function packEntry(headers: Record, body: string): ArrayBuffer { + return packIsrValue(headers, new TextEncoder().encode(body).buffer as ArrayBuffer); +} + +function seedEntry( + store: Map, + path: string, + { + headers = { 'content-type': 'text/html' }, + body = 'cached', + status = 200, + createdAt, + ttl = 60, + value, + }: { + headers?: Record; + body?: string; + status?: number; + createdAt: number; + ttl?: number; + value?: ArrayBuffer; + }, +): void { + store.set(isrKey(DPL, path), { + value: value ?? packEntry(headers, body), + metadata: { s: status, c: createdAt, t: ttl }, + }); +} + +// --------------------------------------------------------------------------- +// Fake per-colo edge cache (caches.default): match/put/delete over string keys. +// --------------------------------------------------------------------------- + +function createEdgeMock() { + const store = new Map(); + const match = vi.fn(async (key: string | Request) => { + const k = typeof key === 'string' ? key : (key as Request).url; + const res = store.get(k); + // Real Cache.match hands back a fresh Response each call. + return res ? res.clone() : undefined; + }); + const put = vi.fn(async (key: string | Request, response: Response) => { + const k = typeof key === 'string' ? key : (key as Request).url; + store.set(k, response); + }); + const del = vi.fn(async (key: string | Request) => { + const k = typeof key === 'string' ? key : (key as Request).url; + return store.delete(k); + }); + const cache = { match, put, delete: del } as unknown as Cache; + return { store, cache, match, put, delete: del }; +} + +function createCtx() { + const tasks: Array> = []; + const ctx = { + waitUntil: (p: Promise) => { + tasks.push(p); + }, + passThroughOnException: () => {}, + } as unknown as ExecutionContext; + return { + ctx, + tasks, + flush: async () => { + await Promise.all(tasks); + }, + }; +} + +function createRender(makeResponse: (req: Request) => Response) { + const requests: Array = []; + const spy = vi.fn(async (req: Request) => { + requests.push(req); + return makeResponse(req); + }); + return { render: spy as unknown as RenderFn, spy, requests }; +} + +// =========================================================================== +// Pure helpers +// =========================================================================== + +describe('normalizeIsrPath / denormalizeIsrPath', () => { + it('maps root to __index__ and back', () => { + expect(normalizeIsrPath('/')).toBe('__index__'); + expect(denormalizeIsrPath('__index__')).toBe('/'); + }); + + it('strips leading and trailing slashes', () => { + expect(normalizeIsrPath('/about')).toBe('about'); + expect(normalizeIsrPath('/about/')).toBe('about'); + expect(normalizeIsrPath('/blog/hello-world')).toBe('blog/hello-world'); + }); + + it('round-trips non-root paths', () => { + for (const p of ['/', '/about', '/blog/hello']) { + expect(denormalizeIsrPath(normalizeIsrPath(p))).toBe(p); + } + }); +}); + +describe('collapsed self-host cache keys', () => { + it('scopes the KV key by deployment id (void-isr//...), no projectId/isr-v2', () => { + expect(isrKey(DPL, '/')).toBe(`void-isr/${DPL}/__index__`); + expect(isrKey(DPL, '/blog/x')).toBe(`void-isr/${DPL}/blog/x`); + expect(isrKey(DPL, '/about/')).toBe(`void-isr/${DPL}/about`); + }); + + it('builds HTML edge keys with the deployment id and a canonical pathname', () => { + expect(htmlEdgeKey(HOST, '/about', DPL)).toBe(`https://${HOST}/about?__void_isr=${DPL}`); + // Trailing slash canonicalized (matches normalizeIsrPath). + expect(htmlEdgeKey(HOST, '/about/', DPL)).toBe(`https://${HOST}/about?__void_isr=${DPL}`); + // Root keeps its slash. + expect(htmlEdgeKey(HOST, '/', DPL)).toBe(`https://${HOST}/?__void_isr=${DPL}`); + }); + + it('builds JSON edge keys as the HTML key plus the &__void=json marker', () => { + expect(jsonEdgeKey(HOST, '/about', DPL)).toBe( + `https://${HOST}/about?__void_isr=${DPL}&__void=json`, + ); + }); +}); + +describe('isStale', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const entry = (createdAt: number, ttl = 60): IsrCacheEntry => ({ + body: new ReadableStream(), + status: 200, + headers: {}, + createdAt, + ttl, + }); + + it('is fresh before the TTL elapses', () => { + vi.setSystemTime(BASE); + expect(isStale(entry(BASE))).toBe(false); + }); + + it('is stale past the TTL', () => { + vi.setSystemTime(BASE + 61 * 1000); + expect(isStale(entry(BASE))).toBe(true); + }); + + it('is fresh at the exact TTL boundary', () => { + vi.setSystemTime(BASE + 60 * 1000); + expect(isStale(entry(BASE))).toBe(false); + }); +}); + +describe('resolveRevalidateTtl', () => { + it('returns the default when no header is present', () => { + expect(resolveRevalidateTtl(new Response('ok'), 60)).toBe(60); + }); + + it('returns the header value when present', () => { + expect( + resolveRevalidateTtl(new Response('ok', { headers: { 'x-revalidate': '120' } }), 60), + ).toBe(120); + }); + + it('returns 0 for the opt-out header (x-revalidate: 0 → do not cache)', () => { + expect(resolveRevalidateTtl(new Response('ok', { headers: { 'x-revalidate': '0' } }), 60)).toBe( + 0, + ); + }); + + it('falls back to the default for invalid or negative header values', () => { + expect( + resolveRevalidateTtl(new Response('ok', { headers: { 'x-revalidate': 'abc' } }), 60), + ).toBe(60); + expect( + resolveRevalidateTtl(new Response('ok', { headers: { 'x-revalidate': '-5' } }), 60), + ).toBe(60); + }); +}); + +describe('isrCacheResponse', () => { + it('serves HIT with an edge-cacheable s-maxage for a fresh entry', () => { + const res = isrCacheResponse( + { + body: new ReadableStream(), + status: 200, + headers: { 'content-type': 'text/html' }, + createdAt: Date.now(), + ttl: 60, + }, + false, + ); + expect(res.status).toBe(200); + expect(res.headers.get('x-isr-cache')).toBe('HIT'); + expect(res.headers.get('content-type')).toBe('text/html'); + const cc = res.headers.get('Cache-Control')!; + expect(cc).toContain('public'); + expect(cc).toContain('s-maxage='); + expect(cc).toContain('must-revalidate'); + }); + + it('serves STALE with s-maxage=min(10, ttl)', () => { + const res = isrCacheResponse( + { + body: new ReadableStream(), + status: 200, + headers: {}, + createdAt: Date.now() - 120000, + ttl: 60, + }, + true, + ); + expect(res.headers.get('x-isr-cache')).toBe('STALE'); + expect(res.headers.get('Cache-Control')).toBe( + 'public, s-maxage=10, max-age=0, must-revalidate', + ); + }); + + it('caps the STALE s-maxage at ttl for short ISR windows', () => { + const res = isrCacheResponse( + { + body: new ReadableStream(), + status: 200, + headers: {}, + createdAt: Date.now() - 10000, + ttl: 5, + }, + true, + ); + expect(res.headers.get('Cache-Control')).toBe('public, s-maxage=5, max-age=0, must-revalidate'); + }); +}); + +describe('packIsrValue / unpackIsrValue', () => { + it('round-trips headers and body', () => { + const headers = { 'content-type': 'text/html', etag: '"abc"' }; + const packed = packIsrValue( + headers, + new TextEncoder().encode('hi').buffer as ArrayBuffer, + ); + const result = unpackIsrValue(packed); + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(headers); + expect(new TextDecoder().decode(result!.body)).toBe('hi'); + }); + + it('handles empty body and empty headers', () => { + const emptyBody = unpackIsrValue( + packIsrValue({ 'content-type': 'text/html' }, new ArrayBuffer(0)), + ); + expect(emptyBody!.body.byteLength).toBe(0); + const emptyHeaders = unpackIsrValue( + packIsrValue({}, new TextEncoder().encode('x').buffer as ArrayBuffer), + ); + expect(emptyHeaders!.headers).toEqual({}); + }); + + it('returns null for a buffer too small or a truncated header', () => { + expect(unpackIsrValue(new ArrayBuffer(2))).toBeNull(); + const buf = new ArrayBuffer(4); + new DataView(buf).setUint32(0, 999, true); // claims 999 header bytes it does not have + expect(unpackIsrValue(buf)).toBeNull(); + }); +}); + +// =========================================================================== +// KV cache: write / read round-trip, delete, and corrupt-entry fail-open +// =========================================================================== + +describe('writeIsrCache / readIsrCache / deleteIsrCache', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('writes a packed entry to KV and reads it back with status/headers/ttl/body', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + await writeIsrCache( + env, + '/blog/post', + new Response('fresh', { + status: 200, + headers: { 'content-type': 'text/html', 'set-cookie': 'sid=secret', 'cache-control': 'no' }, + }), + 60, + ); + + // Real KV contents: exactly one entry under the collapsed key. + expect(store.has(isrKey(DPL, '/blog/post'))).toBe(true); + const meta = store.get(isrKey(DPL, '/blog/post'))!.metadata!; + expect(meta).toEqual({ s: 200, c: BASE, t: 60 }); + + const entry = await readIsrCache(env, '/blog/post'); + expect(entry).not.toBeNull(); + expect(entry!.status).toBe(200); + expect(entry!.ttl).toBe(60); + expect(entry!.createdAt).toBe(BASE); + expect(entry!.headers['content-type']).toBe('text/html'); + // serializeHeaders strips cache-control/set-cookie before persisting. + expect(entry!.headers['set-cookie']).toBeUndefined(); + expect(entry!.headers['cache-control']).toBeUndefined(); + expect(await new Response(entry!.body).text()).toBe('fresh'); + }); + + it('returns null on a cache miss', async () => { + const { kv } = createKvMock(); + expect(await readIsrCache(makeEnv(kv), '/nope')).toBeNull(); + }); + + it('deleteIsrCache removes the entry', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + seedEntry(store, '/gone', { createdAt: BASE }); + expect(store.has(isrKey(DPL, '/gone'))).toBe(true); + await deleteIsrCache(env, '/gone'); + expect(store.has(isrKey(DPL, '/gone'))).toBe(false); + }); + + it('fails open to null when getWithMetadata throws (never surfaces the error)', async () => { + const { kv } = createKvMock({ throwOnGet: true }); + await expect(readIsrCache(makeEnv(kv), '/x')).resolves.toBeNull(); + }); + + // --- Corrupt-KV fail-open (critical) ------------------------------------- + // A KV entry whose metadata/headers make a Response un-constructible must be + // rejected at this choke point so `isrCacheResponse` can never throw. + const corruptCases: Array<{ name: string; seed: () => KvRecord }> = [ + { + name: 'status out of range (s: 999)', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 999, c: BASE, t: 60 } }), + }, + { + name: 'status out of range (s: 0)', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 0, c: BASE, t: 60 } }), + }, + { + name: 'invalid header name', + seed: () => ({ + value: packEntry({ 'Invalid Header Name': 'x' }, 'x'), + metadata: { s: 200, c: BASE, t: 60 }, + }), + }, + { + name: 'createdAt NaN', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: NaN, t: 60 } }), + }, + { + name: 'createdAt Infinity', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: Infinity, t: 60 } }), + }, + { + name: 'createdAt negative', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: -1, t: 60 } }), + }, + { + name: 'ttl NaN', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: NaN } }), + }, + { + name: 'ttl zero', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: 0 } }), + }, + { + name: 'ttl negative', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: -5 } }), + }, + { + name: 'malformed packed value', + seed: () => ({ value: new ArrayBuffer(2), metadata: { s: 200, c: BASE, t: 60 } }), + }, + ]; + + for (const c of corruptCases) { + it(`readIsrCache returns null for a corrupt entry: ${c.name}`, async () => { + const { store, kv } = createKvMock(); + store.set(isrKey(DPL, '/p'), c.seed()); + await expect(readIsrCache(makeEnv(kv), '/p')).resolves.toBeNull(); + }); + } +}); + +// =========================================================================== +// handleIsr — the cache ladder end to end +// =========================================================================== + +describe('handleIsr', () => { + let edge: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE); + edge = createEdgeMock(); + vi.stubGlobal('caches', { default: edge.cache }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + const OPTS = { revalidate: 60, deploymentId: DPL }; + + function htmlRequest(path = '/blog/post', headers: Record = {}) { + return new Request(`https://${HOST}${path}`, { method: 'GET', headers }); + } + + it('MISS → renders live, returns MISS, and writes KV + HTML edge in the background', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => new Response('live', { headers: { 'content-type': 'text/html' } }), + ); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('live'); + expect(spy).toHaveBeenCalledTimes(1); + + await flush(); + // Real KV contents: the live render was persisted under the collapsed key. + const entry = await readIsrCache(env, '/blog/post'); + expect(entry).not.toBeNull(); + expect(await new Response(entry!.body).text()).toBe('live'); + // HTML edge variant warmed; plain HTML (no page data) never warms the JSON variant. + expect(edge.store.has(htmlEdgeKey(HOST, '/blog/post', DPL))).toBe(true); + expect(edge.store.has(jsonEdgeKey(HOST, '/blog/post', DPL))).toBe(false); + expect(store.size).toBe(1); + }); + + it('KV HIT (fresh) → serves HIT from KV without a live render and warms the edge', async () => { + const { kv, store } = createKvMock(); + const env = makeEnv(kv); + seedEntry(store, '/blog/post', { body: 'kv', createdAt: BASE, ttl: 60 }); + const { render, spy } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('HIT'); + // Client response revalidates on every hit. + expect(res!.headers.get('Cache-Control')).toBe('public, max-age=0, must-revalidate'); + expect(await res!.text()).toBe('kv'); + expect(spy).not.toHaveBeenCalled(); + + await flush(); + // Edge warmed from KV with the fresh (HIT) response — s-maxage tracks remaining freshness. + const warmed = edge.store.get(htmlEdgeKey(HOST, '/blog/post', DPL)); + expect(warmed).toBeDefined(); + expect(warmed!.headers.get('x-isr-cache')).toBe('HIT'); + expect(warmed!.headers.get('Cache-Control')).toBe( + 'public, s-maxage=60, max-age=0, must-revalidate', + ); + }); + + it('KV HIT (stale) → serves STALE and queues a background revalidation that re-puts KV with an X-VoidPages-stripped request', async () => { + const { kv, store } = createKvMock(); + const env = makeEnv(kv); + // Seed a stale entry: created at BASE, now BASE+200s (> ttl 60s). + seedEntry(store, '/blog/post', { body: 'stale', createdAt: BASE, ttl: 60 }); + vi.setSystemTime(BASE + 200_000); + + const { render, spy, requests } = createRender( + () => new Response('revalidated', { headers: { 'content-type': 'text/html' } }), + ); + const { ctx, flush } = createCtx(); + + // Original request carries a marker header (and NO X-VoidPages — an + // X-VoidPages request would take the JSON branch and never reach here). + const res = await handleIsr( + htmlRequest('/blog/post', { 'X-Test-Marker': 'keep' }), + env, + ctx, + OPTS, + render, + ); + + // Stale content served immediately. + expect(res!.headers.get('x-isr-cache')).toBe('STALE'); + expect(await res!.text()).toBe('stale'); + + await flush(); + // Background re-render ran exactly once (queued via ctx.waitUntil). + expect(spy).toHaveBeenCalledTimes(1); + const rerender = requests[0]!; + // The re-render request is derived from the original (marker preserved, GET, + // same URL) but carries NO X-VoidPages, so the background render is HTML. + // (On the HTML path the original request never has X-VoidPages; this locks in + // that cloneWithoutVoidPages hands the render fn a clean HTML request.) + // NOTE: cloneWithoutVoidPages uses `new Request(request, { headers })`, which + // preserves `request.cf` on real workerd (verified empirically). These unit + // tests run on Node's default vitest pool where `request.cf` does not exist, + // so cf preservation is not representable here and is intentionally not asserted. + expect(rerender.headers.has('X-VoidPages')).toBe(false); + expect(rerender.headers.get('X-Test-Marker')).toBe('keep'); + expect(rerender.method).toBe('GET'); + expect(rerender.url).toBe(`https://${HOST}/blog/post`); + + // KV re-put with the fresh render (KV is only written by the background task, + // so this is race-free) — createdAt advanced, entry is fresh again. + const entry = await readIsrCache(env, '/blog/post'); + expect(entry!.createdAt).toBe(BASE + 200_000); + expect(isStale(entry!)).toBe(false); + expect(await new Response(entry!.body).text()).toBe('revalidated'); + }); + + it('HTML edge HIT → serves the edge variant without touching KV or rendering', async () => { + const { kv, store, getWithMetadata } = createKvMock(); + const env = makeEnv(kv); + edge.store.set( + htmlEdgeKey(HOST, '/blog/post', DPL), + new Response('edge', { headers: { 'x-isr-cache': 'HIT' } }), + ); + const { render, spy } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(await res!.text()).toBe('edge'); + expect(res!.headers.get('Cache-Control')).toBe('public, max-age=0, must-revalidate'); + expect(spy).not.toHaveBeenCalled(); + expect(getWithMetadata).not.toHaveBeenCalled(); + expect(store.size).toBe(0); + }); + + it('JSON (X-VoidPages) MISS → renders live JSON and never writes the edge or KV', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + const { render, spy, requests } = createRender( + () => + new Response('{"component":"Blog"}', { headers: { 'content-type': 'application/json' } }), + ); + const { ctx, tasks } = createCtx(); + + const res = await handleIsr( + htmlRequest('/blog/post', { 'X-VoidPages': '1' }), + env, + ctx, + OPTS, + render, + ); + + expect(res!.status).toBe(200); + expect(await res!.text()).toBe('{"component":"Blog"}'); + // JSON path renders with the ORIGINAL request (X-VoidPages preserved). + expect(requests[0]!.headers.has('X-VoidPages')).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); + // No writes on the JSON path — the JSON variant is warmed only by the HTML render path. + expect(tasks).toHaveLength(0); + expect(edge.store.size).toBe(0); + expect(store.size).toBe(0); + }); + + it('JSON (X-VoidPages) edge HIT → serves the cached JSON variant, no render', async () => { + const { kv } = createKvMock(); + const env = makeEnv(kv); + edge.store.set(jsonEdgeKey(HOST, '/blog/post', DPL), new Response('{"cached":true}')); + const { render, spy } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx } = createCtx(); + + const res = await handleIsr( + htmlRequest('/blog/post', { 'X-VoidPages': '1' }), + env, + ctx, + OPTS, + render, + ); + expect(await res!.text()).toBe('{"cached":true}'); + expect(res!.headers.get('Cache-Control')).toBe('public, max-age=0, must-revalidate'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('fails open to a LIVE render (not a 500) when getWithMetadata throws', async () => { + const { kv } = createKvMock({ throwOnGet: true }); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => new Response('live', { headers: { 'content-type': 'text/html' } }), + ); + const { ctx } = createCtx(); + + // readIsrCache degrades to null on the throw... + await expect(readIsrCache(env, '/blog/post')).resolves.toBeNull(); + // ...and handleIsr degrades to the live render rather than throwing. + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('live'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + // Corrupt KV entry on the hit path must degrade to a live render, never throw. + const corruptHitCases: Array<{ name: string; record: () => KvRecord }> = [ + { + name: 'status out of range', + record: () => ({ value: packEntry({}, 'x'), metadata: { s: 999, c: BASE, t: 60 } }), + }, + { + name: 'invalid header name', + record: () => ({ + value: packEntry({ 'Invalid Header Name': 'x' }, 'x'), + metadata: { s: 200, c: BASE, t: 60 }, + }), + }, + { + name: 'ttl NaN', + record: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: NaN } }), + }, + ]; + + for (const c of corruptHitCases) { + it(`degrades to a live render on a corrupt KV hit: ${c.name}`, async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + store.set(isrKey(DPL, '/blog/post'), c.record()); + const { render, spy } = createRender( + () => new Response('live', { headers: { 'content-type': 'text/html' } }), + ); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('live'); + expect(spy).toHaveBeenCalledTimes(1); + + await flush(); + // The MISS path overwrote the corrupt entry with a valid, readable one. + const entry = await readIsrCache(env, '/blog/post'); + expect(entry).not.toBeNull(); + expect(await new Response(entry!.body).text()).toBe('live'); + }); + } + + it('background revalidation with x-revalidate: 0 purges the KV entry and both edge variants', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + const env = makeEnv(kv); + seedEntry(store, '/blog/post', { createdAt: BASE, ttl: 60 }); + vi.setSystemTime(BASE + 200_000); // stale + + // Pre-warm the JSON edge variant so we can watch it get purged. The HTML + // edge key is left empty on purpose — pre-warming it would hit L1 and + // short-circuit before the KV stale path (which triggers the purge) runs. + edge.store.set(jsonEdgeKey(HOST, '/blog/post', DPL), new Response('{"edge":true}')); + + const { render } = createRender( + () => + new Response('opt-out', { + headers: { 'content-type': 'text/html', 'x-revalidate': '0' }, + }), + ); + const { ctx, flush } = createCtx(); + + await handleIsr(htmlRequest(), env, ctx, OPTS, render); + await flush(); + + // KV entry is only ever deleted here (never re-put) — deterministic real removal. + expect(store.has(isrKey(DPL, '/blog/post'))).toBe(false); + expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, '/blog/post')); + // JSON edge variant is only touched by the purge → deterministically removed. + expect(edge.store.has(jsonEdgeKey(HOST, '/blog/post', DPL))).toBe(false); + expect(edge.delete).toHaveBeenCalledWith(jsonEdgeKey(HOST, '/blog/post', DPL)); + // HTML edge purge is issued; its final state races the concurrent stale-warm + // put, so assert the delete call rather than the (racey) final map state. + expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/blog/post', DPL)); + }); + + it('returns null (caller renders live) for non-GET, credentialed, and non-ISR paths', async () => { + const { kv } = createKvMock(); + const env = makeEnv(kv); + const { render } = createRender(() => new Response('x')); + const { ctx } = createCtx(); + + // Non-GET. + expect( + await handleIsr( + new Request(`https://${HOST}/blog/post`, { method: 'POST' }), + env, + ctx, + OPTS, + render, + ), + ).toBeNull(); + // Credentialed (Cookie) requests bypass ISR. + expect( + await handleIsr(htmlRequest('/blog/post', { Cookie: 'sid=1' }), env, ctx, OPTS, render), + ).toBeNull(); + // Path with no configured TTL. + expect( + await handleIsr( + htmlRequest('/blog/post'), + env, + ctx, + { revalidate: { '/other': 60 }, deploymentId: DPL }, + render, + ), + ).toBeNull(); + }); +}); diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts new file mode 100644 index 000000000..ea66eb239 --- /dev/null +++ b/packages/void/test/unit/isr-config.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vite-plus/test'; +import type { VoidConfig } from '../../src/config.ts'; +import { collectRevalidate, hasRevalidate } from '../../src/isr-config.ts'; +import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; + +describe('hasRevalidate', () => { + it('treats null / undefined / 0 / negative / {} as no ISR', () => { + expect(hasRevalidate(null)).toBe(false); + expect(hasRevalidate(undefined)).toBe(false); + expect(hasRevalidate(0)).toBe(false); + expect(hasRevalidate(-5)).toBe(false); + expect(hasRevalidate({})).toBe(false); + }); + + it('treats a positive number or a non-empty path map as ISR on', () => { + expect(hasRevalidate(60)).toBe(true); + expect(hasRevalidate({ '/x': 10 })).toBe(true); + }); + + it('treats non-number/non-object values as no ISR', () => { + expect(hasRevalidate('60')).toBe(false); + expect(hasRevalidate(true)).toBe(false); + }); +}); + +function makePage(pattern: string, revalidate?: number): PageDefinition { + return { + pattern, + params: [], + catchAll: false, + componentPath: `${pattern}.vue`, + serverPath: `${pattern}.server.ts`, + methods: ['GET'], + componentId: pattern.replace(/^\//, ''), + revalidate, + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, + }; +} + +function makeScan(pages: Array): PageScanResult { + return { pages, layouts: [], namedLayouts: [] }; +} + +function configWith(revalidate: number | Record | undefined): VoidConfig { + return (revalidate === undefined ? {} : { routing: { revalidate } }) as VoidConfig; +} + +describe('collectRevalidate', () => { + it('returns undefined with no config and no per-page rules', () => { + expect(collectRevalidate(configWith(undefined), makeScan([]))).toBeUndefined(); + expect(collectRevalidate(configWith(undefined), null)).toBeUndefined(); + }); + + it('passes the void.json config through when there are no per-page rules', () => { + expect(collectRevalidate(configWith(60), makeScan([]))).toBe(60); + expect(collectRevalidate(configWith({ '/docs/*': 30 }), makeScan([]))).toEqual({ + '/docs/*': 30, + }); + }); + + it('uses per-page rules alone when config is absent', () => { + const scan = makeScan([makePage('/blog/:slug', 120), makePage('/about')]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/:slug': 120 }); + }); + + it('turns a numeric config into a path map with the number as the "*" fallback (per-page first)', () => { + const scan = makeScan([makePage('/blog/:slug', 120)]); + const result = collectRevalidate(configWith(60), scan); + expect(result).toEqual({ '/blog/:slug': 120, '*': 60 }); + // Per-page entry must precede the fallback (first-match-wins ordering). + expect(Object.keys(result as Record)).toEqual(['/blog/:slug', '*']); + }); + + it('merges per-page rules ahead of an object config (per-page first in match order)', () => { + const scan = makeScan([makePage('/blog/:slug', 120)]); + const result = collectRevalidate(configWith({ '/docs/*': 30, '*': 10 }), scan); + // Per-page entries are emitted before the config entries, so first-match + // resolution consults the per-page pattern ahead of the config's catch-alls. + expect(Object.entries(result as Record)).toEqual([ + ['/blog/:slug', 120], + ['/docs/*', 30], + ['*', 10], + ]); + }); + + it('ignores pages that declare no revalidate', () => { + const scan = makeScan([makePage('/about'), makePage('/contact')]); + expect(collectRevalidate(configWith(undefined), scan)).toBeUndefined(); + expect(collectRevalidate(configWith(60), scan)).toBe(60); + }); +}); diff --git a/packages/void/test/unit/isr.test.ts b/packages/void/test/unit/isr.test.ts new file mode 100644 index 000000000..128ca349a --- /dev/null +++ b/packages/void/test/unit/isr.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { envContext } from '../../src/runtime/env-raw.ts'; +import { htmlEdgeKey, isrKey, jsonEdgeKey } from '../../src/runtime/isr-cache.ts'; +import { revalidate } from '../../src/runtime/isr.ts'; + +const DPL = 'dpl_1'; +const HOST = 'app.example.com'; + +// In-memory KV mock. `pageSize` forces the mock to paginate so revalidate's +// cursor loop (`{ all: true }`) is genuinely exercised with a small key set. +function createKvMock(pageSize = 1000) { + const store = new Map(); + const del = vi.fn(async (key: string) => { + store.delete(key); + }); + // Real KV list is a point-in-time view: snapshot the key set when a new + // list chain starts (cursor absent) so deletions mid-pagination — as + // revalidate({ all: true }) does — don't shift the offset and skip keys. + let snapshot: Array = []; + const list = vi.fn(async (o: { prefix?: string; cursor?: string } = {}) => { + const prefix = o.prefix ?? ''; + if (!o.cursor) { + snapshot = [...store.keys()].filter((k) => k.startsWith(prefix)).sort(); + } + const start = o.cursor ? Number.parseInt(o.cursor, 10) : 0; + const slice = snapshot.slice(start, start + pageSize); + const complete = start + pageSize >= snapshot.length; + return { + keys: slice.map((name) => ({ name })), + list_complete: complete, + cursor: complete ? '' : String(start + pageSize), + }; + }); + const kv = { delete: del, list } as unknown as KVNamespace; + return { store, kv, delete: del, list }; +} + +function createEdgeMock() { + const store = new Map(); + const del = vi.fn(async (key: string | Request) => { + const k = typeof key === 'string' ? key : (key as Request).url; + return store.delete(k); + }); + const cache = { delete: del } as unknown as Cache; + return { store, cache, delete: del }; +} + +describe('revalidate — managed (platform proxy)', () => { + it('forwards { paths } to the proxy /revalidate endpoint (no self-host regression)', async () => { + const fetchMock = vi.fn(async () => new Response('ok', { status: 200 })); + await envContext.run( + { __VOID_PROXY: { fetch: fetchMock }, __PROJECT_ID: 'proj_1', __VOID_PROXY_TOKEN: 'tok_1' }, + async () => { + await revalidate({ paths: ['/', '/blog/x'] }); + }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://proxy/revalidate'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + projectId: 'proj_1', + aiToken: 'tok_1', + paths: ['/', '/blog/x'], + }); + }); + + it('forwards { all: true } to the proxy', async () => { + const fetchMock = vi.fn(async () => new Response('ok', { status: 200 })); + await envContext.run( + { __VOID_PROXY: { fetch: fetchMock }, __PROJECT_ID: 'proj_1', __VOID_PROXY_TOKEN: 'tok_1' }, + async () => { + await revalidate({ all: true }); + }, + ); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + projectId: 'proj_1', + aiToken: 'tok_1', + all: true, + }); + }); + + it('throws when the proxy responds non-ok', async () => { + const fetchMock = vi.fn(async () => new Response('nope', { status: 500 })); + await expect( + envContext.run( + { __VOID_PROXY: { fetch: fetchMock }, __PROJECT_ID: 'proj_1', __VOID_PROXY_TOKEN: 'tok_1' }, + async () => { + await revalidate({ paths: ['/'] }); + }, + ), + ).rejects.toThrow(/500/); + }); + + it('throws when project id / proxy token bindings are missing', async () => { + const fetchMock = vi.fn(async () => new Response('ok')); + await expect( + envContext.run({ __VOID_PROXY: { fetch: fetchMock } }, async () => { + await revalidate({ paths: ['/'] }); + }), + ).rejects.toThrow(/__PROJECT_ID and __VOID_PROXY_TOKEN/); + }); +}); + +describe('revalidate — self-host (own ISR_CACHE + edge)', () => { + let edge: ReturnType; + + beforeEach(() => { + edge = createEdgeMock(); + vi.stubGlobal('caches', { default: edge.cache }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('deletes exactly the requested paths from KV and both edge variants', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); + store.set(isrKey(DPL, '/blog/x'), new ArrayBuffer(4)); + store.set(isrKey(DPL, '/keepme'), new ArrayBuffer(4)); + edge.store.set(htmlEdgeKey(HOST, '/', DPL), new Response('x')); + edge.store.set(jsonEdgeKey(HOST, '/', DPL), new Response('x')); + edge.store.set(htmlEdgeKey(HOST, '/blog/x', DPL), new Response('x')); + edge.store.set(jsonEdgeKey(HOST, '/blog/x', DPL), new Response('x')); + + await envContext.run( + { ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL, __VOID_ISR_HOST: HOST }, + async () => { + await revalidate({ paths: ['/', '/blog/x'] }); + }, + ); + + // Exactly the requested KV keys are gone; the untouched one survives. + expect(store.has(isrKey(DPL, '/'))).toBe(false); + expect(store.has(isrKey(DPL, '/blog/x'))).toBe(false); + expect(store.has(isrKey(DPL, '/keepme'))).toBe(true); + expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, '/')); + expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, '/blog/x')); + + // Both edge variants purged for each path. + expect(edge.store.has(htmlEdgeKey(HOST, '/', DPL))).toBe(false); + expect(edge.store.has(jsonEdgeKey(HOST, '/', DPL))).toBe(false); + expect(edge.store.has(htmlEdgeKey(HOST, '/blog/x', DPL))).toBe(false); + expect(edge.store.has(jsonEdgeKey(HOST, '/blog/x', DPL))).toBe(false); + }); + + it('skips edge purges when no host is configured (KV only)', async () => { + const { store, kv } = createKvMock(); + store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); + + await envContext.run({ ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }, async () => { + await revalidate({ paths: ['/'] }); + }); + + expect(store.has(isrKey(DPL, '/'))).toBe(false); + expect(edge.delete).not.toHaveBeenCalled(); + }); + + it('{ all: true } lists and deletes the entire void-isr// prefix (cursor-paginated)', async () => { + // pageSize 2 forces the cursor loop to iterate across multiple pages. + const { store, kv, list } = createKvMock(2); + store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); + store.set(isrKey(DPL, '/a'), new ArrayBuffer(4)); + store.set(isrKey(DPL, '/b/c'), new ArrayBuffer(4)); + store.set(isrKey(DPL, '/d'), new ArrayBuffer(4)); + store.set(isrKey(DPL, '/e'), new ArrayBuffer(4)); + // A different deployment's entry must never be touched. + store.set(isrKey('other_dpl', '/x'), new ArrayBuffer(4)); + + await envContext.run( + { ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL, __VOID_ISR_HOST: HOST }, + async () => { + await revalidate({ all: true }); + }, + ); + + // The entire prefix for THIS deployment is gone; the other survives. + for (const p of ['/', '/a', '/b/c', '/d', '/e']) { + expect(store.has(isrKey(DPL, p))).toBe(false); + } + expect(store.has(isrKey('other_dpl', '/x'))).toBe(true); + // Cursor loop ran more than once (5 keys / pageSize 2 → 3 list calls). + expect(list.mock.calls.length).toBeGreaterThan(1); + // Edge purge issued for a representative path (round-tripped through denormalizeIsrPath). + expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/', DPL)); + expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/b/c', DPL)); + }); +}); + +describe('revalidate — unconfigured (silent no-op)', () => { + beforeEach(() => { + // No caches.default stub — an unconfigured worker must not even reach it. + vi.stubGlobal('caches', undefined); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('no-ops when neither proxy nor ISR_CACHE is bound', async () => { + await expect( + envContext.run({}, async () => { + await revalidate({ paths: ['/'] }); + }), + ).resolves.toBeUndefined(); + }); + + it('no-ops when ISR_CACHE is bound but the deployment id is missing', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); + await envContext.run({ ISR_CACHE: kv }, async () => { + await revalidate({ paths: ['/'] }); + }); + // Early return before any delete — the key survives. + expect(store.has(isrKey(DPL, '/'))).toBe(true); + expect(kvDelete).not.toHaveBeenCalled(); + }); +}); From bb674ae2ba082496071dbe8684ba91b4998feeeb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 20:10:58 +0800 Subject: [PATCH 14/31] test(isr): strengthen X-VoidPages/waitUntil/corrupt-KV/exact-purge assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up to Task 11. Four ISR tests passed without proving the behavior they asserted; strengthen each so it fails on regression. - X-VoidPages stripping: the JSON gate in handleIsr means an X-VoidPages request never reaches the HTML stale path where cloneWithoutVoidPages runs, so the old assertion was tautological. Export cloneWithoutVoidPages and test the stripper directly (header removed, method/url/other headers preserved). - Stale waitUntil: drive the background render through a test-controlled deferred promise; assert handleIsr resolves to STALE before the render resolves and that a waitUntil task was queued (background, not inline). - Corrupt-KV via handleIsr: hoist the full corruptCases table to module scope and drive ALL 10 shapes (incl. createdAt NaN/Infinity/negative, ttl zero/negative) through handleIsr — each degrades to a live MISS render, never 500, with readIsrCache returning null. - Self-host revalidate: seed unrelated /keepme edge variants and assert they survive; assert exact kv.delete (x2) and cache.delete (x4) keys with no over-purge. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/runtime/isr-cache.ts | 2 +- packages/void/test/unit/isr-cache.test.ts | 233 ++++++++++++++-------- packages/void/test/unit/isr.test.ts | 32 ++- 3 files changed, 182 insertions(+), 85 deletions(-) diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts index 761b32a1c..7d5c8d5ee 100644 --- a/packages/void/src/runtime/isr-cache.ts +++ b/packages/void/src/runtime/isr-cache.ts @@ -590,7 +590,7 @@ async function safeCacheDelete(cache: Cache, key: string): Promise { * existing Request are immutable, so we must clone into a fresh Request. ISR * only runs for GET, so there is no body to preserve. */ -function cloneWithoutVoidPages(request: Request): Request { +export function cloneWithoutVoidPages(request: Request): Request { const headers = new Headers(request.headers); headers.delete('X-VoidPages'); // Construct from the Request OBJECT (not a string URL): workerd copies the diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts index 64d6d77a5..2ab3c9e2e 100644 --- a/packages/void/test/unit/isr-cache.test.ts +++ b/packages/void/test/unit/isr-cache.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { + cloneWithoutVoidPages, deleteIsrCache, denormalizeIsrPath, handleIsr, @@ -152,6 +153,59 @@ function createRender(makeResponse: (req: Request) => Response) { return { render: spy as unknown as RenderFn, spy, requests }; } +// --------------------------------------------------------------------------- +// Corrupt-KV fail-open table (critical). A KV entry whose metadata/headers make +// a Response un-constructible must be rejected at the `readIsrCache` choke point +// so `isrCacheResponse` can never throw. Shared by two suites: the low-level +// `readIsrCache` fail-open tests AND the end-to-end `handleIsr` live-render +// path (every corrupt shape must degrade to a live render, never a 500). +// --------------------------------------------------------------------------- +const corruptCases: Array<{ name: string; seed: () => KvRecord }> = [ + { + name: 'status out of range (s: 999)', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 999, c: BASE, t: 60 } }), + }, + { + name: 'status out of range (s: 0)', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 0, c: BASE, t: 60 } }), + }, + { + name: 'invalid header name', + seed: () => ({ + value: packEntry({ 'Invalid Header Name': 'x' }, 'x'), + metadata: { s: 200, c: BASE, t: 60 }, + }), + }, + { + name: 'createdAt NaN', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: NaN, t: 60 } }), + }, + { + name: 'createdAt Infinity', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: Infinity, t: 60 } }), + }, + { + name: 'createdAt negative', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: -1, t: 60 } }), + }, + { + name: 'ttl NaN', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: NaN } }), + }, + { + name: 'ttl zero', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: 0 } }), + }, + { + name: 'ttl negative', + seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: -5 } }), + }, + { + name: 'malformed packed value', + seed: () => ({ value: new ArrayBuffer(2), metadata: { s: 200, c: BASE, t: 60 } }), + }, +]; + // =========================================================================== // Pure helpers // =========================================================================== @@ -403,55 +457,8 @@ describe('writeIsrCache / readIsrCache / deleteIsrCache', () => { await expect(readIsrCache(makeEnv(kv), '/x')).resolves.toBeNull(); }); - // --- Corrupt-KV fail-open (critical) ------------------------------------- - // A KV entry whose metadata/headers make a Response un-constructible must be - // rejected at this choke point so `isrCacheResponse` can never throw. - const corruptCases: Array<{ name: string; seed: () => KvRecord }> = [ - { - name: 'status out of range (s: 999)', - seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 999, c: BASE, t: 60 } }), - }, - { - name: 'status out of range (s: 0)', - seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 0, c: BASE, t: 60 } }), - }, - { - name: 'invalid header name', - seed: () => ({ - value: packEntry({ 'Invalid Header Name': 'x' }, 'x'), - metadata: { s: 200, c: BASE, t: 60 }, - }), - }, - { - name: 'createdAt NaN', - seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: NaN, t: 60 } }), - }, - { - name: 'createdAt Infinity', - seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: Infinity, t: 60 } }), - }, - { - name: 'createdAt negative', - seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: -1, t: 60 } }), - }, - { - name: 'ttl NaN', - seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: NaN } }), - }, - { - name: 'ttl zero', - seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: 0 } }), - }, - { - name: 'ttl negative', - seed: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: -5 } }), - }, - { - name: 'malformed packed value', - seed: () => ({ value: new ArrayBuffer(2), metadata: { s: 200, c: BASE, t: 60 } }), - }, - ]; - + // Corrupt-KV fail-open: the `corruptCases` table is module-scoped (shared with + // the `handleIsr` live-render suite). readIsrCache must reject every shape. for (const c of corruptCases) { it(`readIsrCache returns null for a corrupt entry: ${c.name}`, async () => { const { store, kv } = createKvMock(); @@ -461,6 +468,51 @@ describe('writeIsrCache / readIsrCache / deleteIsrCache', () => { } }); +// =========================================================================== +// cloneWithoutVoidPages — the background-render request stripper +// =========================================================================== +// A request that carries `X-VoidPages` takes the JSON branch in `handleIsr` and +// returns before ever reaching the HTML stale path where the stripper runs, so +// the public API cannot deliver an X-VoidPages request to it. Test it directly +// so the mandated stripping is actually exercised (the stale-path test can only +// ever hand it a request that already lacks the header — a tautology otherwise). + +describe('cloneWithoutVoidPages', () => { + it('removes X-VoidPages while preserving method, url, and every other header', () => { + const original = new Request(`https://${HOST}/blog/post`, { + method: 'GET', + headers: { + 'X-VoidPages': '1', + 'X-Test-Marker': 'keep', + Accept: 'text/html', + }, + }); + // Precondition: the input genuinely carries the header we expect stripped. + expect(original.headers.has('X-VoidPages')).toBe(true); + + const cleaned = cloneWithoutVoidPages(original); + + // Only X-VoidPages is dropped; everything else is preserved verbatim. This + // fails if `cloneWithoutVoidPages` stops deleting the header (regression). + expect(cleaned.headers.has('X-VoidPages')).toBe(false); + expect(cleaned.headers.get('X-Test-Marker')).toBe('keep'); + expect(cleaned.headers.get('Accept')).toBe('text/html'); + expect(cleaned.method).toBe('GET'); + expect(cleaned.url).toBe(`https://${HOST}/blog/post`); + }); + + it('is a no-op on the header set when X-VoidPages is absent', () => { + const original = new Request(`https://${HOST}/blog/post`, { + method: 'GET', + headers: { 'X-Test-Marker': 'keep' }, + }); + const cleaned = cloneWithoutVoidPages(original); + expect(cleaned.headers.has('X-VoidPages')).toBe(false); + expect(cleaned.headers.get('X-Test-Marker')).toBe('keep'); + expect(cleaned.url).toBe(`https://${HOST}/blog/post`); + }); +}); + // =========================================================================== // handleIsr — the cache ladder end to end // =========================================================================== @@ -536,17 +588,33 @@ describe('handleIsr', () => { ); }); - it('KV HIT (stale) → serves STALE and queues a background revalidation that re-puts KV with an X-VoidPages-stripped request', async () => { + it('KV HIT (stale) → serves STALE BEFORE revalidation runs, queues it via ctx.waitUntil (background, not inline), then re-puts KV', async () => { const { kv, store } = createKvMock(); const env = makeEnv(kv); // Seed a stale entry: created at BASE, now BASE+200s (> ttl 60s). seedEntry(store, '/blog/post', { body: 'stale', createdAt: BASE, ttl: 60 }); vi.setSystemTime(BASE + 200_000); - const { render, spy, requests } = createRender( - () => new Response('revalidated', { headers: { 'content-type': 'text/html' } }), - ); - const { ctx, flush } = createCtx(); + // A DEFERRED render we control: the background revalidation cannot produce a + // response until we call `release()`. This is the crux of proving background + // vs. inline — if `handleIsr` awaited the revalidation INLINE (instead of + // `ctx.waitUntil`), the `await handleIsr(...)` below would deadlock on this + // gate and the test would time out. It resolves only because the stale + // response is served WITHOUT waiting on the re-render. + const requests: Array = []; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const spy = vi.fn(async (req: Request) => { + requests.push(req); + await gate; // block until the test explicitly releases the render + return new Response('revalidated', { + headers: { 'content-type': 'text/html' }, + }); + }); + const render = spy as unknown as RenderFn; + const { ctx, tasks, flush } = createCtx(); // Original request carries a marker header (and NO X-VoidPages — an // X-VoidPages request would take the JSON branch and never reach here). @@ -558,18 +626,32 @@ describe('handleIsr', () => { render, ); - // Stale content served immediately. + // (a) Resolves to the STALE response BEFORE the deferred render resolves — + // the gate is still closed, yet the client already has its answer. expect(res!.headers.get('x-isr-cache')).toBe('STALE'); expect(await res!.text()).toBe('stale'); + // (b) A background task was queued via ctx.waitUntil (revalidation + edge + // warm). Without waitUntil queuing, nothing would be scheduled here. + expect(tasks.length).toBeGreaterThanOrEqual(1); + // The revalidation was KICKED OFF (render invoked) but NOT awaited inline: + // KV still holds the ORIGINAL stale entry because the gate hasn't opened. + expect(spy).toHaveBeenCalledTimes(1); + const midEntry = await readIsrCache(env, '/blog/post'); + expect(midEntry!.createdAt).toBe(BASE); + expect(isStale(midEntry!)).toBe(true); + expect(await new Response(midEntry!.body).text()).toBe('stale'); + // Now release the deferred render and drain the queued background task. + release(); await flush(); + // Background re-render ran exactly once (queued via ctx.waitUntil). expect(spy).toHaveBeenCalledTimes(1); const rerender = requests[0]!; // The re-render request is derived from the original (marker preserved, GET, - // same URL) but carries NO X-VoidPages, so the background render is HTML. - // (On the HTML path the original request never has X-VoidPages; this locks in - // that cloneWithoutVoidPages hands the render fn a clean HTML request.) + // same URL). X-VoidPages stripping itself is proven directly by the + // `cloneWithoutVoidPages` suite above (an X-VoidPages request cannot reach + // this HTML stale path through the public `handleIsr` API). // NOTE: cloneWithoutVoidPages uses `new Request(request, { headers })`, which // preserves `request.cf` on real workerd (verified empirically). These unit // tests run on Node's default vitest pool where `request.cf` does not exist, @@ -670,35 +752,26 @@ describe('handleIsr', () => { expect(spy).toHaveBeenCalledTimes(1); }); - // Corrupt KV entry on the hit path must degrade to a live render, never throw. - const corruptHitCases: Array<{ name: string; record: () => KvRecord }> = [ - { - name: 'status out of range', - record: () => ({ value: packEntry({}, 'x'), metadata: { s: 999, c: BASE, t: 60 } }), - }, - { - name: 'invalid header name', - record: () => ({ - value: packEntry({ 'Invalid Header Name': 'x' }, 'x'), - metadata: { s: 200, c: BASE, t: 60 }, - }), - }, - { - name: 'ttl NaN', - record: () => ({ value: packEntry({}, 'x'), metadata: { s: 200, c: BASE, t: NaN } }), - }, - ]; - - for (const c of corruptHitCases) { - it(`degrades to a live render on a corrupt KV hit: ${c.name}`, async () => { + // A corrupt KV entry on the hit path must degrade to a live render, never 500 + // and never throw — proven through the PUBLIC `handleIsr` API for EVERY corrupt + // shape (the full module-scoped `corruptCases` table, including createdAt + // NaN/Infinity/negative and ttl zero/negative), not just a hand-picked subset. + for (const c of corruptCases) { + it(`degrades to a live render on a corrupt KV hit (never 500): ${c.name}`, async () => { const { store, kv } = createKvMock(); const env = makeEnv(kv); - store.set(isrKey(DPL, '/blog/post'), c.record()); + store.set(isrKey(DPL, '/blog/post'), c.seed()); + + // Choke point: the corrupt entry is rejected at `readIsrCache` (→ null), + // so `isrCacheResponse` never sees an un-constructible entry. + await expect(readIsrCache(env, '/blog/post')).resolves.toBeNull(); + const { render, spy } = createRender( () => new Response('live', { headers: { 'content-type': 'text/html' } }), ); const { ctx, flush } = createCtx(); + // handleIsr returns the LIVE render (MISS), never a 500 and never a throw. const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); expect(res!.status).toBe(200); expect(res!.headers.get('x-isr-cache')).toBe('MISS'); diff --git a/packages/void/test/unit/isr.test.ts b/packages/void/test/unit/isr.test.ts index 128ca349a..0aa81dd7a 100644 --- a/packages/void/test/unit/isr.test.ts +++ b/packages/void/test/unit/isr.test.ts @@ -115,15 +115,21 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { vi.unstubAllGlobals(); }); - it('deletes exactly the requested paths from KV and both edge variants', async () => { + it('deletes EXACTLY the requested paths from KV and both edge variants — no over-purge of unrelated entries', async () => { const { store, kv, delete: kvDelete } = createKvMock(); store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); store.set(isrKey(DPL, '/blog/x'), new ArrayBuffer(4)); store.set(isrKey(DPL, '/keepme'), new ArrayBuffer(4)); + // Target edge variants (both should be purged). edge.store.set(htmlEdgeKey(HOST, '/', DPL), new Response('x')); edge.store.set(jsonEdgeKey(HOST, '/', DPL), new Response('x')); edge.store.set(htmlEdgeKey(HOST, '/blog/x', DPL), new Response('x')); edge.store.set(jsonEdgeKey(HOST, '/blog/x', DPL), new Response('x')); + // UNRELATED edge variants for a path NOT being revalidated — an over-purging + // implementation (e.g. deleting sibling variants, or purging by prefix) would + // wrongly remove these. They MUST survive. + edge.store.set(htmlEdgeKey(HOST, '/keepme', DPL), new Response('x')); + edge.store.set(jsonEdgeKey(HOST, '/keepme', DPL), new Response('x')); await envContext.run( { ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL, __VOID_ISR_HOST: HOST }, @@ -136,14 +142,32 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { expect(store.has(isrKey(DPL, '/'))).toBe(false); expect(store.has(isrKey(DPL, '/blog/x'))).toBe(false); expect(store.has(isrKey(DPL, '/keepme'))).toBe(true); - expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, '/')); - expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, '/blog/x')); - // Both edge variants purged for each path. + // Both edge variants purged for each requested path; unrelated ones survive. expect(edge.store.has(htmlEdgeKey(HOST, '/', DPL))).toBe(false); expect(edge.store.has(jsonEdgeKey(HOST, '/', DPL))).toBe(false); expect(edge.store.has(htmlEdgeKey(HOST, '/blog/x', DPL))).toBe(false); expect(edge.store.has(jsonEdgeKey(HOST, '/blog/x', DPL))).toBe(false); + expect(edge.store.has(htmlEdgeKey(HOST, '/keepme', DPL))).toBe(true); + expect(edge.store.has(jsonEdgeKey(HOST, '/keepme', DPL))).toBe(true); + + // EXACT KV delete calls: precisely the two requested keys (correct id), no + // extras. An over-purge (e.g. also deleting `/keepme`) would fail here. + expect(kvDelete).toHaveBeenCalledTimes(2); + expect(kvDelete.mock.calls.map((c) => c[0])).toEqual([ + isrKey(DPL, '/'), + isrKey(DPL, '/blog/x'), + ]); + + // EXACT edge delete calls: html + json for each requested path (correct + // host/id), in purge order, with no unrelated deletes. + expect(edge.delete).toHaveBeenCalledTimes(4); + expect(edge.delete.mock.calls.map((c) => c[0])).toEqual([ + htmlEdgeKey(HOST, '/', DPL), + jsonEdgeKey(HOST, '/', DPL), + htmlEdgeKey(HOST, '/blog/x', DPL), + jsonEdgeKey(HOST, '/blog/x', DPL), + ]); }); it('skips edge purges when no host is configured (KV only)', async () => { From c9828ba333ae7654baac47a05101b9d7b379fe4c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 20:13:03 +0800 Subject: [PATCH 15/31] fix(isr): type proxy fetch mock params so isr.test.ts typechecks The managed-proxy revalidate tests mocked fetch with no params, so mock.calls[0] typed as [] and casting to [string, RequestInit] failed `vp run check` (TS2352). Type the mock params instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/test/unit/isr.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/void/test/unit/isr.test.ts b/packages/void/test/unit/isr.test.ts index 0aa81dd7a..609139043 100644 --- a/packages/void/test/unit/isr.test.ts +++ b/packages/void/test/unit/isr.test.ts @@ -47,7 +47,9 @@ function createEdgeMock() { describe('revalidate — managed (platform proxy)', () => { it('forwards { paths } to the proxy /revalidate endpoint (no self-host regression)', async () => { - const fetchMock = vi.fn(async () => new Response('ok', { status: 200 })); + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => new Response('ok', { status: 200 }), + ); await envContext.run( { __VOID_PROXY: { fetch: fetchMock }, __PROJECT_ID: 'proj_1', __VOID_PROXY_TOKEN: 'tok_1' }, async () => { @@ -67,7 +69,9 @@ describe('revalidate — managed (platform proxy)', () => { }); it('forwards { all: true } to the proxy', async () => { - const fetchMock = vi.fn(async () => new Response('ok', { status: 200 })); + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => new Response('ok', { status: 200 }), + ); await envContext.run( { __VOID_PROXY: { fetch: fetchMock }, __PROJECT_ID: 'proj_1', __VOID_PROXY_TOKEN: 'tok_1' }, async () => { From 7b48721493b7247ffefaba6cb22105aef1432f21 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 20:19:27 +0800 Subject: [PATCH 16/31] test(isr): assert {all} revalidate lists with the deployment prefix The { all: true } self-host purge test proved pagination + key deletion but not that kv.list is scoped to void-isr//. An unscoped list would still pass (purge rekeys through the current id). Assert the prefix on every list page + no cursor on the first. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/test/unit/isr.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/void/test/unit/isr.test.ts b/packages/void/test/unit/isr.test.ts index 609139043..af15ec238 100644 --- a/packages/void/test/unit/isr.test.ts +++ b/packages/void/test/unit/isr.test.ts @@ -211,6 +211,15 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { expect(store.has(isrKey('other_dpl', '/x'))).toBe(true); // Cursor loop ran more than once (5 keys / pageSize 2 → 3 list calls). expect(list.mock.calls.length).toBeGreaterThan(1); + // EVERY list call is scoped to THIS deployment's prefix. An unscoped + // `kv.list()` (or a wrong/missing prefix) would enumerate — and therefore + // purge — other deployments' keys; assert the prefix on every page. + const listPrefix = `void-isr/${DPL}/`; + for (const [opts] of list.mock.calls) { + expect((opts as { prefix?: string }).prefix).toBe(listPrefix); + } + // The first page starts with no cursor. + expect((list.mock.calls[0][0] as { cursor?: string }).cursor).toBeUndefined(); // Edge purge issued for a representative path (round-tripped through denormalizeIsrPath). expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/', DPL)); expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/b/c', DPL)); From d1895874c9ad135458bea1e45fe7e2fa211edc75 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 20:25:00 +0800 Subject: [PATCH 17/31] test(isr): assert exact waitUntil task count on stale revalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-hit test asserted tasks.length >= 1, which the edge-warm waitUntil alone satisfies — a regression to an untracked floating revalidation promise would still pass. Assert exactly 2 tracked tasks (revalidation + edge-warm) so dropping the revalidation waitUntil fails. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/test/unit/isr-cache.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts index 2ab3c9e2e..66c467a2c 100644 --- a/packages/void/test/unit/isr-cache.test.ts +++ b/packages/void/test/unit/isr-cache.test.ts @@ -630,9 +630,14 @@ describe('handleIsr', () => { // the gate is still closed, yet the client already has its answer. expect(res!.headers.get('x-isr-cache')).toBe('STALE'); expect(await res!.text()).toBe('stale'); - // (b) A background task was queued via ctx.waitUntil (revalidation + edge - // warm). Without waitUntil queuing, nothing would be scheduled here. - expect(tasks.length).toBeGreaterThanOrEqual(1); + // (b) BOTH background tasks are registered via ctx.waitUntil on the stale + // path: the revalidation AND the edge-cache warm. Asserting the EXACT count + // (2), not >= 1, is what protects the registration: a regression that fired + // `revalidateInBackground` as an untracked floating promise would leave only + // the edge-warm tracked, dropping this to 1 and failing here. That matters + // because production Workers terminate untracked background work after the + // response, so the revalidation MUST go through waitUntil to survive. + expect(tasks.length).toBe(2); // The revalidation was KICKED OFF (render invoked) but NOT awaited inline: // KV still holds the ORIGINAL stale entry because the gate hasn't opened. expect(spy).toHaveBeenCalledTimes(1); From e02d8c52234f7d064ac36adc3a4047f416332eeb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 20:33:48 +0800 Subject: [PATCH 18/31] test(selfhost): non-regression for AI manifest binding + serve-gate helper Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/test/unit/index.test.ts | 26 +++++++ packages/void/test/unit/package.test.ts | 99 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 packages/void/test/unit/index.test.ts diff --git a/packages/void/test/unit/index.test.ts b/packages/void/test/unit/index.test.ts new file mode 100644 index 000000000..c3ea22705 --- /dev/null +++ b/packages/void/test/unit/index.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { shouldBindWorkersAi } from '../../src/index.ts'; + +// `shouldBindWorkersAi(command, needsAI, resolvedAi, resultAi)` is the pure, +// testable surface of the self-host serve-gate (the surrounding `mergeBindings` +// is an unexported closure). It decides whether the built wrangler config gets +// an injected Workers AI binding. Assert the real return values. +describe('shouldBindWorkersAi', () => { + it('never binds Workers AI during dev/serve (miniflare has no local AI)', () => { + // Dev uses the P2 `__VOID_TOKEN` proxy path instead of `env.AI`. + expect(shouldBindWorkersAi('serve', true, undefined, undefined)).toBe(false); + }); + + it('binds Workers AI on a self-host build when the app uses AI', () => { + expect(shouldBindWorkersAi('build', true, undefined, undefined)).toBe(true); + }); + + it("does not override a user's root-wrangler `ai` binding", () => { + // A resolved root-wrangler `ai` binding wins — never clobber it. + expect(shouldBindWorkersAi('build', true, { binding: 'X' }, undefined)).toBe(false); + }); + + it('does not bind Workers AI when the app does not use AI', () => { + expect(shouldBindWorkersAi('build', false, undefined, undefined)).toBe(false); + }); +}); diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 1490c902a..7991ecac7 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -9,6 +9,10 @@ import { collectWorkerFiles, resolveSpaFallbackChain, } from '../../src/cli/package.ts'; +import type { VoidConfig } from '../../src/config.ts'; +import type { InferredBindings } from '../../src/inference/walk.ts'; +import { collectRevalidate } from '../../src/isr-config.ts'; +import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; const TMP = join(import.meta.dirname, '.tmp-package-test'); @@ -50,6 +54,101 @@ describe('worker packaging', () => { }); }); +// Non-regression: the managed `void deploy` manifest must stay byte-identical +// after the self-host AI + ISR refactors (Tasks 2 & 5). These assert the manifest +// builder still sets `bindings.ai` from `needsAI` and still carries the exact +// `collectRevalidate` output the deploy path feeds it. +describe('buildVoidManifest — self-host non-regression', () => { + function makeBindings(overrides: Partial = {}): InferredBindings { + return { + needsD1: false, + needsKV: false, + needsR2: false, + needsAuth: false, + needsQueues: false, + needsAI: false, + needsSandbox: false, + ...overrides, + }; + } + + function makePage(pattern: string, revalidate?: number): PageDefinition { + return { + pattern, + params: [], + catchAll: false, + componentPath: `${pattern}.vue`, + serverPath: `${pattern}.server.ts`, + methods: ['GET'], + componentId: pattern.replace(/^\//, ''), + revalidate, + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, + }; + } + + function makeScan(pages: Array): PageScanResult { + return { pages, layouts: [], namedLayouts: [] }; + } + + function configWith(revalidate: number | Record | undefined): VoidConfig { + return (revalidate === undefined ? {} : { routing: { revalidate } }) as VoidConfig; + } + + it('sets bindings.ai === true when the app needs Workers AI', () => { + const manifest = buildVoidManifest({ bindings: makeBindings({ needsAI: true }) }); + expect(manifest.bindings.ai).toBe(true); + }); + + it('omits bindings.ai when the app does not use Workers AI', () => { + const manifest = buildVoidManifest({ bindings: makeBindings({ needsAI: false }) }); + expect(manifest.bindings.ai).toBeUndefined(); + }); + + it('carries collectRevalidate output verbatim into manifest.revalidate (numeric config + per-page)', () => { + // Same inputs the deploy path feeds: void.json config + page-scan per-page rules. + const config = configWith(60); + const scan = makeScan([makePage('/blog/:slug', 120)]); + const revalidate = collectRevalidate(config, scan); + + const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); + + // Merge must be non-trivial (numeric config → path map with "*" fallback)… + expect(revalidate).toEqual({ '/blog/:slug': 120, '*': 60 }); + // …and it must land in the manifest byte-for-byte. + expect(manifest.revalidate).toEqual(revalidate); + expect(Object.keys(manifest.revalidate as Record)).toEqual([ + '/blog/:slug', + '*', + ]); + }); + + it('carries collectRevalidate output verbatim into manifest.revalidate (object config + per-page order)', () => { + const config = configWith({ '/docs/*': 30, '*': 10 }); + const scan = makeScan([makePage('/blog/:slug', 120)]); + const revalidate = collectRevalidate(config, scan); + + const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); + + expect(manifest.revalidate).toEqual(revalidate); + // Per-page entries precede config entries (first-match-wins ordering preserved). + expect(Object.entries(manifest.revalidate as Record)).toEqual([ + ['/blog/:slug', 120], + ['/docs/*', 30], + ['*', 10], + ]); + }); + + it('leaves manifest.revalidate unset when collectRevalidate yields nothing', () => { + const revalidate = collectRevalidate(configWith(undefined), makeScan([])); + const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); + expect(revalidate).toBeUndefined(); + expect(manifest.revalidate).toBeUndefined(); + }); +}); + describe('resolveSpaFallbackChain', () => { it('returns SPA platform switch with no rules when user has no fallbacks (SPA)', () => { const result = resolveSpaFallbackChain('spa', undefined); From cb4d6943bfb0459e982017a717f82c1c607db0db Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 20:47:01 +0800 Subject: [PATCH 19/31] docs: document self-host AI gateway + ISR on Cloudflare Add AI (self-host) and ISR (self-host) subsections to the "Deploy to your own Cloudflare account" guide: ai.gateway + auto-bound env.AI, ai.provider() via the user's own AI Gateway, and the ISR_CACHE KV requirement, each with its non-goal list. Document ai.gateway / __VOID_AI_GATEWAY (incl. the leak-check exception) and ISR_CACHE in the void package instructions, plus 3 Pitfalls entries. Convert packages/void/AGENTS.md into the real CLAUDE.md with an AGENTS.md symlink to match the repo-root convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/integrations/cloudflare.md | 107 ++++++++++++++++ packages/void/AGENTS.md | 206 +----------------------------- packages/void/CLAUDE.md | 219 ++++++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+), 205 deletions(-) mode change 100644 => 120000 packages/void/AGENTS.md create mode 100644 packages/void/CLAUDE.md diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index f661052b3..09fd3ee8f 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -285,3 +285,110 @@ That's it. The Cloudflare Vite plugin produces a complete build output with a me ### Local development `pnpm dev` continues to work as before -- Miniflare creates local instances of all bindings regardless of the IDs in your `wrangler.jsonc`. Your real resource IDs are only used when you run `wrangler deploy`. + +### AI (self-host) + +`void/ai` works on your own Cloudflare account, along two paths: + +- **Workers AI** (`ai.run`, `ai.stream`, `ai.image`) works out of the box. When your app imports `void/ai`, `vite build` infers that you need AI and adds a Workers AI binding (`env.AI`) to the generated `wrangler.json` automatically -- you do **not** add it to `wrangler.jsonc`. +- **Provider models** (`ai.provider("openai").fetch(...)`) route through _your own_ Cloudflare AI Gateway. Set its id in `void.json` and add the provider's API key as a Worker secret. + +```jsonc +// void.json +{ + "ai": { + "gateway": "my-gateway", // an AI Gateway in YOUR Cloudflare account + }, +} +``` + +```bash +# provider API key, stored as a Worker secret (never committed) +wrangler secret put OPENAI_API_KEY +``` + +```ts +// routes/chat.ts +import { defineHandler } from 'void'; +import { ai } from 'void/ai'; + +export const POST = defineHandler(async (c) => { + // Workers AI -- uses env.AI directly + const summary = await ai.run('@cf/meta/llama-3.1-8b-instruct', { + prompt: 'Summarize the changelog.', + }); + + // Provider model -- routes through your "my-gateway" AI Gateway, + // authed with the OPENAI_API_KEY secret above + const res = await ai.provider('openai').fetch('chat/completions', { + method: 'POST', + body: JSON.stringify({ model: 'gpt-4o-mini', messages: [] }), + }); + + return c.json({ summary, provider: await res.json() }); +}); +``` + +`ai.gateway` is required for `ai.provider().fetch()` -- without it, that call returns a `501`. The gateway lives in _your_ account and provider keys come from _your_ secrets, so nothing leaves your account. + +Notes: + +- **No cross-tenant usage metering.** On the Void platform, AI calls are metered and billed through a shared proxy. Self-hosted, there is no metering -- you get your own [Cloudflare AI Gateway analytics](https://developers.cloudflare.com/ai-gateway/) instead. +- **The runtime reads the `env.AI` binding by name** -- a custom-named root AI binding is not supported. +- **`void dev` has no local Workers AI emulation.** In development, AI still routes through Void, so `void dev` AI requires `void auth login` even when you deploy self-hosted. + +### ISR (self-host) + +[Revalidation (ISR)](../guide/edge/revalidation.md) works self-hosted, but the cache KV is **not** auto-injected (a KV binding needs a real namespace id). Create a namespace and bind it as `ISR_CACHE`: + +```bash +wrangler kv namespace create ISR_CACHE +``` + +```jsonc +// wrangler.jsonc +{ + "kv_namespaces": [ + { + "binding": "ISR_CACHE", + "id": "", + }, + ], +} +``` + +Configure revalidation exactly as on the platform -- globally or per-path in `void.json`: + +```jsonc +// void.json +{ + "routing": { + "revalidate": { "/blog/*": 3600, "*": 60 }, + }, +} +``` + +...or per page with an exported `revalidate` literal in a `.server.ts` companion (Pages mode): + +```ts +// pages/blog/[slug].server.ts +export const revalidate = 3600; // seconds +``` + +On-demand purges work through `revalidate()`: + +```ts +import { revalidate } from 'void/isr'; + +await revalidate({ paths: ['/blog/hello'] }); +// or purge every ISR page: +await revalidate({ all: true }); +``` + +Self-hosted, `revalidate()` purges this worker's own KV entries (global, authoritative) plus the current colo's edge cache. + +Limits (the single-worker cache ladder can't do everything the platform's dispatch layer does): + +- **No fleet-wide / cross-colo edge purge.** `revalidate()` clears KV globally and the _local_ colo's edge cache; other colos keep serving their edge copy until it expires (bounded by the response's `s-maxage`), then re-render on the next edge miss. +- **The pages-protocol JSON variant is not served from a cold colo's KV.** A colo that hasn't rendered the HTML yet re-renders the JSON live; the JSON edge cache is warmed only as a side effect of the HTML render path. +- **The warm cache is dropped on every redeploy.** Each `vite build` bakes a fresh deployment id into the cache keys, so cached HTML never outlives the hashed assets it references -- the first request after a deploy is a cold render. diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md deleted file mode 100644 index cadae8d03..000000000 --- a/packages/void/AGENTS.md +++ /dev/null @@ -1,205 +0,0 @@ -# void - -Core Vite plugin — routing, inference, migrations, CLI. - -## Architecture - -The main export `voidPlugin()` returns an array of Vite plugins. For the default Cloudflare target, this includes the `cloudflare()` plugin. For non-CF targets (`node`, `bun`, `deno`), it replaces `cloudflare()` with `nodeTargetPlugin()`: - -``` -# Cloudflare (default) -voidPlugin() → [routingPlugin, migrationPlugin, cloudflare({...inferredBindings})] - -# Node.js / Bun / Deno -voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()] -``` - -Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/env`, `void/ws`) at compile time. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. - -### Runtime Framework - -**Hono** is used as the runtime framework. The generated virtual module creates a Hono app with file-based routes and middleware. Route handlers receive a Hono `Context` object (`c`) and can use the full Hono API (`c.json()`, `c.text()`, `c.req.param()`, etc.) plus the Hono middleware ecosystem. - -- `defineHandler(fn)` — wraps a route handler `(c: CloudContext) => R`, returns `TypedHandler<{}, R>` -- `defineHandler.withValidator(validators)(fn)` — validates body/query/params via Standard Schema before calling handler, returns `TypedHandler` with phantom types for codegen -- `defineMiddleware(fn)` — wraps Hono middleware `(c, next) => void` -- `basicAuth(options)` — built-in Basic Auth middleware for temporary site gates; excludes Void internals under `/__void` -- Handlers can return plain objects/strings (auto-converted via `convertReturnValue`) or use Hono's `c.json()` / `c.text()` directly. -- Route files use **named HTTP method exports** (`export const GET`, `export const POST`, etc.) — one file per path, multiple methods per file. - -### Source Layout - -``` -src/ - index.ts # Main export: voidPlugin() → Plugin[] - config.ts # readConfig(), VoidConfig, convertHeadersConfig(), redirect/header helpers - plugin-routing.ts # Virtual module resolution, file watching, HMR, type codegen - plugin-node-target.ts # Non-CF deploy targets (Node.js, Bun, Deno) — binding guard, dev server, build entry - plugin-inference.ts # Binding inference orchestration (calls walk.ts) - plugin-migration.ts # Local migration runner on dev startup - standard-schema.ts # Inlined Standard Schema V1 types (no @standard-schema/spec dep) - routes-stub.ts # Empty RouteMap/WebSocketRouteMap interfaces (augmented by generated routes.d.ts) - router/ - parse-filename.ts # Route filename → RouteDefinition (pure function) - detect-exports.ts # Detects named HTTP method exports (GET, POST, etc.) via parseSync - websocket.ts # .ws.ts scanner + DO class metadata extraction - scan.ts # Filesystem scanner using tinyglobby + export detection - compile.ts # Generates Hono app code + worker entry + websocket wiring - codegen/ - route-types.ts # Generates .void/routes.d.ts (RouteMap + WebSocketRouteMap) - db-types.ts # (removed — Drizzle schema is now the type source) - queue-types.ts # Generates .void/queues.d.ts (QueueMap with payload types) - queues/ - scan.ts # Queue file scanner (queues/ dir, name from filename + consumer options) - runtime/ - handler.ts # defineHandler(), defineHandler.withValidator(), defineMiddleware(), defineHead(), defineQueue() - queues.ts # QueueMap stub interface + typed `queues` proxy using env.QUEUE_* - validator.ts # runValidation() — Standard Schema body/query/params validation - response.ts # convertReturnValue() — return → Response - fetch.ts # Typed fetch client for void/client - fetch-stream.ts # SSE stream consumer (fetchStream) for void/client - ws.ts # defineRoom(), defineWebSocket(), typed connect() client - ws-server.ts # DO runtime helpers for generated websocket routes - ai.ts # AI proxy — auto-detects backend (service binding / HTTPS / direct binding) - db.ts # Drizzle D1 instance (auto-wired with user schema via virtual module) - inference/ - walk.ts # AST walking with vite's parseSync + estree-walker - migrations/ - runner.ts # Applies pending migrations via drizzle-orm's programmatic migrate() - collect.ts # Reads .sql files from db/migrations/ dir, sorted by filename - validate.ts # Destructive-op detection + pragma enforcement - cli/ - cli.ts # CLI entry: `void deploy|link|login` - deploy.ts # Orchestrator: build → collect/validate migrations → package → upload - project.ts # Reads/creates .void/project.json (projectId + slug only) - auth.ts # Token read/save + runLogin() (local server + browser OAuth) - login.ts # Login command UI (@clack/prompts) - package.ts # Packages dist/ into FormData (worker + assets + migrations + manifest) - agents.ts # Shared agent configs, detection, MCP config targets - client.ts # HTTP client for platform API - types.ts # DeployManifest, MigrationMeta types -``` - -### Key Design Decisions - -- **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. -- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. -- **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings. -- **Migration DB path**: Miniflare stores D1 data at a deterministic path computed from the `database_id` using HMAC-SHA256. The `getMiniflareDatabasePath()` function in `migrations/runner.ts` replicates this algorithm so migrations target `/v3/d1/miniflare-D1DatabaseObject/.sqlite` (for example `.void/v3/...` in default/class-A mode and `.wrangler/state/v3/...` in class-B/C framework mode). -- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a Drizzle D1 instance. `@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). `drizzle-orm` and `drizzle-kit` ship with void. -- **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: the first appends a new entry when writing a file (failing loud if the journal is missing — run `drizzle-kit generate` first), the second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). `gen migration` additionally copies the previous `meta/_snapshot.json` forward to the new idx so drizzle-kit's drop/rollback tooling keeps working on mixed hand-written + drizzle projects; projects without prior snapshots are skipped silently. Drift will fail loudly before touching any remote DB. Motivated by [void-sdk/void#6](https://github.com/void-sdk/void/issues/6), where an orphan scaffolded `.sql` file half-applied against D1 and wedged the deploy. - -### Package Exports - -| Subpath | What | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `void` | `voidPlugin()` named export + `defineHandler` + `defineMiddleware` + `basicAuth` + `defineHead` + `defineQueue` + `CloudContext` type | -| `void/handler` | `defineHandler()`, `defineHandler.withValidator()`, `defineMiddleware()`, `basicAuth()`, `defineHead()`, types | -| `void/response` | `convertReturnValue()` | -| `void/validator` | `runValidation()`, `ValidatorSlots`, `HandlerInput` types | -| `void/routes` | Empty `RouteMap` + `WebSocketRouteMap` stubs (augmented by generated `routes.d.ts`) | -| `void/client` | Typed `fetch()` client + `fetchStream()` SSE consumer + `FetchError` | -| `void/ws` | `defineRoom()`, `defineWebSocket()`, typed `connect()`, and WebSocket context types | -| `void/db` | `db` Drizzle D1 instance (auto-wired with user schema) + `createDb()` for custom D1 bindings | -| `void/queues` | `queues` typed proxy + `QueueMap` stub interface (augmented by generated `queues.d.ts`) | -| `void/ai` | `ai` proxy — `ai.run()`, `ai.stream()`, `ai.models()` with auto-detected backend (service binding / HTTPS / direct) | -| `void/log` | `logger.error/warn/info(msg, fields?)` — emits stringified JSON to `console.*` so Cloudflare Tail captures level + msg | -| `void/env` | `defineEnv()`, typed `env` proxy, built-in schema helpers (`string`, `number`, `oneOf`, …) + global Cloudflare types | - -The CLI binary is at `dist/cli/cli.mjs` (declared in `bin` field of package.json). - -### Internal Staging Projects - -Internal-only staging is selected per linked project with `.void/project.json`: - -```json -{ - "projectId": "proj_...", - "slug": "my-app", - "voidInternalStaging": true -} -``` - -A pre-link file containing only `{ "voidInternalStaging": true }` is valid for Void platform developers who need `void auth login` or `void project link` to target staging before a project is linked. - -When `voidInternalStaging` is true: - -- **Token**: reads/writes `stagingToken` in `~/.void/config.json` instead of `token` -- **API URL**: defaults to `https://api.void.cloud` (prod); when staging is active, uses `https://api.staging.void.cloud` (unless overridden by `VOID_API_URL` or `api` config field) -- **Site domain**: uses `staging.void.app` for generated project URLs -- **Proxy**: injects staging proxy bindings for local dev AI and prerendering - -Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). - -### Route Filename Parsing Rules - -Applied in order to a path relative to `routes/`: - -1. Strip extension (`.ts`, `.js`, `.mts`, `.mjs`) -2. Strip env suffix (`.dev`, `.prod`) -3. Strip trailing `index` -4. Remove route group dirs `(groupname)` -5. Convert `[param]` → `:param`, `[...param]` → `:param{.+}`, `[...]` → `*` -6. Prefix with `/` - -HTTP methods are determined by **named exports** in each file (detected by `detect-exports.ts` via `parseSync`), not by filename suffixes. Files/dirs starting with `_` are ignored by the scanner. - -## Code Generation (`void gen`) - -Rails/Laravel-style scaffolding commands for common boilerplate: - -| Command | Creates | -| -------------------------------------------------- | ----------------------------------------------------------- | -| `void gen model [cols...]` | Drizzle table in `db/schema/` + barrel export + CRUD routes | -| `void gen migration ` | Empty migration file with auto-numbered prefix | -| `void gen route [--methods get,post,...]` | Route file with `defineHandler` exports | -| `void gen middleware ` | Numbered middleware file with `defineMiddleware` | -| `void gen ssr [--react\|--vue\|--svelte\|--solid]` | SSR entry + client entry + App component | -| `void gen cron ` | Cron job file with `defineScheduled` in `crons/` | -| `void gen queue ` | Queue consumer file with `defineQueue` | - -Column types for `gen model`: `string`, `text`, `datetime` → `text()`, `integer` → `integer()`, `boolean` → `integer({ mode: "boolean" })`, `real` → `real()`. Append `?` for nullable (omits `.notNull()`). - -`gen model` creates Drizzle table definitions in `db/schema/` and updates the `db/schema.ts` barrel export. Auto-detects your validation library (valibot, zod, or arktype) from `package.json`. If none is found, prompts to choose one or skip validation. - -Source: `src/cli/gen-args.ts` (arg parsing), `src/cli/gen.ts` (command runner). Tests: `test/unit/gen-args.test.ts`, `test/unit/gen.test.ts`. - -## Coding Agent Integration - -Void ships with first-class support for coding agents (Claude Code, Cursor, Codex, etc.): - -- **`AGENT_PROMPT.md`** — compressed reference prompt shipped in the `void` npm package. Covers project structure, routing, DB, auth, typed fetch, KV/storage/queues/cron, CLI, and deployment. -- **`skills/`** — Claude Code skills shipped in the npm package: - - `void` — unified app + CLI skill covering project architecture, runtime APIs, deployment flows, and CLI operations (deploy, db, gen, secrets, domains, projects) with lazy-loaded reference files. - - `migrate-cloudflare-to-void` — guides migration from `@cloudflare/vite-plugin` to `void`. -- **`docs/`** — all 23 docs markdown files are included in the npm package at `node_modules/void/docs/`. -- **`void init --agents`** — runs all agent setup steps from a single detection/selection pass: injects Void instructions with versioned markers, symlinks skills from the `void` package, and writes MCP config (or prints manual commands/JSON for unsupported/generic agents). -- **`void init` in an empty directory** — scaffolds a Pages-mode starter (React, Vue, Svelte, or Solid), then lets you pick D1, PostgreSQL, or Static Pages before running the same setup flow. - -Agent instructions use versioned markers (`` / ``) for safe update-in-place across version upgrades. - -## Tests - -| Test file | Covers | -| ------------------------------------------- | -------------------------------------------------------------- | -| `test/unit/parse-filename.test.ts` | Route filename parsing (21 tests) | -| `test/unit/scan.test.ts` | Filesystem scanning + queue consumers (13 tests) | -| `test/unit/detect-exports.test.ts` | Named HTTP method export detection (8 tests) | -| `test/unit/compile.test.ts` | Code generation + queue routes (10 tests) | -| `test/unit/response.test.ts` | Return value → Response (8 tests) | -| `test/unit/inference.test.ts` | AST-based binding inference + queues + AI (19 tests) | -| `test/unit/fetch-stream.test.ts` | SSE stream consumer (fetchStream) (5 tests) | -| `test/unit/validate-migrations.test.ts` | Destructive-op detection + pragma (12 tests) | -| `test/unit/collect-migrations.test.ts` | Migration file collection (4 tests) | -| `test/unit/validator.test.ts` | Standard Schema validation runtime (8 tests) | -| `test/unit/validator-real-schemas.test.ts` | Real schema validation (zod/valibot/arktype) (16 tests) | -| `test/unit/define-handler.test.ts` | defineHandler + middleware composition (18 tests) | -| `test/unit/fetch.test.ts` | Typed fetch client (9 tests) | -| `test/unit/ws.test.ts` | Typed websocket client (3 tests) | -| `test/unit/route-types.test.ts` | Route type codegen output (6 tests) | -| `test/unit/route-type-extraction.test.ts` | Type extraction pipeline (Serialize + ExtractInput) (14 tests) | -| `test/unit/db.test.ts` | Drizzle D1 instance + schema wiring (25 tests) | -| `test/unit/queue-types.test.ts` | Queue type codegen output (3 tests) | -| `test/unit/plugin-node-target.test.ts` | Node target plugin — binding guard, plugin creation (5 tests) | -| `test/integration/node-target.test.ts` | Node app playground — dev server + production build (5 tests) | -| `test/integration/queue-playground.test.ts` | Queue send → consumer → KV round-trip (1 test) | diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/packages/void/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/packages/void/CLAUDE.md b/packages/void/CLAUDE.md new file mode 100644 index 000000000..23d5f5b37 --- /dev/null +++ b/packages/void/CLAUDE.md @@ -0,0 +1,219 @@ +# void + +Core Vite plugin — routing, inference, migrations, CLI. + +## Architecture + +The main export `voidPlugin()` returns an array of Vite plugins. For the default Cloudflare target, this includes the `cloudflare()` plugin. For non-CF targets (`node`, `bun`, `deno`), it replaces `cloudflare()` with `nodeTargetPlugin()`: + +``` +# Cloudflare (default) +voidPlugin() → [routingPlugin, migrationPlugin, cloudflare({...inferredBindings})] + +# Node.js / Bun / Deno +voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()] +``` + +Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/env`, `void/ws`) at compile time. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. + +### Runtime Framework + +**Hono** is used as the runtime framework. The generated virtual module creates a Hono app with file-based routes and middleware. Route handlers receive a Hono `Context` object (`c`) and can use the full Hono API (`c.json()`, `c.text()`, `c.req.param()`, etc.) plus the Hono middleware ecosystem. + +- `defineHandler(fn)` — wraps a route handler `(c: CloudContext) => R`, returns `TypedHandler<{}, R>` +- `defineHandler.withValidator(validators)(fn)` — validates body/query/params via Standard Schema before calling handler, returns `TypedHandler` with phantom types for codegen +- `defineMiddleware(fn)` — wraps Hono middleware `(c, next) => void` +- `basicAuth(options)` — built-in Basic Auth middleware for temporary site gates; excludes Void internals under `/__void` +- Handlers can return plain objects/strings (auto-converted via `convertReturnValue`) or use Hono's `c.json()` / `c.text()` directly. +- Route files use **named HTTP method exports** (`export const GET`, `export const POST`, etc.) — one file per path, multiple methods per file. + +### Source Layout + +``` +src/ + index.ts # Main export: voidPlugin() → Plugin[] + config.ts # readConfig(), VoidConfig, convertHeadersConfig(), redirect/header helpers + plugin-routing.ts # Virtual module resolution, file watching, HMR, type codegen + plugin-node-target.ts # Non-CF deploy targets (Node.js, Bun, Deno) — binding guard, dev server, build entry + plugin-inference.ts # Binding inference orchestration (calls walk.ts) + plugin-migration.ts # Local migration runner on dev startup + standard-schema.ts # Inlined Standard Schema V1 types (no @standard-schema/spec dep) + routes-stub.ts # Empty RouteMap/WebSocketRouteMap interfaces (augmented by generated routes.d.ts) + router/ + parse-filename.ts # Route filename → RouteDefinition (pure function) + detect-exports.ts # Detects named HTTP method exports (GET, POST, etc.) via parseSync + websocket.ts # .ws.ts scanner + DO class metadata extraction + scan.ts # Filesystem scanner using tinyglobby + export detection + compile.ts # Generates Hono app code + worker entry + websocket wiring + codegen/ + route-types.ts # Generates .void/routes.d.ts (RouteMap + WebSocketRouteMap) + db-types.ts # (removed — Drizzle schema is now the type source) + queue-types.ts # Generates .void/queues.d.ts (QueueMap with payload types) + queues/ + scan.ts # Queue file scanner (queues/ dir, name from filename + consumer options) + runtime/ + handler.ts # defineHandler(), defineHandler.withValidator(), defineMiddleware(), defineHead(), defineQueue() + queues.ts # QueueMap stub interface + typed `queues` proxy using env.QUEUE_* + validator.ts # runValidation() — Standard Schema body/query/params validation + response.ts # convertReturnValue() — return → Response + fetch.ts # Typed fetch client for void/client + fetch-stream.ts # SSE stream consumer (fetchStream) for void/client + ws.ts # defineRoom(), defineWebSocket(), typed connect() client + ws-server.ts # DO runtime helpers for generated websocket routes + ai.ts # AI proxy — auto-detects backend (service binding / HTTPS / direct binding) + db.ts # Drizzle D1 instance (auto-wired with user schema via virtual module) + inference/ + walk.ts # AST walking with vite's parseSync + estree-walker + migrations/ + runner.ts # Applies pending migrations via drizzle-orm's programmatic migrate() + collect.ts # Reads .sql files from db/migrations/ dir, sorted by filename + validate.ts # Destructive-op detection + pragma enforcement + cli/ + cli.ts # CLI entry: `void deploy|link|login` + deploy.ts # Orchestrator: build → collect/validate migrations → package → upload + project.ts # Reads/creates .void/project.json (projectId + slug only) + auth.ts # Token read/save + runLogin() (local server + browser OAuth) + login.ts # Login command UI (@clack/prompts) + package.ts # Packages dist/ into FormData (worker + assets + migrations + manifest) + agents.ts # Shared agent configs, detection, MCP config targets + client.ts # HTTP client for platform API + types.ts # DeployManifest, MigrationMeta types +``` + +### Key Design Decisions + +- **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. +- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. +- **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings. +- **Migration DB path**: Miniflare stores D1 data at a deterministic path computed from the `database_id` using HMAC-SHA256. The `getMiniflareDatabasePath()` function in `migrations/runner.ts` replicates this algorithm so migrations target `/v3/d1/miniflare-D1DatabaseObject/.sqlite` (for example `.void/v3/...` in default/class-A mode and `.wrangler/state/v3/...` in class-B/C framework mode). +- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a Drizzle D1 instance. `@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). `drizzle-orm` and `drizzle-kit` ship with void. +- **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: the first appends a new entry when writing a file (failing loud if the journal is missing — run `drizzle-kit generate` first), the second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). `gen migration` additionally copies the previous `meta/_snapshot.json` forward to the new idx so drizzle-kit's drop/rollback tooling keeps working on mixed hand-written + drizzle projects; projects without prior snapshots are skipped silently. Drift will fail loudly before touching any remote DB. Motivated by [void-sdk/void#6](https://github.com/void-sdk/void/issues/6), where an orphan scaffolded `.sql` file half-applied against D1 and wedged the deploy. + +### Self-Host AI + ISR + +Two features need extra wiring on a self-hosted `vite build && wrangler deploy` (no Void platform proxy/dispatch in front of the worker). Both are inert on managed `void deploy`. + +- **`ai.gateway` config + `__VOID_AI_GATEWAY` var** — `void.json`'s `ai.gateway` (validated in `config.ts` as `ai?: { gateway?: string }`; non-empty string, `additionalProperties: false`) names a Cloudflare AI Gateway in the user's own account. `mergeBindings` (`index.ts`): (1) injects `{ ai: { binding: 'AI' } }`, but only when `shouldBindWorkersAi()` is true — the app infers `needsAI`, `command === 'build'`, and neither the resolved root wrangler config nor the result already declares an `ai` binding (so it never clobbers a user's own `ai` binding); (2) emits `vars.__VOID_AI_GATEWAY = config.ai.gateway` whenever `config.ai?.gateway` is set. At runtime, `runtime/ai.ts` `resolveBackend()` selects the direct backend (Priority 3) when `env.AI` is present and no `__VOID_PROXY`/`__VOID_TOKEN` exists. `ai.run/stream/image` hit Workers AI directly; `ai.provider().fetch()` routes through the user's own gateway via `directAi.gateway(__VOID_AI_GATEWAY).getUrl(provider)` with the provider key from the user's own secret (`resolveProviderKey` → `env[PROVIDER_KEY_MAP[provider]]`), returning HTTP 501 if `__VOID_AI_GATEWAY` is unset. No cross-tenant metering on this path. The runtime reads `env.AI` **by name** — a custom-named root AI binding is not honored. +- **Internal-binding leak check permits exactly `__VOID_AI_GATEWAY`** — the integration guard (`test/integration/kitchen-sink-build.test.ts`) that asserts no `__VOID_*` var reaches `dist/ssr/wrangler.json` explicitly excludes `__VOID_AI_GATEWAY`, because it is a legitimate, non-credential runtime var (unlike the credential keys in `INTERNAL_ENV_KEYS`, which are stripped). +- **`ISR_CACHE` self-host requirement** — self-host ISR (`runtime/isr-cache.ts`, `runtime/isr.ts`) reads `env.ISR_CACHE` (a `KVNamespace`) + `env.__VOID_ISR_DEPLOYMENT_ID`. Unlike D1/KV/R2, `ISR_CACHE` is **not** auto-injected (a KV binding needs a real namespace id), so the user must create the namespace and declare it as `ISR_CACHE` in their root wrangler config. `routing.revalidate` (void.json) and per-page `export const revalidate` are merged by `collectRevalidate` (`isr-config.ts`) and baked as `__VOID_ISR_REVALIDATE`. On-demand `revalidate()` on self-host purges the worker's own KV (global) plus the local-colo `caches.default` edge variants only. + +### Package Exports + +| Subpath | What | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `void` | `voidPlugin()` named export + `defineHandler` + `defineMiddleware` + `basicAuth` + `defineHead` + `defineQueue` + `CloudContext` type | +| `void/handler` | `defineHandler()`, `defineHandler.withValidator()`, `defineMiddleware()`, `basicAuth()`, `defineHead()`, types | +| `void/response` | `convertReturnValue()` | +| `void/validator` | `runValidation()`, `ValidatorSlots`, `HandlerInput` types | +| `void/routes` | Empty `RouteMap` + `WebSocketRouteMap` stubs (augmented by generated `routes.d.ts`) | +| `void/client` | Typed `fetch()` client + `fetchStream()` SSE consumer + `FetchError` | +| `void/ws` | `defineRoom()`, `defineWebSocket()`, typed `connect()`, and WebSocket context types | +| `void/db` | `db` Drizzle D1 instance (auto-wired with user schema) + `createDb()` for custom D1 bindings | +| `void/queues` | `queues` typed proxy + `QueueMap` stub interface (augmented by generated `queues.d.ts`) | +| `void/ai` | `ai` proxy — `ai.run()`, `ai.stream()`, `ai.models()` with auto-detected backend (service binding / HTTPS / direct) | +| `void/log` | `logger.error/warn/info(msg, fields?)` — emits stringified JSON to `console.*` so Cloudflare Tail captures level + msg | +| `void/env` | `defineEnv()`, typed `env` proxy, built-in schema helpers (`string`, `number`, `oneOf`, …) + global Cloudflare types | + +The CLI binary is at `dist/cli/cli.mjs` (declared in `bin` field of package.json). + +### Internal Staging Projects + +Internal-only staging is selected per linked project with `.void/project.json`: + +```json +{ + "projectId": "proj_...", + "slug": "my-app", + "voidInternalStaging": true +} +``` + +A pre-link file containing only `{ "voidInternalStaging": true }` is valid for Void platform developers who need `void auth login` or `void project link` to target staging before a project is linked. + +When `voidInternalStaging` is true: + +- **Token**: reads/writes `stagingToken` in `~/.void/config.json` instead of `token` +- **API URL**: defaults to `https://api.void.cloud` (prod); when staging is active, uses `https://api.staging.void.cloud` (unless overridden by `VOID_API_URL` or `api` config field) +- **Site domain**: uses `staging.void.app` for generated project URLs +- **Proxy**: injects staging proxy bindings for local dev AI and prerendering + +Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). + +### Route Filename Parsing Rules + +Applied in order to a path relative to `routes/`: + +1. Strip extension (`.ts`, `.js`, `.mts`, `.mjs`) +2. Strip env suffix (`.dev`, `.prod`) +3. Strip trailing `index` +4. Remove route group dirs `(groupname)` +5. Convert `[param]` → `:param`, `[...param]` → `:param{.+}`, `[...]` → `*` +6. Prefix with `/` + +HTTP methods are determined by **named exports** in each file (detected by `detect-exports.ts` via `parseSync`), not by filename suffixes. Files/dirs starting with `_` are ignored by the scanner. + +## Code Generation (`void gen`) + +Rails/Laravel-style scaffolding commands for common boilerplate: + +| Command | Creates | +| -------------------------------------------------- | ----------------------------------------------------------- | +| `void gen model [cols...]` | Drizzle table in `db/schema/` + barrel export + CRUD routes | +| `void gen migration ` | Empty migration file with auto-numbered prefix | +| `void gen route [--methods get,post,...]` | Route file with `defineHandler` exports | +| `void gen middleware ` | Numbered middleware file with `defineMiddleware` | +| `void gen ssr [--react\|--vue\|--svelte\|--solid]` | SSR entry + client entry + App component | +| `void gen cron ` | Cron job file with `defineScheduled` in `crons/` | +| `void gen queue ` | Queue consumer file with `defineQueue` | + +Column types for `gen model`: `string`, `text`, `datetime` → `text()`, `integer` → `integer()`, `boolean` → `integer({ mode: "boolean" })`, `real` → `real()`. Append `?` for nullable (omits `.notNull()`). + +`gen model` creates Drizzle table definitions in `db/schema/` and updates the `db/schema.ts` barrel export. Auto-detects your validation library (valibot, zod, or arktype) from `package.json`. If none is found, prompts to choose one or skip validation. + +Source: `src/cli/gen-args.ts` (arg parsing), `src/cli/gen.ts` (command runner). Tests: `test/unit/gen-args.test.ts`, `test/unit/gen.test.ts`. + +## Coding Agent Integration + +Void ships with first-class support for coding agents (Claude Code, Cursor, Codex, etc.): + +- **`AGENT_PROMPT.md`** — compressed reference prompt shipped in the `void` npm package. Covers project structure, routing, DB, auth, typed fetch, KV/storage/queues/cron, CLI, and deployment. +- **`skills/`** — Claude Code skills shipped in the npm package: + - `void` — unified app + CLI skill covering project architecture, runtime APIs, deployment flows, and CLI operations (deploy, db, gen, secrets, domains, projects) with lazy-loaded reference files. + - `migrate-cloudflare-to-void` — guides migration from `@cloudflare/vite-plugin` to `void`. +- **`docs/`** — all 23 docs markdown files are included in the npm package at `node_modules/void/docs/`. +- **`void init --agents`** — runs all agent setup steps from a single detection/selection pass: injects Void instructions with versioned markers, symlinks skills from the `void` package, and writes MCP config (or prints manual commands/JSON for unsupported/generic agents). +- **`void init` in an empty directory** — scaffolds a Pages-mode starter (React, Vue, Svelte, or Solid), then lets you pick D1, PostgreSQL, or Static Pages before running the same setup flow. + +Agent instructions use versioned markers (`` / ``) for safe update-in-place across version upgrades. + +## Tests + +| Test file | Covers | +| ------------------------------------------- | -------------------------------------------------------------- | +| `test/unit/parse-filename.test.ts` | Route filename parsing (21 tests) | +| `test/unit/scan.test.ts` | Filesystem scanning + queue consumers (13 tests) | +| `test/unit/detect-exports.test.ts` | Named HTTP method export detection (8 tests) | +| `test/unit/compile.test.ts` | Code generation + queue routes (10 tests) | +| `test/unit/response.test.ts` | Return value → Response (8 tests) | +| `test/unit/inference.test.ts` | AST-based binding inference + queues + AI (19 tests) | +| `test/unit/fetch-stream.test.ts` | SSE stream consumer (fetchStream) (5 tests) | +| `test/unit/validate-migrations.test.ts` | Destructive-op detection + pragma (12 tests) | +| `test/unit/collect-migrations.test.ts` | Migration file collection (4 tests) | +| `test/unit/validator.test.ts` | Standard Schema validation runtime (8 tests) | +| `test/unit/validator-real-schemas.test.ts` | Real schema validation (zod/valibot/arktype) (16 tests) | +| `test/unit/define-handler.test.ts` | defineHandler + middleware composition (18 tests) | +| `test/unit/fetch.test.ts` | Typed fetch client (9 tests) | +| `test/unit/ws.test.ts` | Typed websocket client (3 tests) | +| `test/unit/route-types.test.ts` | Route type codegen output (6 tests) | +| `test/unit/route-type-extraction.test.ts` | Type extraction pipeline (Serialize + ExtractInput) (14 tests) | +| `test/unit/db.test.ts` | Drizzle D1 instance + schema wiring (25 tests) | +| `test/unit/queue-types.test.ts` | Queue type codegen output (3 tests) | +| `test/unit/plugin-node-target.test.ts` | Node target plugin — binding guard, plugin creation (5 tests) | +| `test/integration/node-target.test.ts` | Node app playground — dev server + production build (5 tests) | +| `test/integration/queue-playground.test.ts` | Queue send → consumer → KV round-trip (1 test) | + +## Pitfalls / Things That Broke + +- **`__VOID_ISR_DEPLOYMENT_ID` must be a compile-time literal.** `router/compile.ts` stringifies `crypto.randomUUID()` once at build time (`export const __VOID_ISR_DEPLOYMENT_ID = "..."`, exactly like `__VOID_BUILD_KEY`) so every isolate of a deployment shares one id. A bare runtime `crypto.randomUUID()` per-isolate in the emitted source would mint a fresh id per isolate, so the edge-cache key (`handleIsr`'s `deploymentId` option) and the KV key (`env.__VOID_ISR_DEPLOYMENT_ID`, also used by the `revalidate()` purge path) would disagree — breaking cache reads and on-demand purge. +- **Self-host ISR requires a user-declared `ISR_CACHE` KV.** There is no `id: 'local'` injection for it: a placeholder id is fine for miniflare-backed dev but breaks a real `wrangler deploy`, which rejects an unknown namespace id. The user creates the namespace and binds it as `ISR_CACHE`; `readIsrCache`/`writeIsrCache` fail open to a live render when it is absent. +- **Workers AI binding injection is `command === 'build'`-gated.** `shouldBindWorkersAi()` only injects `{ ai: { binding: 'AI' } }` on build, because dev/miniflare has no local Workers AI emulation — `void dev` AI still routes through the Void proxy (P2 `__VOID_TOKEN` path) and needs `void auth login`. From c6bfc329d9cb51e8a24ad06e5914d20bbef57e70 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 20:53:29 +0800 Subject: [PATCH 20/31] docs: correct ai.gateway scope + AI self-host trust-boundary wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai.gateway only affects ai.provider().fetch() — Workers AI (ai.run/image) binds env.AI directly. The schema/config descriptions wrongly implied it routes Workers AI. Also narrow the "nothing leaves your account" claim: the provider request + key are sent to the AI Gateway and upstream provider. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/integrations/cloudflare.md | 2 +- packages/void/schema.json | 2 +- packages/void/src/config.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 09fd3ee8f..a0ff59272 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -329,7 +329,7 @@ export const POST = defineHandler(async (c) => { }); ``` -`ai.gateway` is required for `ai.provider().fetch()` -- without it, that call returns a `501`. The gateway lives in _your_ account and provider keys come from _your_ secrets, so nothing leaves your account. +`ai.gateway` is required for `ai.provider().fetch()` -- without it, that call returns a `501`. No AI traffic or provider secret passes through Void's shared proxy: requests go directly to _your own_ Cloudflare AI Gateway, authenticated with provider secrets from _your_ Worker's environment. (The request and that secret are of course still sent onward to the AI Gateway and the upstream provider you call.) Notes: diff --git a/packages/void/schema.json b/packages/void/schema.json index a0c1e29c9..706f62098 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -366,7 +366,7 @@ }, "ai": { "type": "object", - "description": "AI configuration. Route Workers AI inference through your own Cloudflare AI Gateway.", + "description": "AI configuration for self-hosted deploys. Configures the Cloudflare AI Gateway used by ai.provider().fetch() (Workers AI ai.run/ai.image use the env.AI binding directly and are not affected).", "properties": { "gateway": { "type": "string", diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index e59c790f0..9d3ef1121 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -55,7 +55,7 @@ export type AuthConfig = { providers?: Array; }; -/** AI configuration — route Workers AI inference through your own Cloudflare AI Gateway. */ +/** AI configuration — the Cloudflare AI Gateway used by `ai.provider().fetch()` on self-hosted deploys (Workers AI `ai.run`/`ai.image` use the `env.AI` binding directly). */ export type AiConfig = { gateway?: string; }; From 30c859fc8c5d887556a2e39ac76a6d99f6c411db Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 22:57:04 +0800 Subject: [PATCH 21/31] fix(isr): stamp deployment id in cron/queue, hide ISR_CACHE from void/env, match dynamic-page revalidate - Self-host revalidate() from scheduled/queue handlers no longer no-ops: stamp env.__VOID_ISR_DEPLOYMENT_ID (id only, no host) at the top of both handlers when ISR is wired. isr-absent output is byte-identical. - ISR_CACHE no longer leaks through the public void/env API: add a shared INTERNAL_BINDING_EXACT_NAMES list in env-raw.ts; env.ts isInternalBinding and compile.ts's generated c.env filter both derive the exact-name check from it. Generated worker code is byte-identical. - collectRevalidate converts generated per-page route params (:slug, :slug{.+}) to * wildcards so the literal TTL matcher matches dynamic pages (managed AND self-host). User routing.revalidate literal keys are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/isr-config.ts | 10 +++- packages/void/src/router/compile.ts | 19 +++++--- packages/void/src/runtime/env-raw.ts | 10 ++++ packages/void/src/runtime/env.ts | 6 ++- packages/void/test/unit/compile.test.ts | 46 ++++++++++++++++++ packages/void/test/unit/env.test.ts | 48 ++++++++++++++++++- packages/void/test/unit/isr-config.test.ts | 54 ++++++++++++++++++++-- packages/void/test/unit/package.test.ts | 15 +++--- 8 files changed, 188 insertions(+), 20 deletions(-) diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts index 2c6b649ca..d99798dda 100644 --- a/packages/void/src/isr-config.ts +++ b/packages/void/src/isr-config.ts @@ -56,7 +56,15 @@ export function collectRevalidate( const perPageRules: Record = {}; for (const page of pageScan.pages) { if (page.revalidate != null) { - perPageRules[page.pattern] = page.revalidate; + // The TTL matcher (`matchLiteralPattern`/`resolvePathTtl`) treats `:` as + // a literal and only `*` as a wildcard, so a raw route pattern like + // `/blog/:slug` would never match `/blog/hello`. Convert route params + // (`:slug`, `:slug{.+}`) to `*` so generated dynamic-page rules match. + // This applies ONLY to scanned page patterns — the user's own + // `routing.revalidate` keys below are authored literals and pass through + // untouched. + const key = page.pattern.replace(/:[A-Za-z0-9_]+(?:\{[^}]*\})?/g, '*'); + perPageRules[key] = page.revalidate; } } if (Object.keys(perPageRules).length > 0) { diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 246eeca6d..50f7f86de 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -2,7 +2,7 @@ import type { VoidAuthProvider } from '../auth/providers.ts'; import type { DevRedirectRule, HeaderRule } from '../cli/types.ts'; import type { MigrationPlanBundle } from '../migrations/plan.ts'; import { resolveLayoutIds } from '../pages/layout-resolution.ts'; -import { INTERNAL_BINDING_PREFIXES } from '../runtime/env-raw.ts'; +import { INTERNAL_BINDING_EXACT_NAMES, INTERNAL_BINDING_PREFIXES } from '../runtime/env-raw.ts'; import type { ScanResult } from './scan.ts'; /** @@ -2496,11 +2496,11 @@ export function compileWorkerEntry( // from userEnv is safe. const prefixCheck = [ ...INTERNAL_BINDING_PREFIXES.map((p) => `prop.startsWith(${JSON.stringify(p)})`), - 'prop === "ISR_CACHE"', + ...INTERNAL_BINDING_EXACT_NAMES.map((n) => `prop === ${JSON.stringify(n)}`), ].join(' || '); const prefixCheckNeg = [ ...INTERNAL_BINDING_PREFIXES.map((p) => `!key.startsWith(${JSON.stringify(p)})`), - 'key !== "ISR_CACHE"', + ...INTERNAL_BINDING_EXACT_NAMES.map((n) => `key !== ${JSON.stringify(n)}`), ].join(' && '); const filterBindingsHelper = ` function __filterInternalBindings(env) { @@ -2540,6 +2540,13 @@ function __filterInternalBindings(env) { const isrEnvCapture = isr ? ` env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n env.__VOID_ISR_HOST ??= new URL(request.url).hostname;\n` : ''; + // Scheduled/queue handlers have no request, so there is no host to stamp — but + // they still need the deployment id on the raw env so an on-demand + // `revalidate()` called from a cron or queue consumer can find it and purge KV + // (edge-cache deletes are skipped when host is undefined, per runtime/isr.ts). + const isrDeploymentStamp = isr + ? ` env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n` + : ''; // Render site: unchanged single line when ISR is off; otherwise the render // closure is threaded through the ISR cache ladder (only when ISR_CACHE binds). const renderBlock = isr @@ -2602,11 +2609,11 @@ ${renderBlock} const scheduledHandler = remotePath ? `async function handleScheduled(controller, env, ctx) { ${envValidateCall} if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall}; - const userEnv = __filterInternalBindings(env); +${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); return withRuntimeEnv(env, () => scheduled(controller, userEnv, ctx)); }` : `async function handleScheduled(controller, env, ctx) { -${envValidateCall} const userEnv = __filterInternalBindings(env); +${envValidateCall}${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); return withRuntimeEnv(env, () => scheduled(controller, userEnv, ctx)); }`; @@ -2616,7 +2623,7 @@ ${runtimeEnvImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindings ${internalRouteHelper} ${scheduledHandler} async function queue(batch, env, ctx) { -${envValidateCall}${remotePath ? ` if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall};\n` : ''} const userEnv = __filterInternalBindings(env); +${envValidateCall}${remotePath ? ` if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall};\n` : ''}${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); const messages = [...batch.messages].map(m => ({ id: m.id, timestamp: m.timestamp.toISOString(), body: m.body, attempts: m.attempts, diff --git a/packages/void/src/runtime/env-raw.ts b/packages/void/src/runtime/env-raw.ts index c28dd290e..ae981e0d4 100644 --- a/packages/void/src/runtime/env-raw.ts +++ b/packages/void/src/runtime/env-raw.ts @@ -12,6 +12,16 @@ export type RuntimeEnv = Record; /** Prefixes for platform-internal bindings that must be hidden from user code. */ export const INTERNAL_BINDING_PREFIXES = ['__VOID_', '__PROJECT_'] as const; +/** + * Exact binding names that must be hidden from user code even though they carry + * no internal prefix. `ISR_CACHE` (the self-host ISR KV namespace) is a plain + * binding name, but it is a framework internal — route handlers read the ISR + * cache through `handleIsr`/`revalidate()` on the raw env, never via `c.env` or + * the public `void/env` API. Single source of truth shared by the generated + * `c.env` filter (`router/compile.ts`) and the runtime `env` filter (`env.ts`). + */ +export const INTERNAL_BINDING_EXACT_NAMES = ['ISR_CACHE'] as const; + export const envContext: AsyncLocalStorage = new AsyncLocalStorage(); let cloudflareEnv: RuntimeEnv | undefined; diff --git a/packages/void/src/runtime/env.ts b/packages/void/src/runtime/env.ts index efe4d7a37..0a24a19dc 100644 --- a/packages/void/src/runtime/env.ts +++ b/packages/void/src/runtime/env.ts @@ -9,6 +9,7 @@ import { envContext, asEnv, getRawRuntimeEnv, + INTERNAL_BINDING_EXACT_NAMES, INTERNAL_BINDING_PREFIXES, type RuntimeEnv, } from './env-raw.ts'; @@ -24,7 +25,10 @@ export function withRuntimeEnv(env: unknown, fn: () => T): T { } function isInternalBinding(name: string): boolean { - return INTERNAL_BINDING_PREFIXES.some((p) => name.startsWith(p)); + return ( + INTERNAL_BINDING_PREFIXES.some((p) => name.startsWith(p)) || + (INTERNAL_BINDING_EXACT_NAMES as ReadonlyArray).includes(name) + ); } function filterEnvProxy(env: RuntimeEnv): RuntimeEnv { diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 5e63ae58c..6e5f118e0 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -2880,6 +2880,52 @@ describe('compileWorkerEntry — ISR wiring', () => { expect(code).toContain('env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;'); expect(code).toContain('env.__VOID_ISR_HOST ??= new URL(request.url).hostname;'); }); + + // FIX 1: cron/queue handlers must stamp the deployment id (only — no host, + // there is no request) so a self-host `revalidate()` from a scheduled or queue + // handler can find it and purge KV instead of no-oping. + it('stamps the deployment id at the top of the scheduled + queue handlers when isr is set', () => { + const code = compileWorkerEntry( + true, // hasQueues — emits the queue() handler too + undefined, // remote + 'assets', // assetsDir + [], // webSocketClasses + 'void/_env', // runtimeEnvPath + undefined, // envSchemaModule + undefined, // sandbox + false, // serveSpaFallback + undefined, // live + { runtimePath: 'void/isr' }, + ); + + // Scheduled handler stamps the id (deployment id only — no __VOID_ISR_HOST). + expect(code).toContain( + 'async function handleScheduled(controller, env, ctx) {\n' + + ' env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n' + + ' const userEnv = __filterInternalBindings(env);', + ); + // Queue handler stamps the id (deployment id only — no __VOID_ISR_HOST). + expect(code).toContain( + 'async function queue(batch, env, ctx) {\n' + + ' env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n' + + ' const userEnv = __filterInternalBindings(env);', + ); + // No host stamp in cron/queue (there is no request). + expect(code).not.toContain('env.__VOID_ISR_HOST ??= new URL(controller'); + }); + + it('does NOT stamp the deployment id in scheduled/queue handlers when isr is absent', () => { + const code = compileWorkerEntry(true); // hasQueues, no isr + // Byte-identical to today: no deployment-id stamp anywhere. + expect(code).not.toContain('__VOID_ISR_DEPLOYMENT_ID ??='); + expect(code).toContain( + 'async function handleScheduled(controller, env, ctx) {\n' + + ' const userEnv = __filterInternalBindings(env);', + ); + expect(code).toContain( + 'async function queue(batch, env, ctx) {\n const userEnv = __filterInternalBindings(env);', + ); + }); }); describe('compileRouteTable — baked ISR consts', () => { diff --git a/packages/void/test/unit/env.test.ts b/packages/void/test/unit/env.test.ts index f44f7c8d7..de645a1a8 100644 --- a/packages/void/test/unit/env.test.ts +++ b/packages/void/test/unit/env.test.ts @@ -1,5 +1,11 @@ import { afterAll, beforeEach, describe, expect, it } from 'vite-plus/test'; -import { getRuntimeBinding, getRuntimeEnv, withRuntimeEnv } from '../../src/runtime/env.ts'; +import { env } from '../../src/runtime/env-public.ts'; +import { + getRuntimeBinding, + getRuntimeEnv, + requireRuntimeBinding, + withRuntimeEnv, +} from '../../src/runtime/env.ts'; const globalScope = globalThis as { __env__?: unknown; @@ -41,3 +47,43 @@ describe('runtime env resolution', () => { expect(() => getRuntimeEnv()).toThrow('env: Cloudflare env is unavailable'); }); }); + +// FIX 3: ISR_CACHE is a framework-internal binding with no internal prefix. It +// must be hidden from every public env surface (matching the generated c.env +// filter), while normal bindings pass through. +describe('internal binding filtering — ISR_CACHE (exact-name)', () => { + it('hides ISR_CACHE from getRuntimeEnv / getRuntimeBinding / requireRuntimeBinding', () => { + withRuntimeEnv( + { DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' }, __VOID_TOKEN: 'secret' }, + () => { + const filtered = getRuntimeEnv() as Record; + // Normal binding still visible. + expect(filtered.DB).toEqual({ tag: 'db' }); + expect('DB' in filtered).toBe(true); + // ISR_CACHE hidden by exact name. + expect(filtered.ISR_CACHE).toBeUndefined(); + expect('ISR_CACHE' in filtered).toBe(false); + expect(Object.keys(filtered)).not.toContain('ISR_CACHE'); + // Prefixed internal still hidden (regression guard for the prefix path). + expect('__VOID_TOKEN' in filtered).toBe(false); + + expect(getRuntimeBinding('DB')).toEqual({ tag: 'db' }); + expect(getRuntimeBinding('ISR_CACHE')).toBeUndefined(); + expect(() => requireRuntimeBinding('ISR_CACHE')).toThrow( + "env: Missing Cloudflare binding 'ISR_CACHE'.", + ); + }, + ); + }); + + it('hides ISR_CACHE from the public `env` proxy (absent + not enumerated), keeps normal bindings', () => { + withRuntimeEnv({ DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' } }, () => { + expect('ISR_CACHE' in env).toBe(false); + expect('DB' in env).toBe(true); + expect(Object.keys(env)).not.toContain('ISR_CACHE'); + expect(Object.keys(env)).toContain('DB'); + expect((env as Record).ISR_CACHE).toBeUndefined(); + expect((env as Record).DB).toEqual({ tag: 'db' }); + }); + }); +}); diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts index ea66eb239..27a938cdd 100644 --- a/packages/void/test/unit/isr-config.test.ts +++ b/packages/void/test/unit/isr-config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vite-plus/test'; import type { VoidConfig } from '../../src/config.ts'; import { collectRevalidate, hasRevalidate } from '../../src/isr-config.ts'; import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; +import { resolvePathTtl } from '../../src/runtime/isr-cache.ts'; describe('hasRevalidate', () => { it('treats null / undefined / 0 / negative / {} as no ISR', () => { @@ -63,15 +64,15 @@ describe('collectRevalidate', () => { it('uses per-page rules alone when config is absent', () => { const scan = makeScan([makePage('/blog/:slug', 120), makePage('/about')]); - expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/:slug': 120 }); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/*': 120 }); }); it('turns a numeric config into a path map with the number as the "*" fallback (per-page first)', () => { const scan = makeScan([makePage('/blog/:slug', 120)]); const result = collectRevalidate(configWith(60), scan); - expect(result).toEqual({ '/blog/:slug': 120, '*': 60 }); + expect(result).toEqual({ '/blog/*': 120, '*': 60 }); // Per-page entry must precede the fallback (first-match-wins ordering). - expect(Object.keys(result as Record)).toEqual(['/blog/:slug', '*']); + expect(Object.keys(result as Record)).toEqual(['/blog/*', '*']); }); it('merges per-page rules ahead of an object config (per-page first in match order)', () => { @@ -80,7 +81,7 @@ describe('collectRevalidate', () => { // Per-page entries are emitted before the config entries, so first-match // resolution consults the per-page pattern ahead of the config's catch-alls. expect(Object.entries(result as Record)).toEqual([ - ['/blog/:slug', 120], + ['/blog/*', 120], ['/docs/*', 30], ['*', 10], ]); @@ -91,4 +92,49 @@ describe('collectRevalidate', () => { expect(collectRevalidate(configWith(undefined), scan)).toBeUndefined(); expect(collectRevalidate(configWith(60), scan)).toBe(60); }); + + // FIX 4: generated per-page keys convert route params (`:slug`, `:slug{.+}`) + // to `*` so the literal TTL matcher (`matchLiteralPattern`) actually matches. + describe('route-param → wildcard conversion (generated page keys)', () => { + it('converts a single dynamic param `[slug]` (`/blog/:slug`) to `/blog/*`', () => { + const scan = makeScan([makePage('/blog/:slug', 120)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/*': 120 }); + }); + + it('converts a catch-all param `[...slug]` (`/blog/:slug{.+}`) to `/blog/*`', () => { + const scan = makeScan([makePage('/blog/:slug{.+}', 90)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/*': 90 }); + }); + + it('converts every param in a multi-param pattern (`/shop/:cat/:id` → `/shop/*/*`)', () => { + const scan = makeScan([makePage('/shop/:cat/:id', 45)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/shop/*/*': 45 }); + }); + + it('leaves static page patterns untouched', () => { + const scan = makeScan([makePage('/about', 300)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/about': 300 }); + }); + + it('does NOT convert user `routing.revalidate` literal keys (only generated page keys)', () => { + // A user-authored key that happens to contain a `:` must pass through + // verbatim — only the scanned page patterns are converted. + const scan = makeScan([makePage('/blog/:slug', 120)]); + const result = collectRevalidate(configWith({ '/legacy/:kept': 30 }), scan); + expect(Object.entries(result as Record)).toEqual([ + ['/blog/*', 120], + ['/legacy/:kept', 30], + ]); + }); + }); + + // FIX 4 end-to-end: the converted key must actually match a concrete path + // through the same resolver the runtime uses (mirrors isr-cache resolvePathTtl). + it('produces keys that resolvePathTtl matches for a concrete dynamic path', () => { + const scan = makeScan([makePage('/blog/:slug', 120)]); + const revalidate = collectRevalidate(configWith(undefined), scan); + expect(resolvePathTtl(revalidate, '/blog/hello')).toBe(120); + // A non-matching path still resolves to 0 (no ISR). + expect(resolvePathTtl(revalidate, '/about')).toBe(0); + }); }); diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 7991ecac7..37f380c47 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -115,14 +115,13 @@ describe('buildVoidManifest — self-host non-regression', () => { const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); - // Merge must be non-trivial (numeric config → path map with "*" fallback)… - expect(revalidate).toEqual({ '/blog/:slug': 120, '*': 60 }); + // Merge must be non-trivial (numeric config → path map with "*" fallback). + // The dynamic page pattern `/blog/:slug` is converted to a literal-matcher + // wildcard (`/blog/*`) by collectRevalidate so it actually matches at runtime. + expect(revalidate).toEqual({ '/blog/*': 120, '*': 60 }); // …and it must land in the manifest byte-for-byte. expect(manifest.revalidate).toEqual(revalidate); - expect(Object.keys(manifest.revalidate as Record)).toEqual([ - '/blog/:slug', - '*', - ]); + expect(Object.keys(manifest.revalidate as Record)).toEqual(['/blog/*', '*']); }); it('carries collectRevalidate output verbatim into manifest.revalidate (object config + per-page order)', () => { @@ -134,8 +133,10 @@ describe('buildVoidManifest — self-host non-regression', () => { expect(manifest.revalidate).toEqual(revalidate); // Per-page entries precede config entries (first-match-wins ordering preserved). + // The per-page `/blog/:slug` pattern is converted to `/blog/*`; the user's own + // `routing.revalidate` literal keys (`/docs/*`, `*`) pass through untouched. expect(Object.entries(manifest.revalidate as Record)).toEqual([ - ['/blog/:slug', 120], + ['/blog/*', 120], ['/docs/*', 30], ['*', 10], ]); From c8a7ec98e65262f014fc3cfc1d899d5deb76c15f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 8 Jul 2026 23:26:52 +0800 Subject: [PATCH 22/31] fix(isr): fail open on absent Cache API in handleIsr + cron/queue purge; hide boot-validated ISR_CACHE from void/env FIX A (isr-cache.ts): edgeCache() now returns Cache | undefined via a try/catch around the caches.default deref, so handleIsr never rejects before its fail-open guards. safeCachePut/safeCacheDelete/populateJsonEdgeCache short-circuit on an undefined cache; cache.match uses optional chaining so an absent edge cache is a miss (HTML path falls through to L2 KV), never a throw. FIX B (isr.ts): acquire caches.default lazily inside the if (host) branch of the self-host purge, so the KV-only cron/queue purge (no __VOID_ISR_HOST) never depends on the Cache API. Managed __VOID_PROXY branch untouched. FIX C (env.ts, env-public.ts): export the shared isInternalBinding predicate; _validateEnvOnce no longer seeds internal-binding keys into the sync cache and readKey treats internal-binding names as absent before consulting the cache, so a schema-declared ISR_CACHE is never exposed via void/env even after boot validation seeds its raw value. Normal env keys unaffected. Adds one focused regression test per fix (each verified to fail without the fix). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/runtime/env-public.ts | 19 +++++++- packages/void/src/runtime/env.ts | 9 +++- packages/void/src/runtime/isr-cache.ts | 51 +++++++++++++++----- packages/void/src/runtime/isr.ts | 15 ++++-- packages/void/test/unit/env-schema.test.ts | 53 ++++++++++++++++++++- packages/void/test/unit/isr-cache.test.ts | 55 ++++++++++++++++++++++ packages/void/test/unit/isr.test.ts | 30 ++++++++++++ 7 files changed, 214 insertions(+), 18 deletions(-) diff --git a/packages/void/src/runtime/env-public.ts b/packages/void/src/runtime/env-public.ts index fe401bc4b..840e89ce7 100644 --- a/packages/void/src/runtime/env-public.ts +++ b/packages/void/src/runtime/env-public.ts @@ -32,7 +32,7 @@ import { isUnmaskActive, redactMessage, type SecretOverride } from '../shared/en import type { StandardSchemaV1 } from '../standard-schema.ts'; import { getSecretOverride } from './env-helpers.ts'; import { getRawRuntimeEnv } from './env-raw.ts'; -import { getRuntimeEnv } from './env.ts'; +import { getRuntimeEnv, isInternalBinding } from './env.ts'; type AnyValidator = StandardSchemaV1; type AnySchema = Record; @@ -129,6 +129,15 @@ export async function _validateEnvOnce(envValues: unknown): Promise { cacheList.push(cache); } for (const [key, value] of report.values) { + // Never seed internal framework bindings (__VOID_*, __PROJECT_*, + // ISR_CACHE) into the sync cache — even when a user's schema declares + // one and boot validation resolved its raw value. Exposing it via + // `env.X` would leak an internal binding (e.g. the ISR KV namespace). + // `readKey` also short-circuits internal names, so this is belt-and- + // suspenders that additionally keeps the cache clean. + if (isInternalBinding(key)) { + continue; + } if (!cache.has(key)) { cache.set(key, value); } @@ -231,6 +240,14 @@ export class EnvValidationError extends Error { } function readKey(key: string): unknown { + // Internal framework bindings (__VOID_*, __PROJECT_*, ISR_CACHE) are never + // exposed through the public `env` proxy — treat them as absent BEFORE + // consulting the schema/cache. This holds even when a user's defineEnv schema + // declares one and boot validation seeded its raw value; returning `undefined` + // matches the "not in schema / not found" behavior of the filtered env. + if (isInternalBinding(key)) { + return undefined; + } // Filtered env hides internal __VOID_*/__PROJECT_* bindings from user code. const filtered = getRuntimeEnv() as Record; const rawValue = filtered[key]; diff --git a/packages/void/src/runtime/env.ts b/packages/void/src/runtime/env.ts index 0a24a19dc..dd915c3fa 100644 --- a/packages/void/src/runtime/env.ts +++ b/packages/void/src/runtime/env.ts @@ -24,7 +24,14 @@ export function withRuntimeEnv(env: unknown, fn: () => T): T { return envContext.run(resolved, fn); } -function isInternalBinding(name: string): boolean { +/** + * Shared internal-binding predicate. A binding is framework-internal — and must + * never be exposed to user code — when it carries an internal prefix + * (`__VOID_`, `__PROJECT_`) or is an exact-name internal (e.g. `ISR_CACHE`). + * Single source of truth for the generated `c.env` filter, the runtime `env` + * filter here, and the public `void/env` proxy (`env-public.ts`). + */ +export function isInternalBinding(name: string): boolean { return ( INTERNAL_BINDING_PREFIXES.some((p) => name.startsWith(p)) || (INTERNAL_BINDING_EXACT_NAMES as ReadonlyArray).includes(name) diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts index 7d5c8d5ee..3f8fcb6d4 100644 --- a/packages/void/src/runtime/isr-cache.ts +++ b/packages/void/src/runtime/isr-cache.ts @@ -529,12 +529,15 @@ function jsonCacheResponse(pageData: string, ttl: number, state: 'HIT' | 'MISS') * (fail-open) so this is always safe to fire inside `ctx.waitUntil`. */ export async function populateJsonEdgeCache( - cache: Cache, + cache: Cache | undefined, cacheKey: string, response: Response, ttl: number, state: 'HIT' | 'MISS', ): Promise { + if (!cache) { + return; + } try { const fromHeader = response.headers.get('x-void-page-data'); const pageData = fromHeader @@ -554,16 +557,34 @@ export async function populateJsonEdgeCache( // --------------------------------------------------------------------------- /** - * The per-colo edge cache (`caches.default`). Accessed through a cast because - * this package has both `@types/node` and `@cloudflare/workers-types` in scope, - * and Node's ambient `CacheStorage` (which lacks `.default`) otherwise shadows - * the Workers one on the `caches` global. + * The per-colo edge cache (`caches.default`), or `undefined` when the Cache API + * is absent/degraded. Accessed through a cast because this package has both + * `@types/node` and `@cloudflare/workers-types` in scope, and Node's ambient + * `CacheStorage` (which lacks `.default`) otherwise shadows the Workers one on + * the `caches` global. + * + * Non-throwing by contract: dereferencing `caches.default` can throw if the + * Cache API is missing (e.g. a non-worker runtime) or degraded. We swallow that + * here and return `undefined` so `handleIsr` never rejects before its fail-open + * guards run — the serving invariant is a degraded cache must render live, never + * 500. Every call site guards `undefined` (via `if (!cache)` or `cache?.`). */ -function edgeCache(): Cache { - return (caches as unknown as { default: Cache }).default; +function edgeCache(): Cache | undefined { + try { + return (caches as unknown as { default: Cache }).default; + } catch { + return undefined; + } } -async function safeCachePut(cache: Cache, key: string, response: Response): Promise { +async function safeCachePut( + cache: Cache | undefined, + key: string, + response: Response, +): Promise { + if (!cache) { + return; + } try { await cache.put(key, response); } catch { @@ -571,7 +592,10 @@ async function safeCachePut(cache: Cache, key: string, response: Response): Prom } } -async function safeCacheDelete(cache: Cache, key: string): Promise { +async function safeCacheDelete(cache: Cache | undefined, key: string): Promise { + if (!cache) { + return; + } try { await cache.delete(key); } catch { @@ -709,7 +733,9 @@ export async function handleIsr( const jsonKey = jsonEdgeKey(host, pathname, deploymentId); let jsonCached: Response | undefined; try { - jsonCached = await cache.match(jsonKey); + // `cache?.` — an absent/degraded Cache API (edgeCache() → undefined) is a + // miss, not a throw, so we fall through to a live JSON render. + jsonCached = await cache?.match(jsonKey); } catch { return null; // fail open → caller renders live } @@ -730,7 +756,10 @@ export async function handleIsr( // L1: edge cache (per-colo, sub-ms). let edgeCached: Response | undefined; try { - edgeCached = await cache.match(htmlKey); + // `cache?.` — an absent/degraded Cache API (edgeCache() → undefined) is an + // L1 miss, not a throw, so we fall through to the L2 KV read below (KV still + // serves cached content even with no per-colo edge cache). + edgeCached = await cache?.match(htmlKey); } catch { return null; // fail open → caller renders live } diff --git a/packages/void/src/runtime/isr.ts b/packages/void/src/runtime/isr.ts index dc06fd602..80fca1770 100644 --- a/packages/void/src/runtime/isr.ts +++ b/packages/void/src/runtime/isr.ts @@ -38,14 +38,21 @@ export async function revalidate(options: RevalidateOptions): Promise { if (!kv || !id) { return; } - // Cast because this package has both @types/node and @cloudflare/workers-types - // in scope, and Node's ambient CacheStorage (which lacks `.default`) otherwise - // shadows the Workers one on the `caches` global — same pattern as isr-cache.ts. - const cache = (caches as unknown as { default: Cache }).default; const host = rawEnv.__VOID_ISR_HOST as string | undefined; const purge = async (path: string): Promise => { + // KV first, and unconditionally — the authoritative purge must never + // depend on the Cache API. In scheduled/queue handlers the deployment id + // is stamped without a host (the F1 fix), so edge purges are skipped and + // this KV delete must work even if `caches.default` is degraded. await kv.delete(isrKey(id, path)); if (host) { + // Acquire the edge cache lazily — only when there is a host to purge — + // so the KV path above never touches (and never throws on) the Cache + // API. Cast because this package has both @types/node and + // @cloudflare/workers-types in scope, and Node's ambient CacheStorage + // (which lacks `.default`) otherwise shadows the Workers one on the + // `caches` global — same pattern as isr-cache.ts. + const cache = (caches as unknown as { default: Cache }).default; await cache.delete(htmlEdgeKey(host, path, id)); await cache.delete(jsonEdgeKey(host, path, id)); } diff --git a/packages/void/test/unit/env-schema.test.ts b/packages/void/test/unit/env-schema.test.ts index 5de6a6846..a66594a74 100644 --- a/packages/void/test/unit/env-schema.test.ts +++ b/packages/void/test/unit/env-schema.test.ts @@ -10,7 +10,7 @@ import { _hasRegisteredSchema, _validateEnvOnce, } from '../../src/runtime/env-public.ts'; -import { withRuntimeEnv } from '../../src/runtime/env.ts'; +import { getRuntimeBinding, requireRuntimeBinding, withRuntimeEnv } from '../../src/runtime/env.ts'; // Reset schema between tests so order doesn't leak state. function resetSchema() { @@ -424,6 +424,57 @@ describe('_validateEnvOnce (boot-time worker check)', () => { }); }); +// FIX C: internal framework bindings (e.g. ISR_CACHE) must never be exposed +// through void/env — not even when a user's defineEnv schema declares one and +// boot validation resolved and seeded its RAW value into the sync cache. +describe('_validateEnvOnce — internal bindings stay hidden from void/env', () => { + beforeEach(() => { + resetSchema(); + }); + + it('never exposes a schema-declared ISR_CACHE, even after boot validation seeds its raw value', async () => { + // A passthrough validator so the RAW binding value (a KV-like object) passes + // boot validation and would otherwise be seeded into the sync cache — this + // is exactly the leak path FIX C closes. + const passthrough = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: (v: unknown) => ({ value: v }), + }, + }; + defineEnv({ ISR_CACHE: passthrough, API_KEY: string() }); + + const fakeIsrKv = { __brand: 'isr-kv' }; + const rawEnv = { ISR_CACHE: fakeIsrKv, API_KEY: 'sk_live' }; + + await withRuntimeEnv(rawEnv, async () => { + // Boot validation runs FIRST — as it does at worker boot, before any + // request reads env.X. It succeeds (raw ISR_CACHE passes the passthrough + // validator) and, without FIX C, seeds the raw KV binding into the sync + // cache. Reading env.ISR_CACHE only AFTER this is what exposes the leak: a + // pre-validation read would mask it by caching `undefined` (the filtered + // value) so the `if (!cache.has(key))` seed guard would skip re-seeding. + await _validateEnvOnce(rawEnv); + + // Hidden after boot validation — the seeded raw KV binding must not + // leak through env.ISR_CACHE / `in` / enumeration. + expect((env as Record).ISR_CACHE).toBeUndefined(); + expect('ISR_CACHE' in env).toBe(false); + expect(Object.keys(env)).not.toContain('ISR_CACHE'); + + // Lower-level binding accessors keep hiding it too. + expect(getRuntimeBinding('ISR_CACHE')).toBeUndefined(); + expect(() => requireRuntimeBinding('ISR_CACHE')).toThrow( + "env: Missing Cloudflare binding 'ISR_CACHE'.", + ); + + // A normal declared key remains readable (the fix does not over-filter). + expect(env.API_KEY).toBe('sk_live'); + }); + }); +}); + describe('env proxy with async validators', () => { beforeEach(() => { resetSchema(); diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts index 66c467a2c..dccbca25f 100644 --- a/packages/void/test/unit/isr-cache.test.ts +++ b/packages/void/test/unit/isr-cache.test.ts @@ -855,4 +855,59 @@ describe('handleIsr', () => { ), ).toBeNull(); }); + + // FIX A: the Cache API can be absent/degraded — `edgeCache()` must swallow the + // `caches.default` deref (returning undefined) so `handleIsr` never rejects + // before its fail-open guards run. A degraded cache renders live, never 500. + it('fails open (never throws) when the Cache API is absent — a MISS renders live', async () => { + // Degrade the Cache API entirely: reading `caches.default` throws. + vi.stubGlobal('caches', undefined); + + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => new Response('live', { headers: { 'content-type': 'text/html' } }), + ); + const { ctx, flush } = createCtx(); + + // L1 edge is unreachable → treated as a miss → L2 KV miss → live MISS render. + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('live'); + expect(spy).toHaveBeenCalledTimes(1); + + // Background writes (guarded on an undefined edge cache) drain without throwing; + // the KV write still lands. + await expect(flush()).resolves.toBeUndefined(); + const entry = await readIsrCache(env, '/blog/post'); + expect(entry).not.toBeNull(); + expect(await new Response(entry!.body).text()).toBe('live'); + expect(store.size).toBe(1); + }); + + it('serves the L2 KV HIT with no per-colo edge cache (KV ladder independent of caches.default)', async () => { + // `caches.default` throws on access — the KV ladder must still serve. + vi.stubGlobal('caches', { + get default(): Cache { + throw new Error('Cache API degraded'); + }, + }); + + const { kv, store } = createKvMock(); + const env = makeEnv(kv); + seedEntry(store, '/blog/post', { body: 'kv', createdAt: BASE, ttl: 60 }); + const { render, spy } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res).not.toBeNull(); + expect(res!.headers.get('x-isr-cache')).toBe('HIT'); + expect(await res!.text()).toBe('kv'); + // Served from KV without a live render, despite no reachable edge cache. + expect(spy).not.toHaveBeenCalled(); + // The edge-warm background task (guarded on undefined cache) drains cleanly. + await expect(flush()).resolves.toBeUndefined(); + }); }); diff --git a/packages/void/test/unit/isr.test.ts b/packages/void/test/unit/isr.test.ts index af15ec238..e88b35b1e 100644 --- a/packages/void/test/unit/isr.test.ts +++ b/packages/void/test/unit/isr.test.ts @@ -186,6 +186,36 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { expect(edge.delete).not.toHaveBeenCalled(); }); + // FIX B: cron/queue handlers stamp the deployment id WITHOUT a host (the F1 + // fix), so `revalidate()` must purge KV without ever dereferencing + // `caches.default`. `caches.default` is acquired lazily inside the `if (host)` + // branch — with no host, a degraded Cache API is never touched. + it('purges KV without dereferencing caches.default when no host is set, even if the Cache API is degraded', async () => { + // Degraded Cache API: any access to `caches.default` throws AND is observable. + let defaultAccessed = false; + vi.stubGlobal('caches', { + get default(): Cache { + defaultAccessed = true; + throw new Error('Cache API unavailable'); + }, + }); + + const { store, kv, delete: kvDelete } = createKvMock(); + store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); + + // No __VOID_ISR_HOST → KV-only purge; the degraded caches.default must not throw. + await expect( + envContext.run({ ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }, async () => { + await revalidate({ paths: ['/'] }); + }), + ).resolves.toBeUndefined(); + + // KV delete happened; caches.default was never touched. + expect(store.has(isrKey(DPL, '/'))).toBe(false); + expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, '/')); + expect(defaultAccessed).toBe(false); + }); + it('{ all: true } lists and deletes the entire void-isr// prefix (cursor-paginated)', async () => { // pageSize 2 forces the cursor loop to iterate across multiple pages. const { store, kv, list } = createKvMock(2); From 6ed278d0f95a29f46dd4cb688117a82a8b3e8227 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 9 Jul 2026 14:09:49 +0800 Subject: [PATCH 23/31] fix(isr): gate generated c.env ISR_CACHE filter on ISR emission (restore no-ISR byte-identity) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/router/compile.ts | 14 ++++++++------ packages/void/test/unit/compile.test.ts | 11 +++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 50f7f86de..3e6635eea 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -2490,17 +2490,19 @@ export function compileWorkerEntry( // Filter internal platform bindings so user code cannot access them via c.env. // The original env is preserved for framework internals (withRuntimeEnv, etc.). // Prefixes derived from INTERNAL_BINDING_PREFIXES (shared with env.ts). - // `ISR_CACHE` (the self-host ISR KV binding) is hidden by exact name too — it - // carries no user-facing prefix but must not leak into route handlers' c.env - // (matches the managed platform). `handleIsr` reads the raw env, so hiding it - // from userEnv is safe. + // ISR_CACHE (the self-host ISR KV binding) is hidden by exact name ONLY when + // ISR is emitted — it exists only then (the emitIsr gate). When ISR is absent + // the filter stays prefix-only, byte-identical to the pre-ISR base entry, and + // a user's own binding named ISR_CACHE (if any) is preserved. `handleIsr` + // reads the raw env, so hiding ISR_CACHE from userEnv is safe. + const exactNames = isr ? INTERNAL_BINDING_EXACT_NAMES : []; const prefixCheck = [ ...INTERNAL_BINDING_PREFIXES.map((p) => `prop.startsWith(${JSON.stringify(p)})`), - ...INTERNAL_BINDING_EXACT_NAMES.map((n) => `prop === ${JSON.stringify(n)}`), + ...exactNames.map((n) => `prop === ${JSON.stringify(n)}`), ].join(' || '); const prefixCheckNeg = [ ...INTERNAL_BINDING_PREFIXES.map((p) => `!key.startsWith(${JSON.stringify(p)})`), - ...INTERNAL_BINDING_EXACT_NAMES.map((n) => `key !== ${JSON.stringify(n)}`), + ...exactNames.map((n) => `key !== ${JSON.stringify(n)}`), ].join(' && '); const filterBindingsHelper = ` function __filterInternalBindings(env) { diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 6e5f118e0..08f751419 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -2848,6 +2848,12 @@ describe('compileWorkerEntry — ISR wiring', () => { // The `if (env.ISR_CACHE)` render guard is only emitted when ISR is wired. expect(code).not.toContain('if (env.ISR_CACHE)'); expect(code).not.toContain('__VOID_ISR_REVALIDATE'); + // The c.env internal-binding filter is prefix-only when ISR is absent — + // byte-identical to the pre-ISR base entry. The ISR_CACHE exact-name checks + // must NOT appear (ISR_CACHE the internal binding only exists when ISR is + // emitted; a user's own binding named ISR_CACHE must not be hidden here). + expect(code).not.toContain('prop === "ISR_CACHE"'); + expect(code).not.toContain('key !== "ISR_CACHE"'); }); it('wires the ISR cache ladder with the two baked imports when the isr param is present', () => { @@ -2879,6 +2885,11 @@ describe('compileWorkerEntry — ISR wiring', () => { // on requests that never enter the render ladder. expect(code).toContain('env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;'); expect(code).toContain('env.__VOID_ISR_HOST ??= new URL(request.url).hostname;'); + // When ISR is emitted the c.env internal-binding filter gains the ISR_CACHE + // exact-name checks so the self-host ISR KV binding cannot leak into route + // handlers' c.env (matches the managed platform; handleIsr reads raw env). + expect(code).toContain('prop === "ISR_CACHE"'); + expect(code).toContain('key !== "ISR_CACHE"'); }); // FIX 1: cron/queue handlers must stamp the deployment id (only — no host, From a4a1065b74e1fc3bb5b569c278a53f8d6de92d08 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 9 Jul 2026 15:02:33 +0800 Subject: [PATCH 24/31] test(isr): strip remote-only ai binding before kitchen-sink preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-host build now injects a Workers AI binding (ai.gateway). Workers AI has no local emulator, so its presence forces the CF vite-plugin into a remote proxy session that needs Cloudflare account/creds — unavailable in CI, so the preview server exited before it was reachable. Strip the ai binding before preview (same as containers), since the smoke test never exercises AI and the build-time binding is covered by kitchen-sink-build.test.ts. Co-Authored-By: Claude Opus 4.8 --- packages/void/test/integration/kitchen-sink.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/void/test/integration/kitchen-sink.test.ts b/packages/void/test/integration/kitchen-sink.test.ts index a739bda6a..15f1d8882 100644 --- a/packages/void/test/integration/kitchen-sink.test.ts +++ b/packages/void/test/integration/kitchen-sink.test.ts @@ -55,7 +55,7 @@ describe('playground/kitchen-sink', () => { cwd: ROOT, stdio: 'pipe', }); - disablePreviewContainers(pg); + preparePreviewWranglerConfig(pg); // Start preview server — pre-built assets, no lazy optimization. child = spawn( @@ -539,14 +539,23 @@ describe('playground/kitchen-sink', () => { // Cron: skipped — __scheduled endpoint is only available via wrangler, not Vite dev/preview }); -function disablePreviewContainers(pg: string): void { +// Preview runs the built worker in miniflare with no Cloudflare credentials. +// Two bindings can't run in that environment, so strip them before preview. +// Neither is exercised by this smoke test — both have dedicated coverage: +// - containers (SANDBOX) need a local Docker build. +// - ai (Workers AI) is remote-only, so its presence forces a remote-proxy +// session that needs a CF account/login. The build-time AI binding is +// covered directly by kitchen-sink-build.test.ts. +function preparePreviewWranglerConfig(pg: string): void { const wranglerPath = join(pg, 'dist', 'ssr', 'wrangler.json'); const wranglerConfig = JSON.parse(readFileSync(wranglerPath, 'utf-8')) as { dev?: Record; + ai?: unknown; }; wranglerConfig.dev = { ...(wranglerConfig.dev ?? {}), enable_containers: false, }; + delete wranglerConfig.ai; writeFileSync(wranglerPath, `${JSON.stringify(wranglerConfig, null, 2)}\n`); } From 0faa46289a93d2372195023cedcdbce208120b27 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 9 Jul 2026 15:30:48 +0800 Subject: [PATCH 25/31] fix(isr,ai): stamp X-VoidPages on cached JSON variant; correct non-Bearer provider auth defaults ISR: jsonCacheResponse now mirrors pageJsonResponse (X-VoidPages: true + Vary: X-VoidPages) so a cached Pages JSON variant served on a later X-VoidPages navigation is parsed by the client instead of triggering a full-page hard reload. AI: PROVIDER_AUTH_DEFAULTS gains the confirmed non-Bearer providers (anthropic, elevenlabs, deepgram, fal, cartesia, baseten, ideogram) so the self-host direct gateway path sends each provider's native auth header instead of a wrong Authorization: Bearer that 401s at the AI Gateway pass-through. Bearer fallback and caller apiKeyHeader/apiKeyPrefix override are unchanged. Regression tests added for both fixes (proven to fail without the source change). Co-Authored-By: Claude Opus 4.8 --- packages/void/src/runtime/ai.ts | 13 +++++ packages/void/src/runtime/isr-cache.ts | 7 +++ packages/void/test/unit/ai.test.ts | 63 +++++++++++++++++++++++ packages/void/test/unit/isr-cache.test.ts | 47 +++++++++++++++++ 4 files changed, 130 insertions(+) diff --git a/packages/void/src/runtime/ai.ts b/packages/void/src/runtime/ai.ts index a6ebec86d..037e25ae0 100644 --- a/packages/void/src/runtime/ai.ts +++ b/packages/void/src/runtime/ai.ts @@ -11,8 +11,21 @@ const PROXY_URL = 'https://proxy.void.cloud/ai'; const STAGING_PROXY_URL = 'https://proxy.staging.void.cloud/ai'; const VOID_FILE_MARKER = '__voidFile'; +// Per-provider auth header/prefix for the self-host direct gateway path. The +// gateway passes provider auth through unchanged, so each entry mirrors the +// provider's documented native auth convention. Providers NOT listed here fall +// back to `Authorization: Bearer ` (correct for openai, groq, mistral, +// grok, deepseek, openrouter, perplexity, cohere, cerebras, huggingface, +// replicate). A caller's `apiKeyHeader`/`apiKeyPrefix` override still wins. const PROVIDER_AUTH_DEFAULTS: Record = { 'google-ai-studio': { header: 'x-goog-api-key', prefix: '' }, + anthropic: { header: 'x-api-key', prefix: '' }, + elevenlabs: { header: 'xi-api-key', prefix: '' }, + deepgram: { header: 'authorization', prefix: 'Token ' }, + fal: { header: 'authorization', prefix: 'Key ' }, + cartesia: { header: 'x-api-key', prefix: '' }, + baseten: { header: 'authorization', prefix: 'Api-Key ' }, + ideogram: { header: 'Api-Key', prefix: '' }, }; type SerializedFileInput = { diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts index 3f8fcb6d4..b125cc0cd 100644 --- a/packages/void/src/runtime/isr-cache.ts +++ b/packages/void/src/runtime/isr-cache.ts @@ -516,6 +516,13 @@ function jsonCacheResponse(pageData: string, ttl: number, state: 'HIT' | 'MISS') return new Response(pageData, { headers: { 'content-type': 'application/json', + // Mirror the live JSON responder `pageJsonResponse` (pages/protocol.ts): + // it stamps `X-VoidPages: true` + `Vary: X-VoidPages`. The Pages client + // gates JSON parsing on `X-VoidPages` (navigation-engine.ts) — a cached + // JSON variant served on a later navigation without it is treated as + // non-JSON and hard-reloads the whole page. Keep name/value/casing exact. + 'X-VoidPages': 'true', + Vary: 'X-VoidPages', 'Cache-Control': `public, s-maxage=${ttl}, max-age=0, must-revalidate`, 'x-isr-cache': state, }, diff --git a/packages/void/test/unit/ai.test.ts b/packages/void/test/unit/ai.test.ts index 412faf78c..4a6ec0956 100644 --- a/packages/void/test/unit/ai.test.ts +++ b/packages/void/test/unit/ai.test.ts @@ -429,6 +429,69 @@ describe('runtime ai', () => { expect(sent).not.toHaveProperty('providerKey'); }); + it('sends the provider-native auth header for a non-Bearer provider (anthropic → x-api-key, no Bearer)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ id: 'msg_direct' }), { + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', ANTHROPIC_API_KEY: 'sk-ant-test' }, + async () => { + await ai.provider('anthropic').fetch('/v1/messages', { + body: { model: 'claude-3-5-sonnet', messages: [{ role: 'user', content: 'Hi' }] }, + }); + }, + ); + + expect(direct.gateway.mock.results[0].value.getUrl).toHaveBeenCalledWith('anthropic'); + const init = fetchMock.mock.calls[0][1]!; + const headers = new Headers(init.headers); + // Anthropic authenticates with `x-api-key` and NO prefix — sending + // `Authorization: Bearer ` 401s at the gateway pass-through. + expect(headers.get('x-api-key')).toBe('sk-ant-test'); + expect(headers.get('authorization')).toBeNull(); + }); + + it('sends a prefixed non-Bearer auth header (deepgram → Authorization: Token)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', DEEPGRAM_API_KEY: 'dg-test' }, + async () => { + await ai + .provider('deepgram') + .fetch('/v1/listen', { body: { url: 'https://example.com/a.wav' } }); + }, + ); + + const init = fetchMock.mock.calls[0][1]!; + const headers = new Headers(init.headers); + // Deepgram uses the `Token ` scheme, not `Bearer `. + expect(headers.get('authorization')).toBe('Token dg-test'); + }); + it('resolves to 501 when no AI gateway is configured', async () => { vi.resetModules(); const { envContext } = await import('../../src/runtime/env-raw.ts'); diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts index dccbca25f..5ea7220a1 100644 --- a/packages/void/test/unit/isr-cache.test.ts +++ b/packages/void/test/unit/isr-cache.test.ts @@ -739,6 +739,53 @@ describe('handleIsr', () => { expect(spy).not.toHaveBeenCalled(); }); + // Regression: when an HTML render warms the JSON edge variant, the STORED JSON + // response must mirror the live responder `pageJsonResponse` (pages/protocol.ts) + // by carrying `X-VoidPages: true` + `Vary: X-VoidPages`. The Pages client gates + // JSON parsing on `X-VoidPages` (navigation-engine.ts) — a cached JSON variant + // served on a later navigation WITHOUT it is treated as non-JSON and triggers a + // full-page hard reload. Prove the served header end to end (warm → HIT serve). + it('cached JSON variant (warmed by an HTML render) carries X-VoidPages + Vary on a later navigation HIT', async () => { + const { kv } = createKvMock(); + const env = makeEnv(kv); + // HTML MISS render exposes embedded page data via the x-void-page-data header, + // which `populateJsonEdgeCache` stores as the JSON edge variant. + const { render } = createRender( + () => + new Response('live', { + headers: { + 'content-type': 'text/html', + 'x-void-page-data': '{"component":"Blog","props":{}}', + }, + }), + ); + const { ctx, flush } = createCtx(); + + // 1) HTML navigation MISS → warms the JSON edge variant in the background. + await handleIsr(htmlRequest(), env, ctx, OPTS, render); + await flush(); + expect(edge.store.has(jsonEdgeKey(HOST, '/blog/post', DPL))).toBe(true); + + // 2) A subsequent Pages (X-VoidPages) navigation hits the warmed JSON variant. + const { render: render2, spy: spy2 } = createRender(() => new Response('SHOULD-NOT-RENDER')); + const { ctx: ctx2 } = createCtx(); + const res = await handleIsr( + htmlRequest('/blog/post', { 'X-VoidPages': '1' }), + env, + ctx2, + OPTS, + render2, + ); + + // Served from the cached JSON variant (no live render). + expect(spy2).not.toHaveBeenCalled(); + // The header the client gates on — missing it means a hard reload. + expect(res!.headers.get('X-VoidPages')).toBe('true'); + expect(res!.headers.get('Vary')).toBe('X-VoidPages'); + expect(res!.headers.get('content-type')).toBe('application/json'); + expect(await res!.text()).toBe('{"component":"Blog","props":{}}'); + }); + it('fails open to a LIVE render (not a 500) when getWithMetadata throws', async () => { const { kv } = createKvMock({ throwOnGet: true }); const env = makeEnv(kv); From 9df3936d6e4f69f5e42e96953d33aff75e960fc3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 9 Jul 2026 16:11:21 +0800 Subject: [PATCH 26/31] fix(isr): segment-anchored revalidate matching + host-scoped self-host KV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX #3 — segment-aware revalidate (parity with void-platform dispatch): - collectRevalidate (isr-config.ts): only named/anonymous catch-alls (:name{.+}, *) collapse to '*'; single-segment params (:slug) stay as :slug so the matcher can anchor them to ONE path segment. - matchLiteralPattern (isr-cache.ts): whole-segment :name -> [^/]+ via a U+E000 private-use sentinel (survives regex-special escaping, expanded after). Mid-segment literal ':' (e.g. /api/v1:health) left alone. '*' still crosses '/'. Strict SUPERSET of the old '*'-only matcher, so old '*'-only manifests match identically (rollout safety). Byte-identical to the shared cross-repo matcher spec. - Manifest emission changes again (single-seg pages now :slug, not '*'); updated the pinned keys in isr-config/package tests. FIX #4 — host-scoped self-host ISR KV keys + host registry: - isrKey -> void-isr///; readIsrCache/writeIsrCache/ deleteIsrCache/handleIsr/revalidateInBackground thread `host` (url.hostname). One host never reads/serves another host's cached bytes. - writeIsrCache registers the host under void-isr-hosts// (guarded by an in-isolate Set, fail-open). New listIsrHosts(kv, id) enumerates them. - revalidate() self-host purge (isr.ts): {paths} enumerates registered hosts (union __VOID_ISR_HOST) and purges each host's KV + edge; {all} splits the host off the scanned key tail before denormalizing. KV purge never depends on caches.default (edge purge guarded, fail-open). Empty host set no-ops. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/isr-config.ts | 15 +- packages/void/src/runtime/isr-cache.ts | 124 +++++++++++-- packages/void/src/runtime/isr.ts | 59 ++++-- packages/void/test/unit/isr-cache.test.ts | 198 ++++++++++++++++++--- packages/void/test/unit/isr-config.test.ts | 42 +++-- packages/void/test/unit/isr.test.ts | 130 +++++++++----- packages/void/test/unit/package.test.ts | 17 +- 7 files changed, 446 insertions(+), 139 deletions(-) diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts index d99798dda..70e0b24bc 100644 --- a/packages/void/src/isr-config.ts +++ b/packages/void/src/isr-config.ts @@ -56,14 +56,13 @@ export function collectRevalidate( const perPageRules: Record = {}; for (const page of pageScan.pages) { if (page.revalidate != null) { - // The TTL matcher (`matchLiteralPattern`/`resolvePathTtl`) treats `:` as - // a literal and only `*` as a wildcard, so a raw route pattern like - // `/blog/:slug` would never match `/blog/hello`. Convert route params - // (`:slug`, `:slug{.+}`) to `*` so generated dynamic-page rules match. - // This applies ONLY to scanned page patterns — the user's own - // `routing.revalidate` keys below are authored literals and pass through - // untouched. - const key = page.pattern.replace(/:[A-Za-z0-9_]+(?:\{[^}]*\})?/g, '*'); + // Single-segment params (:slug) stay as :slug so the matcher can anchor + // them to ONE path segment; named catch-alls (:slug{.+}) and anonymous + // catch-alls (*) stay/become '*' (a splat that crosses '/'). See + // matchLiteralPattern. This applies ONLY to scanned page patterns — the + // user's own `routing.revalidate` keys below are authored literals and + // pass through untouched. + const key = page.pattern.replace(/:[A-Za-z0-9_]+\{[^}]*\}/g, '*'); perPageRules[key] = page.revalidate; } } diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts index b125cc0cd..ccb2065de 100644 --- a/packages/void/src/runtime/isr-cache.ts +++ b/packages/void/src/runtime/isr-cache.ts @@ -110,12 +110,24 @@ export function canonicalIsrPathname(pathname: string): string { // --------------------------------------------------------------------------- /** - * KV cache key for an ISR-cached page. Deployment-scoped so cached HTML cannot - * outlive the hashed assets referenced by the deployment that produced it - * (a fresh `wrangler deploy` mints a new deployment id → cold cache). + * KV cache key for an ISR-cached page. Deployment- AND host-scoped: cached HTML + * cannot outlive the hashed assets of the deployment that produced it (a fresh + * `wrangler deploy` mints a new deployment id → cold cache), and one host's + * cached content never bleeds into another host served by the same worker + * (custom domain + workers.dev, apex + www, etc.). */ -export function isrKey(deploymentId: string, pathname: string): string { - return `void-isr/${deploymentId}/${normalizeIsrPath(pathname)}`; +export function isrKey(deploymentId: string, host: string, pathname: string): string { + return `void-isr/${deploymentId}/${host}/${normalizeIsrPath(pathname)}`; +} + +/** + * Host-registry key. Every host that has written cached content registers itself + * under `void-isr-hosts//` (value `''`) so a purge with no request host + * (cron / queue `revalidate({ paths })`) can enumerate the hosts to purge. + * Scoped by deployment id so registrations from an old deploy do not leak. + */ +export function hostRegistryKey(deploymentId: string, host: string): string { + return `void-isr-hosts/${deploymentId}/${host}`; } /** @@ -144,9 +156,12 @@ const literalRegexCache = new Map(); /** * Match a pattern against a pathname using LITERAL semantics — no named - * captures. Only `*` is special (matches any characters including `/`); every - * other character (including `:`) is treated as a literal. Malformed patterns - * yield `false` rather than throwing. + * captures. Two wildcards are special: `*` matches any characters INCLUDING `/` + * (a splat), and a WHOLE-segment `:name` (preceded by `/` or start, followed by + * `/` or end) matches exactly ONE path segment (`[^/]+`). A mid-segment literal + * `:` (e.g. `/api/v1:health`) is left alone. This is a strict SUPERSET of the + * old `*`-only matcher (old `*`-only manifests match identically). Malformed + * patterns yield `false` rather than throwing. */ export function matchLiteralPattern(pattern: string, pathname: string): boolean { let regex = literalRegexCache.get(pattern); @@ -156,7 +171,11 @@ export function matchLiteralPattern(pattern: string, pathname: string): boolean literalRegexCache.set(pattern, regex); } else { try { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*'); + const escaped = pattern + .replace(/(^|\/):[A-Za-z0-9_]+(?=\/|$)/g, '$1') // whole-segment :name -> sentinel + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials + .replaceAll('*', '.*') // catch-all crosses '/' + .replaceAll('', '[^/]+'); // sentinel -> single segment regex = new RegExp(`^${escaped}$`); } catch { return false; @@ -226,17 +245,19 @@ export function unpackIsrValue( /** * Read an ISR cache entry from KV. Returns null on cache miss, malformed * metadata, or ANY KV error (fail-open → caller renders live). The KV key is - * deployment-scoped via `env.__VOID_ISR_DEPLOYMENT_ID` (see module header). + * deployment-scoped via `env.__VOID_ISR_DEPLOYMENT_ID` (see module header) and + * host-scoped via `host` so one host never reads another host's cached bytes. */ export async function readIsrCache( env: IsrCacheEnv, + host: string, pathname: string, ): Promise { const kv = env.ISR_CACHE; if (!kv) { return null; } - const key = isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', pathname); + const key = isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', host, pathname); let value: ArrayBuffer | null; let metadata: IsrCacheMetadata | null; try { @@ -305,6 +326,7 @@ export async function readIsrCache( */ export async function writeIsrCache( env: IsrCacheEnv, + host: string, pathname: string, response: Response, ttl: number, @@ -319,10 +341,11 @@ export async function writeIsrCache( const headers = serializeHeaders(responseClone); const status = responseClone.status; + const id = env.__VOID_ISR_DEPLOYMENT_ID ?? ''; try { const body = await responseClone.arrayBuffer(); const packed = packIsrValue(headers, body); - await kv.put(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', pathname), packed, { + await kv.put(isrKey(id, host, pathname), packed, { metadata: { s: status, c: Date.now(), @@ -332,19 +355,73 @@ export async function writeIsrCache( } catch { // Fail open — swallow write errors so a broken ISR_CACHE never 500s. } + // Register this host so a host-less purge (cron / queue) can find it. Guarded + // by an in-isolate Set so it costs one KV write per (isolate, id, host), not + // one per page write. Fail-open — a registry write error never 500s a render. + await registerIsrHost(kv, id, host); +} + +/** + * In-isolate guard: `${id}/${host}` values whose host-registry key this isolate + * has already written (or attempted). Bounds host registration to ~one KV write + * per (isolate, deployment, host) instead of one per page write. + */ +const registeredHosts = new Set(); + +async function registerIsrHost(kv: KVNamespace, deploymentId: string, host: string): Promise { + const guardKey = `${deploymentId}/${host}`; + if (registeredHosts.has(guardKey)) { + return; + } + // Mark BEFORE the await so concurrent page writes in this isolate don't all + // fire the registry put; a rare transient failure simply retries next isolate. + registeredHosts.add(guardKey); + try { + await kv.put(hostRegistryKey(deploymentId, host), ''); + } catch { + // Fail open — a registry write failure must never 500 a page render. + } +} + +/** + * List every host that has registered cached content for a deployment. Used by + * the on-demand `revalidate({ paths })` purge when there is no request host + * (cron / queue). Fail-open: a registry read error yields the hosts collected so + * far (or none) rather than throwing — degraded purge coverage, never a 500. + */ +export async function listIsrHosts(kv: KVNamespace, deploymentId: string): Promise> { + const prefix = `void-isr-hosts/${deploymentId}/`; + const hosts: Array = []; + try { + let cursor: string | undefined; + do { + const list = await kv.list({ prefix, cursor }); + for (const key of list.keys) { + hosts.push(key.name.slice(prefix.length)); + } + cursor = list.list_complete ? undefined : list.cursor; + } while (cursor); + } catch { + // Fail open — a broken registry degrades purge coverage, never throws. + } + return hosts; } /** * Delete an ISR cache entry from KV. Used when a worker permanently opts out * via `x-revalidate: 0`. Any KV error is swallowed (fail-open). */ -export async function deleteIsrCache(env: IsrCacheEnv, pathname: string): Promise { +export async function deleteIsrCache( + env: IsrCacheEnv, + host: string, + pathname: string, +): Promise { const kv = env.ISR_CACHE; if (!kv) { return; } try { - await kv.delete(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', pathname)); + await kv.delete(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? '', host, pathname)); } catch { // Fail open. } @@ -641,6 +718,7 @@ export function cloneWithoutVoidPages(request: Request): Request { async function revalidateInBackground( env: IsrCacheEnv, request: Request, + host: string, pathname: string, defaultTtl: number, keys: { htmlKey: string; jsonKey: string }, @@ -655,7 +733,7 @@ async function revalidateInBackground( const ttl = resolveRevalidateTtl(response, defaultTtl); const cache = edgeCache(); if (ttl > 0 && response.ok) { - await writeIsrCache(env, pathname, response, ttl); + await writeIsrCache(env, host, pathname, response, ttl); // Update HTML edge cache with the fresh response. const headers = new Headers(response.headers); headers.set('x-isr-cache', 'HIT'); @@ -673,7 +751,7 @@ async function revalidateInBackground( await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, 'HIT'); } else if (ttl === 0) { // Worker permanently opted out — remove KV + both edge variants. - await deleteIsrCache(env, pathname); + await deleteIsrCache(env, host, pathname); await safeCacheDelete(cache, keys.htmlKey); await safeCacheDelete(cache, keys.jsonKey); } @@ -775,7 +853,7 @@ export async function handleIsr( } // L2: KV (global, authoritative). `readIsrCache` fails open to null. - const cached = await readIsrCache(env, pathname); + const cached = await readIsrCache(env, host, pathname); if (cached) { // Belt-and-suspenders: `readIsrCache` already validates the entry so the // Response constructors below can't throw, but the never-500 fail-open @@ -785,7 +863,15 @@ export async function handleIsr( const stale = isStale(cached); if (stale) { ctx.waitUntil( - revalidateInBackground(env, request, pathname, isrTtl, { htmlKey, jsonKey }, render), + revalidateInBackground( + env, + request, + host, + pathname, + isrTtl, + { htmlKey, jsonKey }, + render, + ), ); } const cacheResponse = isrCacheResponse(cached, stale); @@ -806,7 +892,7 @@ export async function handleIsr( if (ttl > 0 && response.ok) { // Every clone below is taken synchronously (before `response.body` is read // for the client) so no consumer races the body away. - ctx.waitUntil(writeIsrCache(env, pathname, response, ttl)); + ctx.waitUntil(writeIsrCache(env, host, pathname, response, ttl)); ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, 'MISS')); const cacheHeaders = new Headers(response.headers); diff --git a/packages/void/src/runtime/isr.ts b/packages/void/src/runtime/isr.ts index 80fca1770..6cdd61bd9 100644 --- a/packages/void/src/runtime/isr.ts +++ b/packages/void/src/runtime/isr.ts @@ -1,5 +1,5 @@ import { getRawRuntimeEnv } from './env-raw.ts'; -import { denormalizeIsrPath, htmlEdgeKey, isrKey, jsonEdgeKey } from './isr-cache.ts'; +import { denormalizeIsrPath, htmlEdgeKey, isrKey, jsonEdgeKey, listIsrHosts } from './isr-cache.ts'; export type RevalidateOptions = { paths?: Array; @@ -38,38 +38,61 @@ export async function revalidate(options: RevalidateOptions): Promise { if (!kv || !id) { return; } - const host = rawEnv.__VOID_ISR_HOST as string | undefined; - const purge = async (path: string): Promise => { - // KV first, and unconditionally — the authoritative purge must never - // depend on the Cache API. In scheduled/queue handlers the deployment id - // is stamped without a host (the F1 fix), so edge purges are skipped and - // this KV delete must work even if `caches.default` is degraded. - await kv.delete(isrKey(id, path)); - if (host) { - // Acquire the edge cache lazily — only when there is a host to purge — - // so the KV path above never touches (and never throws on) the Cache - // API. Cast because this package has both @types/node and - // @cloudflare/workers-types in scope, and Node's ambient CacheStorage - // (which lacks `.default`) otherwise shadows the Workers one on the - // `caches` global — same pattern as isr-cache.ts. + const requestHost = rawEnv.__VOID_ISR_HOST as string | undefined; + // Cache keys are host-scoped (`void-isr///`), so a purge must + // target a concrete (host, path). Delete KV first and unconditionally — the + // authoritative purge must never depend on the Cache API — then best-effort + // purge the per-host edge variants, guarded so a degraded `caches.default` + // (scheduled/queue handlers, or no Cache API) cannot fail the KV delete. + const purge = async (host: string, path: string): Promise => { + await kv.delete(isrKey(id, host, path)); + try { + // Cast because this package has both @types/node and @cloudflare/workers-types + // in scope, and Node's ambient CacheStorage (which lacks `.default`) + // otherwise shadows the Workers one — same pattern as isr-cache.ts. const cache = (caches as unknown as { default: Cache }).default; await cache.delete(htmlEdgeKey(host, path, id)); await cache.delete(jsonEdgeKey(host, path, id)); + } catch { + // Fail open — a degraded Cache API must not fail the authoritative KV purge. } }; if (options.all) { + // Enumerate the actual host-scoped cache keys (registry-independent, so it + // is complete even for a host that was never registered). Split the host + // off the key tail BEFORE denormalizing: `void-isr///`. const prefix = `void-isr/${id}/`; let cursor: string | undefined; do { const list = await kv.list({ prefix, cursor }); for (const key of list.keys) { - await purge(denormalizeIsrPath(key.name.slice(prefix.length))); + const tail = key.name.slice(prefix.length); + const slash = tail.indexOf('/'); + if (slash < 0) { + continue; + } + const host = tail.slice(0, slash); + const path = denormalizeIsrPath(tail.slice(slash + 1)); + await purge(host, path); } cursor = list.list_complete ? undefined : list.cursor; } while (cursor); } else { - for (const path of options.paths ?? []) { - await purge(path); + const paths = options.paths ?? []; + if (paths.length === 0) { + return; + } + // No request host is guaranteed here (cron / queue handlers stamp the id + // WITHOUT a host — the F1 fix), so enumerate every registered host, union + // the request host when present. An empty host set is a silent no-op. + const hosts = new Set(await listIsrHosts(kv, id)); + if (requestHost) { + hosts.add(requestHost); + } + for (const host of hosts) { + for (const path of paths) { + await purge(host, path); + } } } return; diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts index 5ea7220a1..24bcc6ec2 100644 --- a/packages/void/test/unit/isr-cache.test.ts +++ b/packages/void/test/unit/isr-cache.test.ts @@ -4,14 +4,18 @@ import { deleteIsrCache, denormalizeIsrPath, handleIsr, + hostRegistryKey, htmlEdgeKey, isrCacheResponse, isrKey, isStale, jsonEdgeKey, + listIsrHosts, + matchLiteralPattern, normalizeIsrPath, packIsrValue, readIsrCache, + resolvePathTtl, resolveRevalidateTtl, unpackIsrValue, writeIsrCache, @@ -97,7 +101,7 @@ function seedEntry( value?: ArrayBuffer; }, ): void { - store.set(isrKey(DPL, path), { + store.set(isrKey(DPL, HOST, path), { value: value ?? packEntry(headers, body), metadata: { s: status, c: createdAt, t: ttl }, }); @@ -230,10 +234,22 @@ describe('normalizeIsrPath / denormalizeIsrPath', () => { }); describe('collapsed self-host cache keys', () => { - it('scopes the KV key by deployment id (void-isr//...), no projectId/isr-v2', () => { - expect(isrKey(DPL, '/')).toBe(`void-isr/${DPL}/__index__`); - expect(isrKey(DPL, '/blog/x')).toBe(`void-isr/${DPL}/blog/x`); - expect(isrKey(DPL, '/about/')).toBe(`void-isr/${DPL}/about`); + it('scopes the KV key by deployment id AND host (void-isr///...), no projectId/isr-v2', () => { + expect(isrKey(DPL, HOST, '/')).toBe(`void-isr/${DPL}/${HOST}/__index__`); + expect(isrKey(DPL, HOST, '/blog/x')).toBe(`void-isr/${DPL}/${HOST}/blog/x`); + expect(isrKey(DPL, HOST, '/about/')).toBe(`void-isr/${DPL}/${HOST}/about`); + }); + + it('host-scopes the KV key: different hosts never share a cache key', () => { + expect(isrKey(DPL, 'foo.example', '/x')).not.toBe(isrKey(DPL, 'bar.example', '/x')); + expect(isrKey(DPL, 'foo.example', '/x')).toBe(`void-isr/${DPL}/foo.example/x`); + }); + + it('builds the host-registry key (void-isr-hosts//), distinct from the cache prefix', () => { + expect(hostRegistryKey(DPL, HOST)).toBe(`void-isr-hosts/${DPL}/${HOST}`); + // The registry prefix must NOT collide with the cache prefix, or an `{ all }` + // list (prefix `void-isr//`) would enumerate registry keys as cache keys. + expect(hostRegistryKey(DPL, HOST).startsWith(`void-isr/${DPL}/`)).toBe(false); }); it('builds HTML edge keys with the deployment id and a canonical pathname', () => { @@ -413,6 +429,7 @@ describe('writeIsrCache / readIsrCache / deleteIsrCache', () => { const env = makeEnv(kv); await writeIsrCache( env, + HOST, '/blog/post', new Response('fresh', { status: 200, @@ -422,11 +439,11 @@ describe('writeIsrCache / readIsrCache / deleteIsrCache', () => { ); // Real KV contents: exactly one entry under the collapsed key. - expect(store.has(isrKey(DPL, '/blog/post'))).toBe(true); - const meta = store.get(isrKey(DPL, '/blog/post'))!.metadata!; + expect(store.has(isrKey(DPL, HOST, '/blog/post'))).toBe(true); + const meta = store.get(isrKey(DPL, HOST, '/blog/post'))!.metadata!; expect(meta).toEqual({ s: 200, c: BASE, t: 60 }); - const entry = await readIsrCache(env, '/blog/post'); + const entry = await readIsrCache(env, HOST, '/blog/post'); expect(entry).not.toBeNull(); expect(entry!.status).toBe(200); expect(entry!.ttl).toBe(60); @@ -440,21 +457,21 @@ describe('writeIsrCache / readIsrCache / deleteIsrCache', () => { it('returns null on a cache miss', async () => { const { kv } = createKvMock(); - expect(await readIsrCache(makeEnv(kv), '/nope')).toBeNull(); + expect(await readIsrCache(makeEnv(kv), HOST, '/nope')).toBeNull(); }); it('deleteIsrCache removes the entry', async () => { const { store, kv } = createKvMock(); const env = makeEnv(kv); seedEntry(store, '/gone', { createdAt: BASE }); - expect(store.has(isrKey(DPL, '/gone'))).toBe(true); - await deleteIsrCache(env, '/gone'); - expect(store.has(isrKey(DPL, '/gone'))).toBe(false); + expect(store.has(isrKey(DPL, HOST, '/gone'))).toBe(true); + await deleteIsrCache(env, HOST, '/gone'); + expect(store.has(isrKey(DPL, HOST, '/gone'))).toBe(false); }); it('fails open to null when getWithMetadata throws (never surfaces the error)', async () => { const { kv } = createKvMock({ throwOnGet: true }); - await expect(readIsrCache(makeEnv(kv), '/x')).resolves.toBeNull(); + await expect(readIsrCache(makeEnv(kv), HOST, '/x')).resolves.toBeNull(); }); // Corrupt-KV fail-open: the `corruptCases` table is module-scoped (shared with @@ -462,8 +479,8 @@ describe('writeIsrCache / readIsrCache / deleteIsrCache', () => { for (const c of corruptCases) { it(`readIsrCache returns null for a corrupt entry: ${c.name}`, async () => { const { store, kv } = createKvMock(); - store.set(isrKey(DPL, '/p'), c.seed()); - await expect(readIsrCache(makeEnv(kv), '/p')).resolves.toBeNull(); + store.set(isrKey(DPL, HOST, '/p'), c.seed()); + await expect(readIsrCache(makeEnv(kv), HOST, '/p')).resolves.toBeNull(); }); } }); @@ -554,13 +571,15 @@ describe('handleIsr', () => { await flush(); // Real KV contents: the live render was persisted under the collapsed key. - const entry = await readIsrCache(env, '/blog/post'); + const entry = await readIsrCache(env, HOST, '/blog/post'); expect(entry).not.toBeNull(); expect(await new Response(entry!.body).text()).toBe('live'); // HTML edge variant warmed; plain HTML (no page data) never warms the JSON variant. expect(edge.store.has(htmlEdgeKey(HOST, '/blog/post', DPL))).toBe(true); expect(edge.store.has(jsonEdgeKey(HOST, '/blog/post', DPL))).toBe(false); - expect(store.size).toBe(1); + // Exactly one PAGE-cache entry persisted (`void-isr/…`); the host-registry + // bookkeeping key (`void-isr-hosts/…`) is separate and excluded here. + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(1); }); it('KV HIT (fresh) → serves HIT from KV without a live render and warms the edge', async () => { @@ -641,7 +660,7 @@ describe('handleIsr', () => { // The revalidation was KICKED OFF (render invoked) but NOT awaited inline: // KV still holds the ORIGINAL stale entry because the gate hasn't opened. expect(spy).toHaveBeenCalledTimes(1); - const midEntry = await readIsrCache(env, '/blog/post'); + const midEntry = await readIsrCache(env, HOST, '/blog/post'); expect(midEntry!.createdAt).toBe(BASE); expect(isStale(midEntry!)).toBe(true); expect(await new Response(midEntry!.body).text()).toBe('stale'); @@ -668,7 +687,7 @@ describe('handleIsr', () => { // KV re-put with the fresh render (KV is only written by the background task, // so this is race-free) — createdAt advanced, entry is fresh again. - const entry = await readIsrCache(env, '/blog/post'); + const entry = await readIsrCache(env, HOST, '/blog/post'); expect(entry!.createdAt).toBe(BASE + 200_000); expect(isStale(entry!)).toBe(false); expect(await new Response(entry!.body).text()).toBe('revalidated'); @@ -795,7 +814,7 @@ describe('handleIsr', () => { const { ctx } = createCtx(); // readIsrCache degrades to null on the throw... - await expect(readIsrCache(env, '/blog/post')).resolves.toBeNull(); + await expect(readIsrCache(env, HOST, '/blog/post')).resolves.toBeNull(); // ...and handleIsr degrades to the live render rather than throwing. const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); expect(res!.status).toBe(200); @@ -812,11 +831,11 @@ describe('handleIsr', () => { it(`degrades to a live render on a corrupt KV hit (never 500): ${c.name}`, async () => { const { store, kv } = createKvMock(); const env = makeEnv(kv); - store.set(isrKey(DPL, '/blog/post'), c.seed()); + store.set(isrKey(DPL, HOST, '/blog/post'), c.seed()); // Choke point: the corrupt entry is rejected at `readIsrCache` (→ null), // so `isrCacheResponse` never sees an un-constructible entry. - await expect(readIsrCache(env, '/blog/post')).resolves.toBeNull(); + await expect(readIsrCache(env, HOST, '/blog/post')).resolves.toBeNull(); const { render, spy } = createRender( () => new Response('live', { headers: { 'content-type': 'text/html' } }), @@ -832,7 +851,7 @@ describe('handleIsr', () => { await flush(); // The MISS path overwrote the corrupt entry with a valid, readable one. - const entry = await readIsrCache(env, '/blog/post'); + const entry = await readIsrCache(env, HOST, '/blog/post'); expect(entry).not.toBeNull(); expect(await new Response(entry!.body).text()).toBe('live'); }); @@ -861,8 +880,8 @@ describe('handleIsr', () => { await flush(); // KV entry is only ever deleted here (never re-put) — deterministic real removal. - expect(store.has(isrKey(DPL, '/blog/post'))).toBe(false); - expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, '/blog/post')); + expect(store.has(isrKey(DPL, HOST, '/blog/post'))).toBe(false); + expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, HOST, '/blog/post')); // JSON edge variant is only touched by the purge → deterministically removed. expect(edge.store.has(jsonEdgeKey(HOST, '/blog/post', DPL))).toBe(false); expect(edge.delete).toHaveBeenCalledWith(jsonEdgeKey(HOST, '/blog/post', DPL)); @@ -928,10 +947,11 @@ describe('handleIsr', () => { // Background writes (guarded on an undefined edge cache) drain without throwing; // the KV write still lands. await expect(flush()).resolves.toBeUndefined(); - const entry = await readIsrCache(env, '/blog/post'); + const entry = await readIsrCache(env, HOST, '/blog/post'); expect(entry).not.toBeNull(); expect(await new Response(entry!.body).text()).toBe('live'); - expect(store.size).toBe(1); + // Exactly one PAGE-cache entry (`void-isr/…`); the registry key is separate. + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(1); }); it('serves the L2 KV HIT with no per-colo edge cache (KV ladder independent of caches.default)', async () => { @@ -958,3 +978,127 @@ describe('handleIsr', () => { await expect(flush()).resolves.toBeUndefined(); }); }); + +// =========================================================================== +// matchLiteralPattern — segment-anchored matching (FIX 3) +// =========================================================================== + +describe('matchLiteralPattern — segment-anchored matching', () => { + it('anchors a WHOLE-segment `:name` to exactly ONE path segment', () => { + expect(matchLiteralPattern('/blog/:slug', '/blog/hello')).toBe(true); + // Deeper path — `:slug` must NOT cross a `/`. + expect(matchLiteralPattern('/blog/:slug', '/blog/a/b')).toBe(false); + // Empty segment — `[^/]+` requires at least one char. + expect(matchLiteralPattern('/blog/:slug', '/blog/')).toBe(false); + expect(matchLiteralPattern('/blog/:slug', '/blog')).toBe(false); + }); + + it('lets `*` cross `/` (catch-all splat)', () => { + expect(matchLiteralPattern('/blog/*', '/blog/a/b')).toBe(true); + expect(matchLiteralPattern('/blog/*', '/blog/hello')).toBe(true); + }); + + it('matches a root catch-all `/*` against any path (including root)', () => { + expect(matchLiteralPattern('/*', '/')).toBe(true); + expect(matchLiteralPattern('/*', '/x/y')).toBe(true); + }); + + it('anchors EACH param in a multi-segment `:cat/:id` pattern to one segment', () => { + expect(matchLiteralPattern('/shop/:cat/:id', '/shop/shoes/42')).toBe(true); + expect(matchLiteralPattern('/shop/:cat/:id', '/shop/shoes')).toBe(false); + expect(matchLiteralPattern('/shop/:cat/:id', '/shop/shoes/42/extra')).toBe(false); + }); + + it('preserves a MID-segment literal `:` — only whole-segment `:name` is a wildcard', () => { + // A user literal key `/api/v1:health` must match ONLY itself: the `:health` + // is mid-segment (preceded by `1`, not `/`), so it stays a literal. + expect(matchLiteralPattern('/api/v1:health', '/api/v1:health')).toBe(true); + expect(matchLiteralPattern('/api/v1:health', '/api/v1:other')).toBe(false); + expect(matchLiteralPattern('/api/v1:health', '/api/v1/health')).toBe(false); + }); + + it('is a strict SUPERSET of the old matcher — old `*`-only manifests match identically', () => { + // The OLD emitter produced `/blog/*` for `[slug]`; that manifest must keep + // matching the same paths under the new matcher (rollout safety). + expect(matchLiteralPattern('/blog/*', '/blog/hello')).toBe(true); + expect(matchLiteralPattern('/blog/*', '/blog/a/b')).toBe(true); + }); +}); + +describe('resolvePathTtl — single-segment anchoring', () => { + it('resolves the single-segment TTL for a one-segment path but NOT a deeper one', () => { + // The FIX-3 emitter keeps `/blog/:slug` as-is; the matcher anchors it to one + // segment, so `/blog/hello` gets the TTL while `/blog/a/b` does not. + const revalidate = { '/blog/:slug': 120 }; + expect(resolvePathTtl(revalidate, '/blog/hello')).toBe(120); + expect(resolvePathTtl(revalidate, '/blog/a/b')).toBe(0); + }); +}); + +// =========================================================================== +// Host-scoped KV cache + host registry (FIX 4) +// =========================================================================== + +describe('host-scoped KV cache + host registry', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not bleed across hosts: write under one host, read under another → null', async () => { + const { kv } = createKvMock(); + const env = makeEnv(kv); + await writeIsrCache( + env, + 'foo.example', + '/x', + new Response('foo', { headers: { 'content-type': 'text/html' } }), + 60, + ); + // Same path, different host → miss (keys are host-scoped). + expect(await readIsrCache(env, 'bar.example', '/x')).toBeNull(); + // Same host → hit. + expect(await readIsrCache(env, 'foo.example', '/x')).not.toBeNull(); + }); + + it('registers the host in the registry on write (void-isr-hosts//)', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + // Unique host so the module-level in-isolate registration guard (shared across + // this file's tests) does not skip the registry write here. + const host = 'registry-write.example'; + await writeIsrCache( + env, + host, + '/p', + new Response('x', { headers: { 'content-type': 'text/html' } }), + 60, + ); + expect(store.has(hostRegistryKey(DPL, host))).toBe(true); + }); + + it('listIsrHosts returns the registered hosts for a deployment, scoped by id', async () => { + const { store, kv } = createKvMock(); + store.set(hostRegistryKey(DPL, 'a.example'), { value: new ArrayBuffer(0), metadata: null }); + store.set(hostRegistryKey(DPL, 'b.example'), { value: new ArrayBuffer(0), metadata: null }); + // A different deployment's registration must NOT leak into this list. + store.set(hostRegistryKey('other_dpl', 'c.example'), { + value: new ArrayBuffer(0), + metadata: null, + }); + const hosts = await listIsrHosts(kv, DPL); + expect([...hosts].sort()).toEqual(['a.example', 'b.example']); + }); + + it('listIsrHosts fails open to [] on a broken registry (never throws)', async () => { + const brokenKv = { + list: vi.fn(async () => { + throw new Error('KV unavailable'); + }), + } as unknown as KVNamespace; + await expect(listIsrHosts(brokenKv, DPL)).resolves.toEqual([]); + }); +}); diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts index 27a938cdd..df2474f47 100644 --- a/packages/void/test/unit/isr-config.test.ts +++ b/packages/void/test/unit/isr-config.test.ts @@ -64,15 +64,15 @@ describe('collectRevalidate', () => { it('uses per-page rules alone when config is absent', () => { const scan = makeScan([makePage('/blog/:slug', 120), makePage('/about')]); - expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/*': 120 }); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/:slug': 120 }); }); it('turns a numeric config into a path map with the number as the "*" fallback (per-page first)', () => { const scan = makeScan([makePage('/blog/:slug', 120)]); const result = collectRevalidate(configWith(60), scan); - expect(result).toEqual({ '/blog/*': 120, '*': 60 }); + expect(result).toEqual({ '/blog/:slug': 120, '*': 60 }); // Per-page entry must precede the fallback (first-match-wins ordering). - expect(Object.keys(result as Record)).toEqual(['/blog/*', '*']); + expect(Object.keys(result as Record)).toEqual(['/blog/:slug', '*']); }); it('merges per-page rules ahead of an object config (per-page first in match order)', () => { @@ -81,7 +81,7 @@ describe('collectRevalidate', () => { // Per-page entries are emitted before the config entries, so first-match // resolution consults the per-page pattern ahead of the config's catch-alls. expect(Object.entries(result as Record)).toEqual([ - ['/blog/*', 120], + ['/blog/:slug', 120], ['/docs/*', 30], ['*', 10], ]); @@ -93,12 +93,13 @@ describe('collectRevalidate', () => { expect(collectRevalidate(configWith(60), scan)).toBe(60); }); - // FIX 4: generated per-page keys convert route params (`:slug`, `:slug{.+}`) - // to `*` so the literal TTL matcher (`matchLiteralPattern`) actually matches. - describe('route-param → wildcard conversion (generated page keys)', () => { - it('converts a single dynamic param `[slug]` (`/blog/:slug`) to `/blog/*`', () => { + // FIX 3: single-segment params (`:slug`) stay as `:slug` so the matcher can + // anchor them to ONE path segment (`[^/]+`); only named catch-alls (`:slug{.+}`) + // and anonymous catch-alls (`*`) collapse to `*` (a splat that crosses `/`). + describe('route-param → key conversion (generated page keys)', () => { + it('keeps a single dynamic param `[slug]` (`/blog/:slug`) as `/blog/:slug`', () => { const scan = makeScan([makePage('/blog/:slug', 120)]); - expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/*': 120 }); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/:slug': 120 }); }); it('converts a catch-all param `[...slug]` (`/blog/:slug{.+}`) to `/blog/*`', () => { @@ -106,9 +107,14 @@ describe('collectRevalidate', () => { expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/blog/*': 90 }); }); - it('converts every param in a multi-param pattern (`/shop/:cat/:id` → `/shop/*/*`)', () => { + it('converts a root catch-all (`/:all{.+}`) to `/*`', () => { + const scan = makeScan([makePage('/:all{.+}', 15)]); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/*': 15 }); + }); + + it('keeps every single-segment param in a multi-param pattern (`/shop/:cat/:id`)', () => { const scan = makeScan([makePage('/shop/:cat/:id', 45)]); - expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/shop/*/*': 45 }); + expect(collectRevalidate(configWith(undefined), scan)).toEqual({ '/shop/:cat/:id': 45 }); }); it('leaves static page patterns untouched', () => { @@ -122,19 +128,23 @@ describe('collectRevalidate', () => { const scan = makeScan([makePage('/blog/:slug', 120)]); const result = collectRevalidate(configWith({ '/legacy/:kept': 30 }), scan); expect(Object.entries(result as Record)).toEqual([ - ['/blog/*', 120], + ['/blog/:slug', 120], ['/legacy/:kept', 30], ]); }); }); - // FIX 4 end-to-end: the converted key must actually match a concrete path - // through the same resolver the runtime uses (mirrors isr-cache resolvePathTtl). - it('produces keys that resolvePathTtl matches for a concrete dynamic path', () => { + // FIX 3 end-to-end: the emitted `:slug` key must anchor to ONE segment through + // the same resolver the runtime uses (mirrors isr-cache resolvePathTtl). + it('produces keys that resolvePathTtl anchors to a single dynamic path segment', () => { const scan = makeScan([makePage('/blog/:slug', 120)]); const revalidate = collectRevalidate(configWith(undefined), scan); + // Single dynamic segment matches. expect(resolvePathTtl(revalidate, '/blog/hello')).toBe(120); - // A non-matching path still resolves to 0 (no ISR). + // A DEEPER path must NOT match the single-segment `:slug` key (would have + // matched the old `/blog/*` wildcard) — proves the segment anchoring. + expect(resolvePathTtl(revalidate, '/blog/a/b')).toBe(0); + // An unrelated path still resolves to 0 (no ISR). expect(resolvePathTtl(revalidate, '/about')).toBe(0); }); }); diff --git a/packages/void/test/unit/isr.test.ts b/packages/void/test/unit/isr.test.ts index e88b35b1e..c20aadd2b 100644 --- a/packages/void/test/unit/isr.test.ts +++ b/packages/void/test/unit/isr.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { envContext } from '../../src/runtime/env-raw.ts'; -import { htmlEdgeKey, isrKey, jsonEdgeKey } from '../../src/runtime/isr-cache.ts'; +import { hostRegistryKey, htmlEdgeKey, isrKey, jsonEdgeKey } from '../../src/runtime/isr-cache.ts'; import { revalidate } from '../../src/runtime/isr.ts'; const DPL = 'dpl_1'; @@ -119,11 +119,11 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { vi.unstubAllGlobals(); }); - it('deletes EXACTLY the requested paths from KV and both edge variants — no over-purge of unrelated entries', async () => { + it('{ paths } (request host) deletes EXACTLY the requested (host, path) entries from KV and both edge variants — no over-purge', async () => { const { store, kv, delete: kvDelete } = createKvMock(); - store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); - store.set(isrKey(DPL, '/blog/x'), new ArrayBuffer(4)); - store.set(isrKey(DPL, '/keepme'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/blog/x'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/keepme'), new ArrayBuffer(4)); // Target edge variants (both should be purged). edge.store.set(htmlEdgeKey(HOST, '/', DPL), new Response('x')); edge.store.set(jsonEdgeKey(HOST, '/', DPL), new Response('x')); @@ -142,10 +142,10 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { }, ); - // Exactly the requested KV keys are gone; the untouched one survives. - expect(store.has(isrKey(DPL, '/'))).toBe(false); - expect(store.has(isrKey(DPL, '/blog/x'))).toBe(false); - expect(store.has(isrKey(DPL, '/keepme'))).toBe(true); + // Exactly the requested (host, path) KV keys are gone; the untouched one survives. + expect(store.has(isrKey(DPL, HOST, '/'))).toBe(false); + expect(store.has(isrKey(DPL, HOST, '/blog/x'))).toBe(false); + expect(store.has(isrKey(DPL, HOST, '/keepme'))).toBe(true); // Both edge variants purged for each requested path; unrelated ones survive. expect(edge.store.has(htmlEdgeKey(HOST, '/', DPL))).toBe(false); @@ -155,12 +155,12 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { expect(edge.store.has(htmlEdgeKey(HOST, '/keepme', DPL))).toBe(true); expect(edge.store.has(jsonEdgeKey(HOST, '/keepme', DPL))).toBe(true); - // EXACT KV delete calls: precisely the two requested keys (correct id), no + // EXACT KV delete calls: precisely the two requested (host, path) keys, no // extras. An over-purge (e.g. also deleting `/keepme`) would fail here. expect(kvDelete).toHaveBeenCalledTimes(2); expect(kvDelete.mock.calls.map((c) => c[0])).toEqual([ - isrKey(DPL, '/'), - isrKey(DPL, '/blog/x'), + isrKey(DPL, HOST, '/'), + isrKey(DPL, HOST, '/blog/x'), ]); // EXACT edge delete calls: html + json for each requested path (correct @@ -174,58 +174,92 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { ]); }); - it('skips edge purges when no host is configured (KV only)', async () => { - const { store, kv } = createKvMock(); - store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); + // Cron/queue handlers stamp the deployment id WITHOUT a request host (the F1 + // fix), so a host-less `revalidate({ paths })` must enumerate EVERY registered + // host from the registry and purge each host's (host, path) entry. + it('{ paths } with NO request host purges every REGISTERED host (cron/queue purge via registry)', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + const HOST_A = 'a.example'; + const HOST_B = 'b.example'; + // Two hosts have cached the same path; both are registered. + store.set(isrKey(DPL, HOST_A, '/p'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST_B, '/p'), new ArrayBuffer(4)); + store.set(hostRegistryKey(DPL, HOST_A), new ArrayBuffer(0)); + store.set(hostRegistryKey(DPL, HOST_B), new ArrayBuffer(0)); + edge.store.set(htmlEdgeKey(HOST_A, '/p', DPL), new Response('x')); + edge.store.set(htmlEdgeKey(HOST_B, '/p', DPL), new Response('x')); + + // NO __VOID_ISR_HOST — the cron/queue path relies entirely on the registry. + await envContext.run({ ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }, async () => { + await revalidate({ paths: ['/p'] }); + }); + + // Both registered hosts' KV + edge entries purged. + expect(store.has(isrKey(DPL, HOST_A, '/p'))).toBe(false); + expect(store.has(isrKey(DPL, HOST_B, '/p'))).toBe(false); + expect(edge.store.has(htmlEdgeKey(HOST_A, '/p', DPL))).toBe(false); + expect(edge.store.has(htmlEdgeKey(HOST_B, '/p', DPL))).toBe(false); + // One KV delete per registered host (order-independent). + expect(kvDelete.mock.calls.map((c) => c[0]).sort()).toEqual( + [isrKey(DPL, HOST_A, '/p'), isrKey(DPL, HOST_B, '/p')].sort(), + ); + }); + + it('{ paths } with no request host and an EMPTY registry is a silent no-op (nothing purged)', async () => { + const { store, kv, delete: kvDelete } = createKvMock(); + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); await envContext.run({ ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }, async () => { await revalidate({ paths: ['/'] }); }); - expect(store.has(isrKey(DPL, '/'))).toBe(false); + // No registered host and no request host → no host to purge → key survives. + expect(store.has(isrKey(DPL, HOST, '/'))).toBe(true); + expect(kvDelete).not.toHaveBeenCalled(); expect(edge.delete).not.toHaveBeenCalled(); }); - // FIX B: cron/queue handlers stamp the deployment id WITHOUT a host (the F1 - // fix), so `revalidate()` must purge KV without ever dereferencing - // `caches.default`. `caches.default` is acquired lazily inside the `if (host)` - // branch — with no host, a degraded Cache API is never touched. - it('purges KV without dereferencing caches.default when no host is set, even if the Cache API is degraded', async () => { - // Degraded Cache API: any access to `caches.default` throws AND is observable. - let defaultAccessed = false; + // The authoritative KV purge must never depend on the Cache API: the edge purge + // is guarded, so a degraded `caches.default` (scheduled/queue handlers, or no + // Cache API) fails open without blocking the KV delete. + it('purges KV via the registry even when the Cache API is degraded (edge fails open, KV still purged)', async () => { + // Degraded Cache API: reading `caches.default` throws. vi.stubGlobal('caches', { get default(): Cache { - defaultAccessed = true; throw new Error('Cache API unavailable'); }, }); const { store, kv, delete: kvDelete } = createKvMock(); - store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); + store.set(hostRegistryKey(DPL, HOST), new ArrayBuffer(0)); - // No __VOID_ISR_HOST → KV-only purge; the degraded caches.default must not throw. + // No __VOID_ISR_HOST → host comes from the registry; the degraded edge must + // not throw and must not block the KV purge. await expect( envContext.run({ ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL }, async () => { await revalidate({ paths: ['/'] }); }), ).resolves.toBeUndefined(); - // KV delete happened; caches.default was never touched. - expect(store.has(isrKey(DPL, '/'))).toBe(false); - expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, '/')); - expect(defaultAccessed).toBe(false); + // KV delete completed despite the swallowed edge throw. + expect(store.has(isrKey(DPL, HOST, '/'))).toBe(false); + expect(kvDelete).toHaveBeenCalledWith(isrKey(DPL, HOST, '/')); }); - it('{ all: true } lists and deletes the entire void-isr// prefix (cursor-paginated)', async () => { + it('{ all: true } lists the void-isr// prefix, SPLITS the host off each key, and purges every (host, path) — cursor-paginated', async () => { // pageSize 2 forces the cursor loop to iterate across multiple pages. const { store, kv, list } = createKvMock(2); - store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); - store.set(isrKey(DPL, '/a'), new ArrayBuffer(4)); - store.set(isrKey(DPL, '/b/c'), new ArrayBuffer(4)); - store.set(isrKey(DPL, '/d'), new ArrayBuffer(4)); - store.set(isrKey(DPL, '/e'), new ArrayBuffer(4)); + const HOST2 = 'other.example'; + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/a'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/b/c'), new ArrayBuffer(4)); + // A SECOND host under the same deployment — its key must be purged too, and + // its host must be split back out of the scanned key correctly. + store.set(isrKey(DPL, HOST2, '/d'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/e'), new ArrayBuffer(4)); // A different deployment's entry must never be touched. - store.set(isrKey('other_dpl', '/x'), new ArrayBuffer(4)); + store.set(isrKey('other_dpl', HOST, '/x'), new ArrayBuffer(4)); await envContext.run( { ISR_CACHE: kv, __VOID_ISR_DEPLOYMENT_ID: DPL, __VOID_ISR_HOST: HOST }, @@ -234,11 +268,17 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { }, ); - // The entire prefix for THIS deployment is gone; the other survives. - for (const p of ['/', '/a', '/b/c', '/d', '/e']) { - expect(store.has(isrKey(DPL, p))).toBe(false); + // Every (host, path) under THIS deployment is gone; the other deployment survives. + for (const [h, p] of [ + [HOST, '/'], + [HOST, '/a'], + [HOST, '/b/c'], + [HOST2, '/d'], + [HOST, '/e'], + ] as const) { + expect(store.has(isrKey(DPL, h, p))).toBe(false); } - expect(store.has(isrKey('other_dpl', '/x'))).toBe(true); + expect(store.has(isrKey('other_dpl', HOST, '/x'))).toBe(true); // Cursor loop ran more than once (5 keys / pageSize 2 → 3 list calls). expect(list.mock.calls.length).toBeGreaterThan(1); // EVERY list call is scoped to THIS deployment's prefix. An unscoped @@ -250,9 +290,11 @@ describe('revalidate — self-host (own ISR_CACHE + edge)', () => { } // The first page starts with no cursor. expect((list.mock.calls[0][0] as { cursor?: string }).cursor).toBeUndefined(); - // Edge purge issued for a representative path (round-tripped through denormalizeIsrPath). + // Edge purge issued with the host SPLIT from the scanned key (path round-tripped + // through denormalizeIsrPath), including the second host. expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/', DPL)); expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST, '/b/c', DPL)); + expect(edge.delete).toHaveBeenCalledWith(htmlEdgeKey(HOST2, '/d', DPL)); }); }); @@ -275,12 +317,12 @@ describe('revalidate — unconfigured (silent no-op)', () => { it('no-ops when ISR_CACHE is bound but the deployment id is missing', async () => { const { store, kv, delete: kvDelete } = createKvMock(); - store.set(isrKey(DPL, '/'), new ArrayBuffer(4)); + store.set(isrKey(DPL, HOST, '/'), new ArrayBuffer(4)); await envContext.run({ ISR_CACHE: kv }, async () => { await revalidate({ paths: ['/'] }); }); // Early return before any delete — the key survives. - expect(store.has(isrKey(DPL, '/'))).toBe(true); + expect(store.has(isrKey(DPL, HOST, '/'))).toBe(true); expect(kvDelete).not.toHaveBeenCalled(); }); }); diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 37f380c47..6db1caf1f 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -116,12 +116,15 @@ describe('buildVoidManifest — self-host non-regression', () => { const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); // Merge must be non-trivial (numeric config → path map with "*" fallback). - // The dynamic page pattern `/blog/:slug` is converted to a literal-matcher - // wildcard (`/blog/*`) by collectRevalidate so it actually matches at runtime. - expect(revalidate).toEqual({ '/blog/*': 120, '*': 60 }); + // The single-segment page pattern `/blog/:slug` stays as `/blog/:slug` so the + // matcher anchors it to ONE path segment (`[^/]+`) at runtime. + expect(revalidate).toEqual({ '/blog/:slug': 120, '*': 60 }); // …and it must land in the manifest byte-for-byte. expect(manifest.revalidate).toEqual(revalidate); - expect(Object.keys(manifest.revalidate as Record)).toEqual(['/blog/*', '*']); + expect(Object.keys(manifest.revalidate as Record)).toEqual([ + '/blog/:slug', + '*', + ]); }); it('carries collectRevalidate output verbatim into manifest.revalidate (object config + per-page order)', () => { @@ -133,10 +136,10 @@ describe('buildVoidManifest — self-host non-regression', () => { expect(manifest.revalidate).toEqual(revalidate); // Per-page entries precede config entries (first-match-wins ordering preserved). - // The per-page `/blog/:slug` pattern is converted to `/blog/*`; the user's own - // `routing.revalidate` literal keys (`/docs/*`, `*`) pass through untouched. + // The per-page `/blog/:slug` pattern stays `/blog/:slug` (single-segment); the + // user's own `routing.revalidate` literal keys (`/docs/*`, `*`) pass untouched. expect(Object.entries(manifest.revalidate as Record)).toEqual([ - ['/blog/*', 120], + ['/blog/:slug', 120], ['/docs/*', 30], ['*', 10], ]); From 5602b3f60c23d1e0e339c80fdb0ad0eb3c2eab8c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 9 Jul 2026 16:29:02 +0800 Subject: [PATCH 27/31] fix(isr): preserve Set-Cookie on live ISR miss (keep cache copy stripped) On a cacheable anonymous GET that MISSes ISR, the client response headers were cloned from the already-set-cookie-stripped cache copy, so the first render's Set-Cookie (session/CSRF/prefs) never reached the browser. Build the client headers from the stripped base but re-append each Set-Cookie from the original render response via getSetCookie() (preserves multiple cookies). The KV (serializeHeaders/SKIP_HEADERS) and edge cache copies stay stripped. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/runtime/isr-cache.ts | 7 ++++ packages/void/test/unit/isr-cache.test.ts | 44 +++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts index ccb2065de..9013a1418 100644 --- a/packages/void/src/runtime/isr-cache.ts +++ b/packages/void/src/runtime/isr-cache.ts @@ -908,6 +908,13 @@ export async function handleIsr( const clientHeaders = new Headers(cacheHeaders); clientHeaders.set('Cache-Control', CLIENT_REVALIDATE_CACHE_CONTROL); + // The shared cache/KV/edge copy above is intentionally stripped of Set-Cookie, + // but the CLIENT must still receive the first-render cookies (session/CSRF/ + // prefs). Re-append each Set-Cookie from the ORIGINAL render response — folding + // via `getSetCookie()` preserves multiple distinct cookies. + for (const cookie of response.headers.getSetCookie()) { + clientHeaders.append('set-cookie', cookie); + } return new Response(response.body, { status: response.status, headers: clientHeaders, diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts index 24bcc6ec2..888b7fbdb 100644 --- a/packages/void/test/unit/isr-cache.test.ts +++ b/packages/void/test/unit/isr-cache.test.ts @@ -582,6 +582,50 @@ describe('handleIsr', () => { expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(1); }); + it('MISS whose render sets Set-Cookie → client keeps EVERY cookie; KV + edge copies stay stripped', async () => { + const { store, kv } = createKvMock(); + const env = makeEnv(kv); + // The live render emits TWO DISTINCT Set-Cookie headers (session + CSRF). + // A `Headers` folds duplicates, so an array init is the only way to seed both. + const { render, spy } = createRender( + () => + new Response('live', { + headers: [ + ['content-type', 'text/html'], + ['set-cookie', 'sid=secret; Path=/; HttpOnly'], + ['set-cookie', 'csrf=tok; Path=/'], + ], + }), + ); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res).not.toBeNull(); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(spy).toHaveBeenCalledTimes(1); + + // (1) The CLIENT response carries BOTH Set-Cookie headers, verbatim. Without + // the fix the client headers are cloned from the already-stripped cache copy, + // so `getSetCookie()` would be empty here and this assertion would fail. + expect(res!.headers.getSetCookie()).toEqual([ + 'sid=secret; Path=/; HttpOnly', + 'csrf=tok; Path=/', + ]); + + await flush(); + // (2) The shared KV copy is stripped — a shared cache must never store cookies. + const entry = await readIsrCache(env, HOST, '/blog/post'); + expect(entry).not.toBeNull(); + expect(entry!.headers['set-cookie']).toBeUndefined(); + expect(await new Response(entry!.body).text()).toBe('live'); + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(1); + // (3) The edge (caches.default) copy is stripped too. + const warmed = edge.store.get(htmlEdgeKey(HOST, '/blog/post', DPL)); + expect(warmed).toBeDefined(); + expect(warmed!.headers.getSetCookie()).toEqual([]); + expect(warmed!.headers.has('set-cookie')).toBe(false); + }); + it('KV HIT (fresh) → serves HIT from KV without a live render and warms the edge', async () => { const { kv, store } = createKvMock(); const env = makeEnv(kv); From 27c0fd2acb65f094b44d22902fccb4e5fe9bae8c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 9 Jul 2026 16:40:16 +0800 Subject: [PATCH 28/31] fix(ai): add parallel provider auth default (x-api-key) `parallel` is a first-class provider in PROVIDER_KEY_MAP but was missing from PROVIDER_AUTH_DEFAULTS, so the self-host direct-gateway path fell back to `Authorization: Bearer `. Cloudflare's Parallel AI Gateway authenticates with an `x-api-key` header (no prefix), so Bearer 401s at the pass-through. Add the entry (matching the anthropic shape) and assert `ai.provider('parallel').fetch()` sends `x-api-key`, not Authorization. Completes the earlier provider-auth fix that skipped `parallel` as unconfirmed. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/runtime/ai.ts | 1 + packages/void/test/unit/ai.test.ts | 33 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/void/src/runtime/ai.ts b/packages/void/src/runtime/ai.ts index 037e25ae0..e8cfa2c75 100644 --- a/packages/void/src/runtime/ai.ts +++ b/packages/void/src/runtime/ai.ts @@ -26,6 +26,7 @@ const PROVIDER_AUTH_DEFAULTS: Record cartesia: { header: 'x-api-key', prefix: '' }, baseten: { header: 'authorization', prefix: 'Api-Key ' }, ideogram: { header: 'Api-Key', prefix: '' }, + parallel: { header: 'x-api-key', prefix: '' }, }; type SerializedFileInput = { diff --git a/packages/void/test/unit/ai.test.ts b/packages/void/test/unit/ai.test.ts index 4a6ec0956..258b8e85b 100644 --- a/packages/void/test/unit/ai.test.ts +++ b/packages/void/test/unit/ai.test.ts @@ -462,6 +462,39 @@ describe('runtime ai', () => { expect(headers.get('authorization')).toBeNull(); }); + it('sends the provider-native auth header for parallel (x-api-key, no Bearer)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ id: 'task_run_direct' }), { + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const direct = makeDirectAi(); + + await envContext.run( + { AI: direct.AI, __VOID_AI_GATEWAY: 'my-gw', PARALLEL_API_KEY: 'pk-test' }, + async () => { + await ai.provider('parallel').fetch('/v1/tasks/runs', { + body: { input: 'What is the capital of France?' }, + }); + }, + ); + + expect(direct.gateway.mock.results[0].value.getUrl).toHaveBeenCalledWith('parallel'); + const init = fetchMock.mock.calls[0][1]!; + const headers = new Headers(init.headers); + // Parallel's AI Gateway authenticates with `x-api-key` and NO prefix — + // sending `Authorization: Bearer ` 401s at the gateway pass-through. + expect(headers.get('x-api-key')).toBe('pk-test'); + expect(headers.get('authorization')).toBeNull(); + }); + it('sends a prefixed non-Bearer auth header (deepgram → Authorization: Token)', async () => { vi.resetModules(); const { envContext } = await import('../../src/runtime/env-raw.ts'); From 5001ca9d1837cc9dd23026f3bdcb3c4c05414019 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 9 Jul 2026 16:40:28 +0800 Subject: [PATCH 29/31] fix(env): gate void/env ISR_CACHE hiding on ISR being active (match c.env) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated c.env filter (router/compile.ts) hides the internal ISR_CACHE binding by exact name ONLY when ISR is emitted (the build-time `isr` gate), so a non-ISR app's own binding named ISR_CACHE stays visible. But the runtime void/env side hid ISR_CACHE unconditionally, so in a non-ISR app that declares its own ISR_CACHE KV, `c.env.ISR_CACHE` was visible while `env.ISR_CACHE` / `getRuntimeBinding('ISR_CACHE')` returned undefined — an inconsistency. Gate the exact-name (ISR_CACHE) internal check on ISR being active at runtime: `isIsrActive(rawEnv)` is true iff `__VOID_ISR_DEPLOYMENT_ID` is present on the raw env (the compile-time deployment id the worker entry stamps only on ISR-emitting builds — the same condition that fires the c.env gate). All void/env surfaces now consult the gated predicate consistently: the `env` proxy get/has/ownKeys, getRuntimeEnv, getRuntimeBinding, requireRuntimeBinding, and `_validateEnvOnce` cache seeding. Prefix internals (`__VOID_`, `__PROJECT_`) stay hidden unconditionally; c.env (compile.ts) is unchanged. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/runtime/env-public.ts | 35 ++++--- packages/void/src/runtime/env-raw.ts | 18 ++++ packages/void/src/runtime/env.ts | 35 ++++--- packages/void/test/unit/env-schema.test.ts | 56 ++++++++--- packages/void/test/unit/env.test.ts | 111 +++++++++++++++------ 5 files changed, 181 insertions(+), 74 deletions(-) diff --git a/packages/void/src/runtime/env-public.ts b/packages/void/src/runtime/env-public.ts index 840e89ce7..44145c323 100644 --- a/packages/void/src/runtime/env-public.ts +++ b/packages/void/src/runtime/env-public.ts @@ -31,7 +31,7 @@ import { isUnmaskActive, redactMessage, type SecretOverride } from '../shared/env-mask.ts'; import type { StandardSchemaV1 } from '../standard-schema.ts'; import { getSecretOverride } from './env-helpers.ts'; -import { getRawRuntimeEnv } from './env-raw.ts'; +import { getRawRuntimeEnv, type RuntimeEnv } from './env-raw.ts'; import { getRuntimeEnv, isInternalBinding } from './env.ts'; type AnyValidator = StandardSchemaV1; @@ -129,13 +129,15 @@ export async function _validateEnvOnce(envValues: unknown): Promise { cacheList.push(cache); } for (const [key, value] of report.values) { - // Never seed internal framework bindings (__VOID_*, __PROJECT_*, - // ISR_CACHE) into the sync cache — even when a user's schema declares - // one and boot validation resolved its raw value. Exposing it via - // `env.X` would leak an internal binding (e.g. the ISR KV namespace). - // `readKey` also short-circuits internal names, so this is belt-and- - // suspenders that additionally keeps the cache clean. - if (isInternalBinding(key)) { + // Never seed internal framework bindings (__VOID_*, __PROJECT_*, and — + // while ISR is active — ISR_CACHE) into the sync cache, even when a + // user's schema declares one and boot validation resolved its raw + // value. Exposing it via `env.X` would leak an internal binding (e.g. + // the ISR KV namespace). `readKey` also short-circuits internal names, + // so this is belt-and-suspenders that additionally keeps the cache + // clean. `cacheKey` is the raw runtime env — the same object the + // exact-name (ISR_CACHE) gate consults for the ISR-active check. + if (isInternalBinding(key, cacheKey as RuntimeEnv)) { continue; } if (!cache.has(key)) { @@ -240,12 +242,15 @@ export class EnvValidationError extends Error { } function readKey(key: string): unknown { - // Internal framework bindings (__VOID_*, __PROJECT_*, ISR_CACHE) are never - // exposed through the public `env` proxy — treat them as absent BEFORE - // consulting the schema/cache. This holds even when a user's defineEnv schema - // declares one and boot validation seeded its raw value; returning `undefined` - // matches the "not in schema / not found" behavior of the filtered env. - if (isInternalBinding(key)) { + // Internal framework bindings (__VOID_*, __PROJECT_*, and — while ISR is + // active — ISR_CACHE) are never exposed through the public `env` proxy — + // treat them as absent BEFORE consulting the schema/cache. This holds even + // when a user's defineEnv schema declares one and boot validation seeded its + // raw value; returning `undefined` matches the "not in schema / not found" + // behavior of the filtered env. The exact-name (ISR_CACHE) gate needs the raw + // env, so resolve it up front and reuse it as the cache key below. + const rawEnv = getRawRuntimeEnv(); + if (isInternalBinding(key, rawEnv)) { return undefined; } // Filtered env hides internal __VOID_*/__PROJECT_* bindings from user code. @@ -260,7 +265,7 @@ function readKey(key: string): unknown { } // Cache identity must be the underlying env object. `getRuntimeEnv()` // returns a fresh Proxy on every call, so keying by it would miss. - const cacheKey = getRawRuntimeEnv() as object; + const cacheKey = rawEnv as object; let cache = validatedCache.get(cacheKey); if (!cache) { cache = new Map(); diff --git a/packages/void/src/runtime/env-raw.ts b/packages/void/src/runtime/env-raw.ts index ae981e0d4..92736d405 100644 --- a/packages/void/src/runtime/env-raw.ts +++ b/packages/void/src/runtime/env-raw.ts @@ -19,9 +19,27 @@ export const INTERNAL_BINDING_PREFIXES = ['__VOID_', '__PROJECT_'] as const; * cache through `handleIsr`/`revalidate()` on the raw env, never via `c.env` or * the public `void/env` API. Single source of truth shared by the generated * `c.env` filter (`router/compile.ts`) and the runtime `env` filter (`env.ts`). + * + * Unlike the prefix internals, these are hidden ONLY when ISR is active for the + * isolate (see {@link isIsrActive}). In a non-ISR app, `ISR_CACHE` is an + * ordinary user binding and stays visible everywhere — matching the build-time + * `isr` gate on the generated `c.env` filter. */ export const INTERNAL_BINDING_EXACT_NAMES = ['ISR_CACHE'] as const; +/** + * ISR is active for this isolate iff the framework has stamped the compile-time + * deployment id onto the raw env (`__VOID_ISR_DEPLOYMENT_ID`). The generated + * worker entry stamps it at the top of `fetch()` (and in cron/queue handlers) + * only for ISR-emitting builds, so its presence is a reliable runtime signal + * that `ISR_CACHE` should be treated as a framework internal. The id itself is a + * `__VOID_`-prefixed binding that is always hidden and framework-controlled, so + * a user cannot forge it. + */ +export function isIsrActive(rawEnv: RuntimeEnv): boolean { + return rawEnv.__VOID_ISR_DEPLOYMENT_ID != null; +} + export const envContext: AsyncLocalStorage = new AsyncLocalStorage(); let cloudflareEnv: RuntimeEnv | undefined; diff --git a/packages/void/src/runtime/env.ts b/packages/void/src/runtime/env.ts index dd915c3fa..e76b56e56 100644 --- a/packages/void/src/runtime/env.ts +++ b/packages/void/src/runtime/env.ts @@ -11,6 +11,7 @@ import { getRawRuntimeEnv, INTERNAL_BINDING_EXACT_NAMES, INTERNAL_BINDING_PREFIXES, + isIsrActive, type RuntimeEnv, } from './env-raw.ts'; @@ -27,38 +28,47 @@ export function withRuntimeEnv(env: unknown, fn: () => T): T { /** * Shared internal-binding predicate. A binding is framework-internal — and must * never be exposed to user code — when it carries an internal prefix - * (`__VOID_`, `__PROJECT_`) or is an exact-name internal (e.g. `ISR_CACHE`). - * Single source of truth for the generated `c.env` filter, the runtime `env` - * filter here, and the public `void/env` proxy (`env-public.ts`). + * (`__VOID_`, `__PROJECT_`; always hidden) or is an exact-name internal (e.g. + * `ISR_CACHE`; hidden ONLY while ISR is active for the isolate). `rawEnv` is the + * unfiltered runtime env, consulted via {@link isIsrActive} to gate the + * exact-name check so a non-ISR app's own `ISR_CACHE` binding stays visible — + * matching the build-time `isr` gate on the generated `c.env` filter. + * + * Single source of truth for the runtime `env` filter here and the public + * `void/env` proxy (`env-public.ts`). */ -export function isInternalBinding(name: string): boolean { +export function isInternalBinding(name: string, rawEnv: RuntimeEnv): boolean { + if (INTERNAL_BINDING_PREFIXES.some((p) => name.startsWith(p))) { + return true; + } return ( - INTERNAL_BINDING_PREFIXES.some((p) => name.startsWith(p)) || - (INTERNAL_BINDING_EXACT_NAMES as ReadonlyArray).includes(name) + isIsrActive(rawEnv) && (INTERNAL_BINDING_EXACT_NAMES as ReadonlyArray).includes(name) ); } function filterEnvProxy(env: RuntimeEnv): RuntimeEnv { + // The proxy target IS the raw env, so each trap consults `target` for the + // ISR-active gate — no extra `getRawRuntimeEnv()` call needed. return new Proxy(env, { get(target, prop, receiver) { - if (typeof prop === 'string' && isInternalBinding(prop)) { + if (typeof prop === 'string' && isInternalBinding(prop, target)) { return undefined; } return Reflect.get(target, prop, receiver); }, has(target, prop) { - if (typeof prop === 'string' && isInternalBinding(prop)) { + if (typeof prop === 'string' && isInternalBinding(prop, target)) { return false; } return Reflect.has(target, prop); }, ownKeys(target) { return Reflect.ownKeys(target).filter( - (key) => typeof key !== 'string' || !isInternalBinding(key), + (key) => typeof key !== 'string' || !isInternalBinding(key, target), ); }, getOwnPropertyDescriptor(target, prop) { - if (typeof prop === 'string' && isInternalBinding(prop)) { + if (typeof prop === 'string' && isInternalBinding(prop, target)) { return undefined; } return Reflect.getOwnPropertyDescriptor(target, prop); @@ -72,10 +82,11 @@ export function getRuntimeEnv(): RuntimeEnv { } export function getRuntimeBinding(name: string): T | undefined { - if (isInternalBinding(name)) { + const raw = getRawRuntimeEnv(); + if (isInternalBinding(name, raw)) { return undefined; } - return getRawRuntimeEnv()[name] as T | undefined; + return raw[name] as T | undefined; } export function requireRuntimeBinding(name: string): T { diff --git a/packages/void/test/unit/env-schema.test.ts b/packages/void/test/unit/env-schema.test.ts index a66594a74..ae6eb7f14 100644 --- a/packages/void/test/unit/env-schema.test.ts +++ b/packages/void/test/unit/env-schema.test.ts @@ -424,29 +424,33 @@ describe('_validateEnvOnce (boot-time worker check)', () => { }); }); -// FIX C: internal framework bindings (e.g. ISR_CACHE) must never be exposed -// through void/env — not even when a user's defineEnv schema declares one and -// boot validation resolved and seeded its RAW value into the sync cache. +// FIX C + FIX D: internal framework bindings (e.g. ISR_CACHE) must never be +// exposed through void/env WHILE ISR IS ACTIVE — not even when a user's +// defineEnv schema declares one and boot validation resolved and seeded its RAW +// value into the sync cache. When ISR is inactive, a schema-declared ISR_CACHE +// is an ordinary user binding and IS exposed. describe('_validateEnvOnce — internal bindings stay hidden from void/env', () => { beforeEach(() => { resetSchema(); }); - it('never exposes a schema-declared ISR_CACHE, even after boot validation seeds its raw value', async () => { - // A passthrough validator so the RAW binding value (a KV-like object) passes - // boot validation and would otherwise be seeded into the sync cache — this - // is exactly the leak path FIX C closes. - const passthrough = { - '~standard': { - version: 1 as const, - vendor: 'test', - validate: (v: unknown) => ({ value: v }), - }, - }; + // A passthrough validator so the RAW binding value (a KV-like object) passes + // boot validation and would otherwise be seeded into the sync cache — this is + // exactly the leak path FIX C closes (while ISR is active). + const passthrough = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: (v: unknown) => ({ value: v }), + }, + }; + + it('never exposes a schema-declared ISR_CACHE while ISR is active, even after boot validation seeds its raw value', async () => { defineEnv({ ISR_CACHE: passthrough, API_KEY: string() }); const fakeIsrKv = { __brand: 'isr-kv' }; - const rawEnv = { ISR_CACHE: fakeIsrKv, API_KEY: 'sk_live' }; + // `__VOID_ISR_DEPLOYMENT_ID` present ⇒ ISR active ⇒ ISR_CACHE is internal. + const rawEnv = { ISR_CACHE: fakeIsrKv, API_KEY: 'sk_live', __VOID_ISR_DEPLOYMENT_ID: 'dep_1' }; await withRuntimeEnv(rawEnv, async () => { // Boot validation runs FIRST — as it does at worker boot, before any @@ -473,6 +477,28 @@ describe('_validateEnvOnce — internal bindings stay hidden from void/env', () expect(env.API_KEY).toBe('sk_live'); }); }); + + it('exposes a schema-declared ISR_CACHE when ISR is inactive (no __VOID_ISR_DEPLOYMENT_ID)', async () => { + defineEnv({ ISR_CACHE: passthrough, API_KEY: string() }); + + const fakeIsrKv = { __brand: 'user-kv' }; + // No `__VOID_ISR_DEPLOYMENT_ID` ⇒ ISR inactive ⇒ ISR_CACHE is a user binding. + const rawEnv = { ISR_CACHE: fakeIsrKv, API_KEY: 'sk_live' }; + + await withRuntimeEnv(rawEnv, async () => { + await _validateEnvOnce(rawEnv); + + // ISR_CACHE is a normal declared binding here — visible everywhere. + expect((env as Record).ISR_CACHE).toEqual(fakeIsrKv); + expect('ISR_CACHE' in env).toBe(true); + expect(Object.keys(env)).toContain('ISR_CACHE'); + + expect(getRuntimeBinding('ISR_CACHE')).toEqual(fakeIsrKv); + expect(requireRuntimeBinding('ISR_CACHE')).toEqual(fakeIsrKv); + + expect(env.API_KEY).toBe('sk_live'); + }); + }); }); describe('env proxy with async validators', () => { diff --git a/packages/void/test/unit/env.test.ts b/packages/void/test/unit/env.test.ts index de645a1a8..13b9252e9 100644 --- a/packages/void/test/unit/env.test.ts +++ b/packages/void/test/unit/env.test.ts @@ -48,42 +48,89 @@ describe('runtime env resolution', () => { }); }); -// FIX 3: ISR_CACHE is a framework-internal binding with no internal prefix. It -// must be hidden from every public env surface (matching the generated c.env -// filter), while normal bindings pass through. +// FIX 3 + FIX D: ISR_CACHE is a framework-internal binding with no internal +// prefix. It is hidden from every public env surface ONLY while ISR is active +// for the isolate — signalled by `__VOID_ISR_DEPLOYMENT_ID` on the raw env +// (stamped by the generated worker entry on ISR-emitting builds). This mirrors +// the build-time `isr` gate on the generated c.env filter. When ISR is NOT +// active, `ISR_CACHE` is an ordinary user binding and stays visible. Prefix +// internals (`__VOID_*`, `__PROJECT_*`) are hidden unconditionally in both. describe('internal binding filtering — ISR_CACHE (exact-name)', () => { - it('hides ISR_CACHE from getRuntimeEnv / getRuntimeBinding / requireRuntimeBinding', () => { - withRuntimeEnv( - { DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' }, __VOID_TOKEN: 'secret' }, - () => { - const filtered = getRuntimeEnv() as Record; - // Normal binding still visible. - expect(filtered.DB).toEqual({ tag: 'db' }); - expect('DB' in filtered).toBe(true); - // ISR_CACHE hidden by exact name. - expect(filtered.ISR_CACHE).toBeUndefined(); - expect('ISR_CACHE' in filtered).toBe(false); - expect(Object.keys(filtered)).not.toContain('ISR_CACHE'); - // Prefixed internal still hidden (regression guard for the prefix path). - expect('__VOID_TOKEN' in filtered).toBe(false); + describe('ISR active (raw env has __VOID_ISR_DEPLOYMENT_ID)', () => { + it('hides ISR_CACHE from getRuntimeEnv / getRuntimeBinding / requireRuntimeBinding', () => { + withRuntimeEnv( + { + DB: { tag: 'db' }, + ISR_CACHE: { tag: 'isr-kv' }, + __VOID_TOKEN: 'secret', + __VOID_ISR_DEPLOYMENT_ID: 'dep_1', + }, + () => { + const filtered = getRuntimeEnv() as Record; + // Normal binding still visible. + expect(filtered.DB).toEqual({ tag: 'db' }); + expect('DB' in filtered).toBe(true); + // ISR_CACHE hidden by exact name. + expect(filtered.ISR_CACHE).toBeUndefined(); + expect('ISR_CACHE' in filtered).toBe(false); + expect(Object.keys(filtered)).not.toContain('ISR_CACHE'); + // Prefixed internal still hidden (regression guard for the prefix path). + expect('__VOID_TOKEN' in filtered).toBe(false); + expect('__VOID_ISR_DEPLOYMENT_ID' in filtered).toBe(false); - expect(getRuntimeBinding('DB')).toEqual({ tag: 'db' }); - expect(getRuntimeBinding('ISR_CACHE')).toBeUndefined(); - expect(() => requireRuntimeBinding('ISR_CACHE')).toThrow( - "env: Missing Cloudflare binding 'ISR_CACHE'.", - ); - }, - ); + expect(getRuntimeBinding('DB')).toEqual({ tag: 'db' }); + expect(getRuntimeBinding('ISR_CACHE')).toBeUndefined(); + expect(() => requireRuntimeBinding('ISR_CACHE')).toThrow( + "env: Missing Cloudflare binding 'ISR_CACHE'.", + ); + }, + ); + }); + + it('hides ISR_CACHE from the public `env` proxy (absent + not enumerated), keeps normal bindings', () => { + withRuntimeEnv( + { DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' }, __VOID_ISR_DEPLOYMENT_ID: 'dep_1' }, + () => { + expect('ISR_CACHE' in env).toBe(false); + expect('DB' in env).toBe(true); + expect(Object.keys(env)).not.toContain('ISR_CACHE'); + expect(Object.keys(env)).toContain('DB'); + expect((env as Record).ISR_CACHE).toBeUndefined(); + expect((env as Record).DB).toEqual({ tag: 'db' }); + }, + ); + }); }); - it('hides ISR_CACHE from the public `env` proxy (absent + not enumerated), keeps normal bindings', () => { - withRuntimeEnv({ DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' } }, () => { - expect('ISR_CACHE' in env).toBe(false); - expect('DB' in env).toBe(true); - expect(Object.keys(env)).not.toContain('ISR_CACHE'); - expect(Object.keys(env)).toContain('DB'); - expect((env as Record).ISR_CACHE).toBeUndefined(); - expect((env as Record).DB).toEqual({ tag: 'db' }); + describe('ISR inactive (no __VOID_ISR_DEPLOYMENT_ID)', () => { + it('treats ISR_CACHE as a normal user binding across getRuntimeEnv / getRuntimeBinding / requireRuntimeBinding', () => { + withRuntimeEnv( + { DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' }, __VOID_TOKEN: 'secret' }, + () => { + const filtered = getRuntimeEnv() as Record; + // ISR_CACHE is visible — the user owns this binding when ISR is off. + expect(filtered.ISR_CACHE).toEqual({ tag: 'isr-kv' }); + expect('ISR_CACHE' in filtered).toBe(true); + expect(Object.keys(filtered)).toContain('ISR_CACHE'); + // Prefixed internal is STILL hidden unconditionally. + expect('__VOID_TOKEN' in filtered).toBe(false); + expect(Object.keys(filtered)).not.toContain('__VOID_TOKEN'); + + expect(getRuntimeBinding('ISR_CACHE')).toEqual({ tag: 'isr-kv' }); + expect(requireRuntimeBinding('ISR_CACHE')).toEqual({ tag: 'isr-kv' }); + // Prefixed internal still blocked. + expect(getRuntimeBinding('__VOID_TOKEN')).toBeUndefined(); + }, + ); + }); + + it('exposes ISR_CACHE through the public `env` proxy (present + enumerated)', () => { + withRuntimeEnv({ DB: { tag: 'db' }, ISR_CACHE: { tag: 'isr-kv' } }, () => { + expect('ISR_CACHE' in env).toBe(true); + expect(Object.keys(env)).toContain('ISR_CACHE'); + expect((env as Record).ISR_CACHE).toEqual({ tag: 'isr-kv' }); + expect((env as Record).DB).toEqual({ tag: 'db' }); + }); }); }); }); From 1c7dffc5eae74f48fcfb9ab8a1dced3dc7629f70 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 9 Jul 2026 16:53:41 +0800 Subject: [PATCH 30/31] fix(isr): bypass ISR for Range requests + only cache full 200 responses A Range GET matching an ISR path (e.g. a byte-range media route under a global revalidate) rendered 206 Partial Content and, because the write gates used response.ok (200-299, including 206), was written to the shared (host,path) full-response entry -- a later full request was then served that truncated 206. handleIsr also only guarded method/shouldBypassIsr, never the Range header. - handleIsr: bail (null -> caller renders live) for any request carrying a Range header, BEFORE any cache read or write, so it renders live (correct 206) and never touches the shared entry. - Tighten both write gates (revalidateInBackground + miss path) from response.ok to response.status === 200 (never store 206/204/etc.). Co-Authored-By: Claude Opus 4.8 --- packages/void/src/runtime/isr-cache.ts | 17 +++- packages/void/test/unit/isr-cache.test.ts | 104 ++++++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts index 9013a1418..9b61f3f57 100644 --- a/packages/void/src/runtime/isr-cache.ts +++ b/packages/void/src/runtime/isr-cache.ts @@ -732,7 +732,7 @@ async function revalidateInBackground( } const ttl = resolveRevalidateTtl(response, defaultTtl); const cache = edgeCache(); - if (ttl > 0 && response.ok) { + if (ttl > 0 && response.status === 200) { await writeIsrCache(env, host, pathname, response, ttl); // Update HTML edge cache with the fresh response. const headers = new Headers(response.headers); @@ -772,7 +772,9 @@ async function revalidateInBackground( * cache op failed open). * * Ladder: - * - Bail (`null`) if not GET, `shouldBypassIsr`, or the path has no ISR TTL. + * - Bail (`null`) if not GET, a Range request, `shouldBypassIsr`, or the path + * has no ISR TTL. Range requests bail BEFORE any cache read/write so they + * render live (correct 206) and never touch the shared full-response entry. * - JSON (X-VoidPages) path: edge match → hit serves `clientRevalidateResponse`; * miss renders live JSON and returns it — the JSON path never writes the edge * cache. The JSON edge variant is warmed only as a side-effect of the HTML @@ -793,6 +795,15 @@ export async function handleIsr( if (request.method !== 'GET') { return null; } + // Range requests must render LIVE so the origin produces a correct partial + // response (`206 Partial Content`) for the requested byte range. They must + // never read or write the shared ISR entry: reading would serve a full-body + // entry for a partial request, and writing would poison the shared (host,path) + // key with a truncated 206 that a later full request would then be served. + // Return null here (→ caller renders live) BEFORE any cache read or write. + if (request.headers.has('range')) { + return null; + } if (shouldBypassIsr(request)) { return null; } @@ -889,7 +900,7 @@ export async function handleIsr( return response; } const ttl = resolveRevalidateTtl(response, isrTtl); - if (ttl > 0 && response.ok) { + if (ttl > 0 && response.status === 200) { // Every clone below is taken synchronously (before `response.body` is read // for the client) so no consumer races the body away. ctx.waitUntil(writeIsrCache(env, host, pathname, response, ttl)); diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts index 888b7fbdb..bfbb4b1b6 100644 --- a/packages/void/test/unit/isr-cache.test.ts +++ b/packages/void/test/unit/isr-cache.test.ts @@ -1021,6 +1021,110 @@ describe('handleIsr', () => { // The edge-warm background task (guarded on undefined cache) drains cleanly. await expect(flush()).resolves.toBeUndefined(); }); + + // ========================================================================= + // Range bypass + full-200-only cache gate + // A Range GET on an ISR path (e.g. a byte-range media route under a global + // `revalidate: "*"`) must render LIVE so the origin emits a correct `206 + // Partial Content`. It must never read the shared (host,path) entry (a full + // body for a partial request) nor write it (a truncated 206 that a later full + // request would then be served). Defense-in-depth: the write gate is tightened + // from `response.ok` (200–299, INCLUDING 206) to `status === 200`, so even a + // non-range render that returns 206/204/etc. never lands in the shared cache. + // ========================================================================= + describe('Range bypass + full-200-only cache gate', () => { + it('bypasses ISR for a Range request → null before any cache read, render, or write', async () => { + const { store, kv, getWithMetadata, put } = createKvMock(); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => new Response('SHOULD-NOT-RENDER', { status: 206 }), + ); + const { ctx, tasks } = createCtx(); + + const res = await handleIsr( + htmlRequest('/blog/post', { Range: 'bytes=0-1023' }), + env, + ctx, + OPTS, + render, + ); + + // Range requests render live (the caller handles them) — handleIsr bails. + expect(res).toBeNull(); + // No cache READ: neither the L1 edge nor the L2 KV was consulted. + expect(edge.match).not.toHaveBeenCalled(); + expect(getWithMetadata).not.toHaveBeenCalled(); + // No render kicked off inside handleIsr, and no cache WRITE scheduled. + expect(spy).not.toHaveBeenCalled(); + expect(edge.put).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + expect(tasks).toHaveLength(0); + expect(store.size).toBe(0); + expect(edge.store.size).toBe(0); + }); + + it('does NOT cache a forced 206 render (non-range) — only full 200 responses are written', async () => { + const { store, kv, put } = createKvMock(); + const env = makeEnv(kv); + // No Range header on the request, yet the render returns 206 — the tightened + // write gate (status === 200, not response.ok) must still refuse to persist it. + const { render, spy } = createRender( + () => + new Response('partial', { + status: 206, + headers: { 'content-type': 'text/html', 'content-range': 'bytes 0-9/100' }, + }), + ); + const { ctx, tasks, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + + // The live 206 is returned to the client verbatim (never rewritten to 200). + expect(res).not.toBeNull(); + expect(res!.status).toBe(206); + expect(await res!.text()).toBe('partial'); + expect(spy).toHaveBeenCalledTimes(1); + + await flush(); + // Nothing persisted: a 206 partial body must never enter a shared full- + // response cache (KV or edge), and no write was even scheduled. + expect(tasks).toHaveLength(0); + expect(put).not.toHaveBeenCalled(); + expect(edge.put).not.toHaveBeenCalled(); + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(0); + expect(edge.store.size).toBe(0); + expect(await readIsrCache(env, HOST, '/blog/post')).toBeNull(); + }); + + it('still writes + serves a full 200 render (the tightened gate does not block 200)', async () => { + const { store, kv, put } = createKvMock(); + const env = makeEnv(kv); + const { render, spy } = createRender( + () => + new Response('ok-200', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + ); + const { ctx, flush } = createCtx(); + + const res = await handleIsr(htmlRequest(), env, ctx, OPTS, render); + expect(res!.status).toBe(200); + expect(res!.headers.get('x-isr-cache')).toBe('MISS'); + expect(await res!.text()).toBe('ok-200'); + expect(spy).toHaveBeenCalledTimes(1); + + await flush(); + // The 200 render lands in KV + the HTML edge variant, exactly as before. + expect(put).toHaveBeenCalled(); + const entry = await readIsrCache(env, HOST, '/blog/post'); + expect(entry).not.toBeNull(); + expect(entry!.status).toBe(200); + expect(await new Response(entry!.body).text()).toBe('ok-200'); + expect(edge.store.has(htmlEdgeKey(HOST, '/blog/post', DPL))).toBe(true); + expect([...store.keys()].filter((k) => k.startsWith('void-isr/')).length).toBe(1); + }); + }); }); // =========================================================================== From 0131eef0694b231e7264863b2de3646a523f27a0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 9 Jul 2026 16:59:12 +0800 Subject: [PATCH 31/31] fix(isr): refresh __VOID_ISR_HOST per request + retry failed host registration - compile.ts: stamp __VOID_ISR_HOST with `=` (not `??=`) so a warm env reused across hostnames tracks the current request's host; revalidate() from a second custom domain no longer reads a frozen first host. Deployment id stays `??=`. - isr-cache.ts: clear the in-isolate registry guard when the registry `kv.put` fails, so a later write retries instead of permanently dropping the host from listIsrHosts() (which cron/queue purge relies on). Co-Authored-By: Claude Opus 4.8 --- packages/void/src/router/compile.ts | 8 +++-- packages/void/src/runtime/isr-cache.ts | 9 ++++-- packages/void/test/unit/compile.test.ts | 6 +++- packages/void/test/unit/isr-cache.test.ts | 39 +++++++++++++++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 752c5c9ad..274ba72b2 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -2537,9 +2537,13 @@ function __filterInternalBindings(env) { : ''; // Capture deployment id + host on the raw env at the top of fetch(), before // dispatch — always, so `revalidate()` (which reads the raw env) can see them - // even on requests that never hit the ISR render ladder. + // even on requests that never hit the ISR render ladder. The deployment id is + // invariant (`??=`), but the host MUST track the CURRENT request: a warm env + // object can be reused across hostnames, so `??=` would freeze it to the first + // host ever seen and make `revalidate()` from a second custom domain read the + // wrong host. Assign it every fetch. const isrEnvCapture = isr - ? ` env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n env.__VOID_ISR_HOST ??= new URL(request.url).hostname;\n` + ? ` env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n env.__VOID_ISR_HOST = new URL(request.url).hostname;\n` : ''; // Scheduled/queue handlers have no request, so there is no host to stamp — but // they still need the deployment id on the raw env so an on-demand diff --git a/packages/void/src/runtime/isr-cache.ts b/packages/void/src/runtime/isr-cache.ts index 9b61f3f57..6478ac3c7 100644 --- a/packages/void/src/runtime/isr-cache.ts +++ b/packages/void/src/runtime/isr-cache.ts @@ -374,12 +374,17 @@ async function registerIsrHost(kv: KVNamespace, deploymentId: string, host: stri return; } // Mark BEFORE the await so concurrent page writes in this isolate don't all - // fire the registry put; a rare transient failure simply retries next isolate. + // fire the registry put. On failure, CLEAR the guard so a later write in this + // same (possibly long-lived) isolate retries — otherwise one transient error + // would permanently drop this host from `listIsrHosts()`, and a cron/queue + // `revalidate()` (which has no request host) could never purge it. registeredHosts.add(guardKey); try { await kv.put(hostRegistryKey(deploymentId, host), ''); } catch { - // Fail open — a registry write failure must never 500 a page render. + // Fail open — a registry write failure must never 500 a page render — but + // allow a retry on a subsequent write instead of skipping this host forever. + registeredHosts.delete(guardKey); } } diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index dc5871b8e..e939bf9bd 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -2876,7 +2876,11 @@ describe('compileWorkerEntry — ISR wiring', () => { // Deployment id + host captured on the raw env so revalidate() can purge even // on requests that never enter the render ladder. expect(code).toContain('env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;'); - expect(code).toContain('env.__VOID_ISR_HOST ??= new URL(request.url).hostname;'); + // Host is assigned every fetch (NOT `??=`) so a warm env reused across + // hostnames does not freeze it to the first host; the id stays `??=`. + expect(code).toContain('env.__VOID_ISR_HOST = new URL(request.url).hostname;'); + expect(code).not.toContain('env.__VOID_ISR_HOST ??= new URL(request.url).hostname;'); + expect(code).toContain('env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;'); // When ISR is emitted the c.env internal-binding filter gains the ISR_CACHE // exact-name checks so the self-host ISR KV binding cannot leak into route // handlers' c.env (matches the managed platform; handleIsr reads raw env). diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts index bfbb4b1b6..3ae54dd83 100644 --- a/packages/void/test/unit/isr-cache.test.ts +++ b/packages/void/test/unit/isr-cache.test.ts @@ -1228,6 +1228,45 @@ describe('host-scoped KV cache + host registry', () => { expect(store.has(hostRegistryKey(DPL, host))).toBe(true); }); + it('retries host registration on a later write after a transient registry-put failure', async () => { + // Unique host so the module-level in-isolate registration guard does not pre-skip it. + const host = 'registry-retry.example'; + const store = new Map(); + let registryPuts = 0; + const put = vi.fn( + async (key: string, value: ArrayBuffer, options?: { metadata?: IsrCacheMetadata }) => { + if (key.startsWith('void-isr-hosts/')) { + registryPuts++; + if (registryPuts === 1) { + throw new Error('transient registry failure'); + } + } + store.set(key, { value, metadata: options?.metadata ?? null }); + }, + ); + const list = vi.fn(async (o: { prefix?: string } = {}) => ({ + keys: [...store.keys()] + .filter((k) => k.startsWith(o.prefix ?? '')) + .sort() + .map((name) => ({ name })), + list_complete: true, + cursor: '', + })); + const kv = { put, list, getWithMetadata: vi.fn(), delete: vi.fn() } as unknown as KVNamespace; + const env = makeEnv(kv); + const res = () => new Response('x', { headers: { 'content-type': 'text/html' } }); + + // First write: content put succeeds, registry put throws → guard cleared, host unregistered. + await writeIsrCache(env, host, '/p', res(), 60); + expect(store.has(hostRegistryKey(DPL, host))).toBe(false); + + // Second write: the guard was cleared, so the registry put is retried and succeeds. + await writeIsrCache(env, host, '/p', res(), 60); + expect(registryPuts).toBe(2); + expect(store.has(hostRegistryKey(DPL, host))).toBe(true); + expect(await listIsrHosts(kv, DPL)).toContain(host); + }); + it('listIsrHosts returns the registered hosts for a deployment, scoped by id', async () => { const { store, kv } = createKvMock(); store.set(hostRegistryKey(DPL, 'a.example'), { value: new ArrayBuffer(0), metadata: null });