From 44c15bcd2b50fe5d29007da3f18ae4fefd200cfd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 22:17:23 +0000 Subject: [PATCH] fix: return mutable copy of cached responses so hooks can add security headers Responses from the Workers Cache API have immutable headers. hooks.server.ts runs applySecurityHeaders on every response, which calls headers.set(...) and threw "Can't modify immutable headers." on public content cache hits (/f, /t), surfacing as a 500 on the second GET of a cached file. matchEdgeCache now reconstructs the cached response as a mutable copy at the single boundary where Cache API responses enter the app, covering every cache-hit path while still serving from and storing in the edge cache. Co-authored-by: Ben Davis --- apps/web/src/lib/server/content-cache.test.ts | 27 +++++++++++++++++++ apps/web/src/lib/server/content-cache.ts | 6 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/content-cache.test.ts b/apps/web/src/lib/server/content-cache.test.ts index 181ef2f..88e3b84 100644 --- a/apps/web/src/lib/server/content-cache.test.ts +++ b/apps/web/src/lib/server/content-cache.test.ts @@ -121,4 +121,31 @@ describe('content cache policy', () => { Effect.runPromise(matchEdgeCache(platform, request)) ).resolves.toBeInstanceOf(Response); }); + + it('hands back a mutable copy so hooks can add security headers', async () => { + const immutable = new Response('cached-bytes', { + status: 200, + headers: { 'Cache-Control': PUBLIC_IMMUTABLE_CACHE_CONTROL, ETag: '"abc"' } + }); + // Mirror the Workers Cache API, whose responses reject header writes. + immutable.headers.set = () => { + throw new TypeError("Can't modify immutable headers."); + }; + const platform = { + caches: { default: { match: vi.fn(async () => immutable), put: vi.fn() } } + }; + const request = new Request('https://files.example/f/id?v=1'); + + const cached = await Effect.runPromise(matchEdgeCache(platform, request)); + expect(cached).toBeInstanceOf(Response); + expect(cached).not.toBe(immutable); + expect(cached?.headers.get('ETag')).toBe('"abc"'); + expect(cached?.headers.get('Cache-Control')).toBe( + PUBLIC_IMMUTABLE_CACHE_CONTROL + ); + expect(() => + cached?.headers.set('Strict-Transport-Security', 'max-age=1') + ).not.toThrow(); + await expect(cached?.text()).resolves.toBe('cached-bytes'); + }); }); diff --git a/apps/web/src/lib/server/content-cache.ts b/apps/web/src/lib/server/content-cache.ts index bc6a6a4..3924d08 100644 --- a/apps/web/src/lib/server/content-cache.ts +++ b/apps/web/src/lib/server/content-cache.ts @@ -90,7 +90,11 @@ const workerCache = (platform: EdgePlatform) => { export const matchEdgeCache = (platform: EdgePlatform, cacheRequest: Request) => Effect.promise(async () => { try { - return await workerCache(platform)?.match(cacheRequest); + const cached = await workerCache(platform)?.match(cacheRequest); + // The Workers Cache API returns responses with immutable headers, but + // hooks.server.ts applies security headers to every response. Hand back + // a reconstructed, mutable copy so those writes don't throw. + return cached && new Response(cached.body, cached); } catch { return undefined; }