diff --git a/.changeset/merge-headers-fix.md b/.changeset/merge-headers-fix.md new file mode 100644 index 00000000..2a4ee108 --- /dev/null +++ b/.changeset/merge-headers-fix.md @@ -0,0 +1,5 @@ +--- +"@mcansh/remix-fastify": patch +--- + +Merge headers set by Fastify with headers from Remix instead of overriding them. This fixes an issue where headers like `Link` set by Fastify before calling the Remix handler were being overridden by headers from the Remix response. diff --git a/packages/remix-fastify/__tests__/server.test.ts b/packages/remix-fastify/__tests__/server.test.ts index 7de7c9e7..e499d8b2 100644 --- a/packages/remix-fastify/__tests__/server.test.ts +++ b/packages/remix-fastify/__tests__/server.test.ts @@ -206,6 +206,42 @@ function runTests( "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax", ]); }); + + it(`[${name}] merges headers set by Fastify with headers from Remix`, async () => { + mockedHandler.mockImplementation(() => async () => { + let headers = new Headers(); + headers.append("Link", "; rel=preload; as=style"); + headers.append("Link", "; rel=modulepreload"); + return new Response(null, { headers }); + }); + + let app = fastify(); + app.all("*", async (request, reply) => { + // Simulate Fastify setting Link headers before Remix handler + reply.header("Link", "; rel=preload; as=font; crossorigin"); + + let handler = createRequestHandler({ + // @ts-expect-error - We don't have a real app to test + build: undefined, + }); + + return handler(request, reply); + }); + + let response = await app.inject("/"); + + expect(response.headers["link"]).toBeDefined(); + + // Should contain both Fastify's header and Remix's headers + let linkHeaders = Array.isArray(response.headers["link"]) + ? response.headers["link"] + : [response.headers["link"]]; + + expect(linkHeaders).toHaveLength(3); + expect(linkHeaders[0]).toBe("; rel=preload; as=font; crossorigin"); + expect(linkHeaders[1]).toBe("; rel=preload; as=style"); + expect(linkHeaders[2]).toBe("; rel=modulepreload"); + }); }); }); diff --git a/packages/remix-fastify/src/shared.ts b/packages/remix-fastify/src/shared.ts index 44ecbd22..99eff834 100644 --- a/packages/remix-fastify/src/shared.ts +++ b/packages/remix-fastify/src/shared.ts @@ -109,8 +109,42 @@ export async function sendResponse( ): Promise { reply.status(nodeResponse.status); - for (let [key, values] of nodeResponse.headers.entries()) { - reply.headers({ [key]: values }); + // Collect all headers from the response first + let responseHeaders = new Map(); + for (let [key, value] of nodeResponse.headers.entries()) { + if (!responseHeaders.has(key)) { + responseHeaders.set(key, []); + } + // The Web Headers API treats Set-Cookie specially: each value appears as a separate + // entry when iterating. For all other headers, multiple values are combined with ", ". + // We split non-Set-Cookie headers on ", " to enable proper merging with existing headers. + // Note: This split is imperfect for headers that contain commas in their values + // (like some Cache-Control directives), but it's necessary because the Headers API + // has already combined them and provides no way to get the original separate values. + // The Link header (the primary use case from the issue) works correctly with this approach. + if (key.toLowerCase() === "set-cookie") { + responseHeaders.get(key)!.push(value); + } else { + let splitValues = value.split(", "); + responseHeaders.get(key)!.push(...splitValues); + } + } + + // Now set headers, merging with any existing headers on the reply + for (let [key, values] of responseHeaders.entries()) { + let existingHeader = reply.hasHeader(key) ? reply.getHeader(key) : undefined; + + if (existingHeader !== undefined) { + // Header exists on reply - merge with response headers + let existingValues = Array.isArray(existingHeader) + ? existingHeader + : [String(existingHeader)]; + let mergedValues = [...existingValues, ...values]; + reply.header(key, mergedValues); + } else { + // No existing header on reply - just set response headers + reply.header(key, values.length === 1 ? values[0] : values); + } } if (nodeResponse.body) {