Bun.serve: a Bun.file() route sends a Transfer-Encoding or Content-Length header from its Response verbatim
What version of Bun is running?
1.4.3-canary.1+c6b7fcb5b. The code on main at 630e921db0 is the same.
What platform is your computer?
Linux x64
What steps can reproduce the bug?
// file-framing.mjs
import net from "node:net";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "file-framing-"));
const p = path.join(dir, "a.txt");
fs.writeFileSync(p, "file-body");
const server = Bun.serve({
port: 0,
hostname: "127.0.0.1",
routes: {
"/file-te": new Response(Bun.file(p), { headers: { "Transfer-Encoding": "chunked" } }),
"/file-cl": new Response(Bun.file(p), { headers: { "Content-Length": "3" } }),
"/static-te": new Response("file-body", { headers: { "Transfer-Encoding": "chunked" } }),
"/static-cl": new Response("file-body", { headers: { "Content-Length": "3" } }),
},
fetch: () => new Response("fallback"),
});
async function raw(pathname) {
const { promise, resolve } = Promise.withResolvers();
let buf = "";
const s = net.connect(server.port, "127.0.0.1", () =>
s.write(`GET ${pathname} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`),
);
s.on("data", d => (buf += d.toString("latin1")));
s.on("error", () => {});
s.on("close", resolve);
await promise;
const [head, ...body] = buf.split("\r\n\r\n");
const framing = head.split("\r\n").filter(l => /^(content-length|transfer-encoding):/i.test(l));
console.log(pathname.padEnd(12), JSON.stringify({ framing, body: body.join("\r\n\r\n") }));
}
for (const r of ["/file-te", "/file-cl", "/static-te", "/static-cl"]) await raw(r);
server.stop(true);
fs.rmSync(dir, { recursive: true, force: true });
Run bun file-framing.mjs.
What is the expected behavior?
The route computes the framing from the file. All four routes send Content-Length: 9 and no Transfer-Encoding. The two static Response routes already do that.
What do you see instead?
/file-te {"framing":["Transfer-Encoding: chunked","content-length: 9"],"body":"file-body"}
/file-cl {"framing":["Content-Length: 3"],"body":"file-body"}
/static-te {"framing":["Content-Length: 9"],"body":"file-body"}
/static-cl {"framing":["Content-Length: 9"],"body":"file-body"}
/file-tesendsTransfer-Encoding: chunkedtogether withcontent-length, and the body is not chunk-encoded. RFC 9112 6.2 forbids the two headers in one message. A client that obeysTransfer-Encodingreadsfile-bodyas a chunk size and fails./file-cldeclares 3 bytes and sends 9. On a keep-alive connection the client reads the other 6 bytes as the start of the next response.
Additional information
StaticRoute::from_js (src/runtime/server/StaticRoute.rs:209) removes Transfer-Encoding and Content-Length from the Response headers before it takes the snapshot. FileRoute::from_js (src/runtime/server/FileRoute.rs:132) takes the snapshot unchanged, and FileRoute::write_headers writes every entry. has_content_length_header then stops the route from writing the length it computed from the file.
A Response that copies the headers of a fetched response is a likely source of such a header. #40902 (open) adds the same strip for { dir, headers } routes.
Found during the review of the static route Connection: close fix. That change does not touch this behavior.
Source: oven-sh/bun