fix(http): silently discard trailers when chunked encoding not used

Per Node.js documentation, `res.addTrailers()` should silently discard
trailers when chunked encoding is not enabled, rather than throwing an
error. This matches Node.js behavior where trailers set via addTrailers()
are only emitted if chunked encoding is used.

Previously, Bun threw ERR_HTTP_TRAILER_INVALID when trailers were set
via addTrailers() and chunked encoding was not enabled. Now we only
throw when the "Trailer" header is explicitly set (indicating intent
to send trailers), while silently discarding trailers set via
addTrailers() when chunked encoding is not in use.

Fixes #26171

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-01-16 17:57:57 +00:00
parent f01467d3dc
commit 7ab5cf7a34
2 changed files with 142 additions and 10 deletions

View File

@@ -1177,17 +1177,29 @@ function _writeHead(statusCode, reason, obj, response) {
if (k) response.setHeader(k, obj[k]);
}
}
if (
(response.chunkedEncoding !== true || response.hasHeader("content-length")) &&
(response._trailer || response.hasHeader("trailer"))
) {
// remove the invalid content-length or trailer header
if (hasContentLength) {
response.removeHeader("trailer");
} else {
response.removeHeader("content-length");
// Check if chunked encoding is being used. Trailers require chunked encoding.
// Check both the chunkedEncoding property and the Transfer-Encoding header.
const teHeader = response.getHeader("transfer-encoding");
const isChunked = response.chunkedEncoding === true || (teHeader && /chunked/i.test(String(teHeader)));
const hasContentLen = response.hasHeader("content-length");
if (!isChunked || hasContentLen) {
// Trailers cannot be used without chunked encoding
if (response.hasHeader("trailer")) {
// If "Trailer" header is explicitly set, that's an error
if (hasContentLength) {
response.removeHeader("trailer");
} else {
response.removeHeader("content-length");
}
throw $ERR_HTTP_TRAILER_INVALID("Trailers are invalid with this transfer encoding");
}
// If only _trailer is set (via addTrailers), silently discard per Node.js docs:
// "Trailers will only be emitted if chunked encoding is used for the response;
// if it is not (e.g. if the request was HTTP/1.0), they will be silently discarded."
if (response._trailer) {
response._trailer = "";
}
throw $ERR_HTTP_TRAILER_INVALID("Trailers are invalid with this transfer encoding");
}
}

View File

@@ -0,0 +1,120 @@
import { expect, test } from "bun:test";
// https://github.com/oven-sh/bun/issues/26171
// res.addTrailers() before res.writeHead() and res.end() should silently discard
// trailers when chunked encoding is not enabled, matching Node.js behavior.
// Direct test using node:http server - this is the exact reproduction from the issue
test("node:http server - addTrailers before writeHead should not throw", async () => {
const { createServer } = await import("node:http");
const server = createServer((req, res) => {
// Add trailers before writeHead - per Node.js docs, these should be silently discarded
// when chunked encoding is not used
res.addTrailers({ "Content-MD5": "7895bf4b8828b55ceaf47747b4bca667" });
res.writeHead(200);
res.end("OK");
});
await new Promise<void>(resolve => {
server.listen(0, () => resolve());
});
const port = (server.address() as any).port;
try {
const response = await fetch(`http://localhost:${port}`);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe("OK");
} finally {
server.close();
}
});
// Test with explicit Content-Length header
test("node:http server - addTrailers with Content-Length should be silently discarded", async () => {
const { createServer } = await import("node:http");
const server = createServer((req, res) => {
// Add trailers before writeHead - per Node.js docs, these should be silently discarded
res.addTrailers({ "Content-MD5": "7895bf4b8828b55ceaf47747b4bca667" });
res.writeHead(200, { "Content-Length": "2" });
res.end("OK");
});
await new Promise<void>(resolve => {
server.listen(0, () => resolve());
});
const port = (server.address() as any).port;
try {
const response = await fetch(`http://localhost:${port}`);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe("OK");
} finally {
server.close();
}
});
// Test that trailers work correctly with chunked encoding
test("node:http server - addTrailers with chunked encoding works", async () => {
const { createServer } = await import("node:http");
const server = createServer((req, res) => {
// When using chunked encoding (no Content-Length), trailers should work
res.writeHead(200, { "Transfer-Encoding": "chunked", "Trailer": "Content-MD5" });
res.write("Hello");
res.addTrailers({ "Content-MD5": "7895bf4b8828b55ceaf47747b4bca667" });
res.end();
});
await new Promise<void>(resolve => {
server.listen(0, () => resolve());
});
const port = (server.address() as any).port;
try {
const response = await fetch(`http://localhost:${port}`);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe("Hello");
} finally {
server.close();
}
});
// Test that explicit Trailer header without chunked encoding still throws
test("node:http server - explicit Trailer header with Content-Length throws", async () => {
const { createServer } = await import("node:http");
let errorCode: string | null = null;
const server = createServer((req, res) => {
// Setting the Trailer header explicitly should throw when not using chunked encoding
try {
res.writeHead(200, { "Content-Length": "2", "Trailer": "Content-MD5" });
res.end("OK");
} catch (e: any) {
errorCode = e.code;
// Can't call writeHead again after it threw, so just destroy the socket
res.socket?.destroy();
}
});
await new Promise<void>(resolve => {
server.listen(0, () => resolve());
});
const port = (server.address() as any).port;
try {
await fetch(`http://localhost:${port}`).catch(() => {});
// The error should have been caught
expect(errorCode).toBe("ERR_HTTP_TRAILER_INVALID");
} finally {
server.close();
}
});