Skip to content

@fedify/elysia duplicates response headers on Elysia 1.4.18 and earlier #970

Description

@dktsudgg

The @fedify/elysia fedify() plugin duplicates every HTTP response header that Fedify produces when the app runs on Elysia 1.4.18 or earlier. A federation response such as an actor document is sent with a duplicated Content-Type, so a strict ActivityPub peer can reject it.

Environment

  • @fedify/elysia: the plugin request handling that causes this is present in every published version and on every branch from 1.8-maintenance through main.
  • Elysia: 1.4.18 and earlier. Elysia 1.4.19 and later mask the bug (see below).
  • Runtime: reproduced on Deno, Bun, and Node.
  • Operating system: not OS-specific (reproduced on macOS).

Steps to reproduce

Register a real actor dispatcher, mount the plugin, and read the response headers. Pin elysia to 1.4.18 or earlier (for example elysia@1.3.8); app.handle() is enough, so no HTTP server is needed:

import { createFederation, MemoryKvStore } from "@fedify/fedify";
import { Person } from "@fedify/vocab";
import { Elysia } from "elysia";
import { fedify } from "@fedify/elysia";

const federation = createFederation<void>({ kv: new MemoryKvStore() });
federation.setActorDispatcher(
  "/users/{identifier}",
  (ctx, id) =>
    id === "alice"
      ? new Person({ id: ctx.getActorUri(id), preferredUsername: id })
      : null,
);

const app = new Elysia().use(fedify(federation, () => undefined));
const res = await app.handle(
  new Request("http://localhost/users/alice", {
    headers: { Accept: "application/activity+json" },
  }),
);
console.log(res.headers.get("content-type"));
// => "application/activity+json, application/activity+json"

For a fuller reproduction that serves this over Bun, Deno, and Node and shows the raw wire headers, see the reproduction repository.

Expected behavior

The federation response is forwarded as-is, with each header appearing once:

HTTP/1.1 200 OK
Content-Type: application/activity+json
Vary: Accept

Actual behavior

Every header Fedify sets is duplicated. Content-Type and Vary each come back twice:

HTTP/1.1 200 OK
Content-Type: application/activity+json, application/activity+json
Vary: Accept, Accept

Cause

In packages/elysia/src/index.ts, the onRequest handler copies every header of the federation response into set.headers and then returns the same response:

response.headers.forEach((value, key) => {
  set.headers[key] = value;
});

if (response.body) {
  return response;
}

When the response has a body it is returned directly, but it already carries its own headers, so each header now lives both on the response and in set.headers. Elysia merges set.headers into the returned response, and on Elysia 1.4.18 and earlier that merge appends unconditionally, so every header is duplicated.

Why Elysia 1.4.18 and earlier

Elysia 1.4.19 changed its header merge to skip headers the response already carries. Its release notes list the fix under the bug-fix section: “#1591, #1590 merge set.headers without duplicating Response by @truehazker” (Elysia 1.4.19 release notes). On 1.4.19 and later the same plugin code no longer duplicates, which is why the steps above pin an Elysia at or below 1.4.18. 1.3.8 is within the range @fedify/elysia supports (^1.3.6 in package.json, ^1.3.8 in the Deno import map and pnpm catalog), so the duplication happens on a supported configuration.

Proposed fix

Move the early return above the header copy, so the copy only runs on the bodyless path where the plugin builds a fresh new Response(null, …) that has no headers of its own:

diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts
--- a/packages/elysia/src/index.ts
+++ b/packages/elysia/src/index.ts
@@ -42,15 +42,15 @@ export const fedify = <TContextData = unknown>(
         if (!notFound && !notAcceptable) {
           set.status = response.status;
 
-          response.headers.forEach((value, key) => {
-            set.headers[key] = value;
-          });
-
           // Return response body if it exists
           if (response.body) {
             return response;
           }
 
+          response.headers.forEach((value, key) => {
+            set.headers[key] = value;
+          });
+
           // Return empty response for successful requests without body
           return new Response(null, { status: response.status });
         }

With this change the response with a body is returned untouched, so there is nothing for Elysia to merge back in, and the headers are no longer duplicated on any Elysia version.

Notes

Per the branch policy this bug fix should target the oldest maintenance branch that contains it; the plugin logic is present at least back to 1.8-maintenance, so I would like to confirm the target branch before opening the pull request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions