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.
The
@fedify/elysiafedify()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 duplicatedContent-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.Steps to reproduce
Register a real actor dispatcher, mount the plugin, and read the response headers. Pin
elysiato 1.4.18 or earlier (for exampleelysia@1.3.8);app.handle()is enough, so no HTTP server is needed: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:
Actual behavior
Every header Fedify sets is duplicated.
Content-TypeandVaryeach come back twice:Cause
In packages/elysia/src/index.ts, the
onRequesthandler copies every header of the federation response intoset.headersand then returns the sameresponse: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 mergesset.headersinto 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/elysiasupports (^1.3.6in package.json,^1.3.8in 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: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.