From 7e13837dbe511750ad486ad38ec30c7fbe5d265f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 18:52:29 +0800 Subject: [PATCH 1/2] fix(platform): preserve immutable cached asset responses --- .../unit/platform-dispatch-assets.test.ts | 113 ++++++++++++++++++ platform/packages/dispatch/src/index.ts | 4 +- 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 packages/void/test/unit/platform-dispatch-assets.test.ts diff --git a/packages/void/test/unit/platform-dispatch-assets.test.ts b/packages/void/test/unit/platform-dispatch-assets.test.ts new file mode 100644 index 000000000..ca47a26dc --- /dev/null +++ b/packages/void/test/unit/platform-dispatch-assets.test.ts @@ -0,0 +1,113 @@ +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { rolldown } from 'rolldown'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +type LocalRuntime = { + dispatchFetch(url: string): Promise; + dispose(): Promise; +}; + +let runtime: LocalRuntime; + +beforeAll(async () => { + const require = createRequire(import.meta.url); + const miniflarePath = createRequire(require.resolve('wrangler')).resolve('miniflare'); + const { Miniflare, convertV4MiniflareOptions } = (await import( + pathToFileURL(miniflarePath).href + )) as { + Miniflare: new (options: Record) => LocalRuntime; + convertV4MiniflareOptions(options: Record): Record; + }; + const dispatchPath = fileURLToPath( + new URL('../../../../platform/packages/dispatch/src/index.ts', import.meta.url), + ); + // Use the real Workers Cache API: Map-backed Response mocks have writable + // headers and cannot catch mutations of an immutable cache-hit response. + const fixture = ` + import dispatch from ${JSON.stringify(dispatchPath)}; + let originCalls = 0; + export default { + async fetch(request) { + const pathname = new URL(request.url).pathname; + const headerRules = pathname.includes('unmatched') + ? [{ pattern: '/other/*', headers: [['X-Test', 'unmatched']] }] + : pathname.includes('matched') + ? [{ pattern: '/assets/*', headers: [['X-Test', 'matched']] }] + : undefined; + const pending = []; + const routing = { + workerName: 'test-worker', projectId: 'proj-test', ssr: true, + hashedAssetsPrefix: 'assets', headerRules, + }; + const response = await dispatch.fetch(request, { + ROUTING: { get: async () => JSON.stringify(routing) }, + DISPATCH: { get: () => ({ fetch: async () => { + originCalls++; + return new Response('export const value = 42;', { + headers: { 'Content-Type': 'text/javascript', 'X-Origin-Call': String(originCalls) }, + }); + } }) }, + ANALYTICS: { writeDataPoint() {} }, + }, { waitUntil: promise => pending.push(promise) }); + await Promise.all(pending); + return response; + }, + }; + `; + const build = await rolldown({ + input: 'virtual:asset-cache-runtime', + plugins: [ + { + name: 'asset-cache-runtime', + resolveId: (id) => (id === 'virtual:asset-cache-runtime' ? id : undefined), + load: (id) => (id === 'virtual:asset-cache-runtime' ? fixture : undefined), + }, + ], + }); + try { + const { output } = await build.generate({ format: 'esm' }); + const entry = output.find((chunk) => chunk.type === 'chunk' && chunk.isEntry); + if (!entry || entry.type !== 'chunk') { + throw new Error('Dispatch test bundle is missing'); + } + runtime = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + compatibilityDate: '2026-02-12', + script: entry.code, + }), + ); + } finally { + await build.close(); + } +}); + +afterAll(async () => { + await runtime?.dispose(); +}); + +describe('hashed assets in the Workers runtime', () => { + it.each(['no-rules', 'unmatched-rules', 'matched-rules'])( + 'serves repeated cache hits with %s', + async (scenario) => { + const url = `https://test.void.app/assets/${scenario}.js`; + const miss = await runtime.dispatchFetch(url); + expect(miss.status).toBe(200); + const originCall = miss.headers.get('X-Origin-Call'); + expect(originCall).not.toBeNull(); + expect(await miss.text()).toBe('export const value = 42;'); + + for (let i = 0; i < 2; i++) { + const hit = await runtime.dispatchFetch(url); + expect(hit.status).toBe(200); + expect(await hit.text()).toBe('export const value = 42;'); + expect(hit.headers.get('X-Origin-Call')).toBe(originCall); + expect(hit.headers.get('Content-Type')).toBe('text/javascript'); + expect(hit.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable'); + expect(hit.headers.get('x-void-shared-cache-policy')).toBeNull(); + expect(hit.headers.get('X-Test')).toBe(scenario === 'matched-rules' ? 'matched' : null); + } + }, + ); +}); diff --git a/platform/packages/dispatch/src/index.ts b/platform/packages/dispatch/src/index.ts index fd1ed15f7..afbd0fbf1 100644 --- a/platform/packages/dispatch/src/index.ts +++ b/platform/packages/dispatch/src/index.ts @@ -736,6 +736,9 @@ export default { } const res = new Response(response.body, response); res.headers.delete('X-Void-No-Match'); + // Cache API and upstream responses have immutable headers. Strip the + // internal cache marker only from this writable client response. + res.headers.delete(SHARED_CACHE_POLICY_HEADER); res.headers.set('X-Hosted-By', 'void.cloud'); return res; }; @@ -862,7 +865,6 @@ export default { } } } - ruled.headers.delete(SHARED_CACHE_POLICY_HEADER); return meter(ruled, failureKind ? { failureKind } : undefined); }; From d2090d5e0fd5468383e4ad1002de893aa7301c94 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 21:02:34 +0800 Subject: [PATCH 2/2] test(platform): run cached asset regressions in platform CI --- platform/packages/dispatch/package.json | 3 ++- .../packages/dispatch/test/runtime/asset-cache.test.ts | 4 +--- platform/packages/dispatch/tsconfig.json | 3 ++- platform/packages/dispatch/tsconfig.test.json | 8 ++++++++ pnpm-lock.yaml | 3 +++ 5 files changed, 16 insertions(+), 5 deletions(-) rename packages/void/test/unit/platform-dispatch-assets.test.ts => platform/packages/dispatch/test/runtime/asset-cache.test.ts (97%) create mode 100644 platform/packages/dispatch/tsconfig.test.json diff --git a/platform/packages/dispatch/package.json b/platform/packages/dispatch/package.json index 278c7274e..3d6c5d53f 100644 --- a/platform/packages/dispatch/package.json +++ b/platform/packages/dispatch/package.json @@ -5,7 +5,7 @@ "scripts": { "deploy": "node ../../scripts/wrangler-deploy.ts", "dev": "wrangler dev", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json" }, "dependencies": { "@void/deploy-core": "workspace:*", @@ -15,6 +15,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "catalog:platform", + "@types/node": "catalog:", "typescript": "catalog:", "vite-plus": "catalog:", "wrangler": "catalog:platform" diff --git a/packages/void/test/unit/platform-dispatch-assets.test.ts b/platform/packages/dispatch/test/runtime/asset-cache.test.ts similarity index 97% rename from packages/void/test/unit/platform-dispatch-assets.test.ts rename to platform/packages/dispatch/test/runtime/asset-cache.test.ts index ca47a26dc..a771707bc 100644 --- a/packages/void/test/unit/platform-dispatch-assets.test.ts +++ b/platform/packages/dispatch/test/runtime/asset-cache.test.ts @@ -19,9 +19,7 @@ beforeAll(async () => { Miniflare: new (options: Record) => LocalRuntime; convertV4MiniflareOptions(options: Record): Record; }; - const dispatchPath = fileURLToPath( - new URL('../../../../platform/packages/dispatch/src/index.ts', import.meta.url), - ); + const dispatchPath = fileURLToPath(new URL('../../src/index.ts', import.meta.url)); // Use the real Workers Cache API: Map-backed Response mocks have writable // headers and cannot catch mutations of an immutable cache-hit response. const fixture = ` diff --git a/platform/packages/dispatch/tsconfig.json b/platform/packages/dispatch/tsconfig.json index b1f08e146..63d2dc541 100644 --- a/platform/packages/dispatch/tsconfig.json +++ b/platform/packages/dispatch/tsconfig.json @@ -11,5 +11,6 @@ "types": ["@cloudflare/workers-types"], "allowImportingTsExtensions": true }, - "include": ["src", "test"] + "include": ["src", "test"], + "exclude": ["test/runtime"] } diff --git a/platform/packages/dispatch/tsconfig.test.json b/platform/packages/dispatch/tsconfig.test.json new file mode 100644 index 000000000..ed40f07d7 --- /dev/null +++ b/platform/packages/dispatch/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["test/runtime"], + "exclude": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 735310fd0..b9301cbb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -865,6 +865,9 @@ importers: '@cloudflare/workers-types': specifier: catalog:platform version: 5.20260910.1 + '@types/node': + specifier: 'catalog:' + version: 26.5.1 typescript: specifier: 'catalog:' version: 7.0.2