Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/merge-headers-fix.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions packages/remix-fastify/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "</remix-resource.css>; rel=preload; as=style");
headers.append("Link", "</remix-script.js>; 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", "</fonts/my-font.woff2>; 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("</fonts/my-font.woff2>; rel=preload; as=font; crossorigin");
expect(linkHeaders[1]).toBe("</remix-resource.css>; rel=preload; as=style");
expect(linkHeaders[2]).toBe("</remix-script.js>; rel=modulepreload");
});
});
});

Expand Down
38 changes: 36 additions & 2 deletions packages/remix-fastify/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,42 @@ export async function sendResponse<Server extends HttpServer>(
): Promise<void> {
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<string, string[]>();
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) {
Expand Down
Loading