Files
2026-08-27 21:09:14 +00:00

136 lines
4.8 KiB
TypeScript

// https://github.com/oven-sh/bun/issues/34415
// Removing Content-Length (what the npm `compression` middleware does) made the
// server advertise `Transfer-Encoding: chunked` to HTTP/1.0 clients while the
// body went out unframed, so nginx (proxy_http_version 1.0) returned 502.
import { expect, test } from "bun:test";
import { once } from "node:events";
import http from "node:http";
import net from "node:net";
async function serve(handler: http.RequestListener): Promise<{ server: http.Server; port: number }> {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1");
await once(server, "listening");
return { server, port: (server.address() as net.AddressInfo).port };
}
async function rawRequest(port: number, request: string): Promise<string> {
const sock = net.connect(port, "127.0.0.1");
await once(sock, "connect");
sock.write(request);
const chunks: Buffer[] = [];
sock.on("data", c => chunks.push(c));
await once(sock, "close");
return Buffer.concat(chunks).toString("latin1");
}
function parseResponse(raw: string) {
const idx = raw.indexOf("\r\n\r\n");
expect(idx).toBeGreaterThan(0);
const [statusLine, ...headerLines] = raw.slice(0, idx).split("\r\n");
const headers: Record<string, string> = {};
for (const line of headerLines) {
const sep = line.indexOf(": ");
headers[line.slice(0, sep).toLowerCase()] = line.slice(sep + 2);
}
return { statusLine, headers, body: raw.slice(idx + 4) };
}
// Strict chunked-framing decoder: throws on anything nginx would reject.
function decodeChunked(body: string): string {
let out = "";
let i = 0;
while (true) {
const lineEnd = body.indexOf("\r\n", i);
if (lineEnd === -1) throw new Error(`missing chunk size line at ${i}: ${JSON.stringify(body.slice(i, i + 32))}`);
const sizeHex = body.slice(i, lineEnd).split(";")[0];
if (!/^[0-9a-fA-F]+$/.test(sizeHex)) throw new Error(`invalid chunk size ${JSON.stringify(sizeHex)}`);
const size = parseInt(sizeHex, 16);
i = lineEnd + 2;
if (size === 0) {
if (body.slice(i) !== "\r\n") throw new Error(`bad chunked trailer ${JSON.stringify(body.slice(i))}`);
return out;
}
out += body.slice(i, i + size);
i += size;
if (body.slice(i, i + 2) !== "\r\n") throw new Error(`missing CRLF after chunk data at ${i}`);
i += 2;
}
}
function removeContentLengthHandler(req: http.IncomingMessage, res: http.ServerResponse) {
res.removeHeader("Content-Length");
res.write("hello world");
res.end();
}
test("HTTP/1.0 response with removed Content-Length is close-delimited, not chunked", async () => {
const { server, port } = await serve(removeContentLengthHandler);
try {
const res = parseResponse(await rawRequest(port, "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n"));
expect({
status: res.statusLine,
transferEncoding: res.headers["transfer-encoding"],
connection: res.headers.connection,
body: res.body,
}).toEqual({
status: "HTTP/1.1 200 OK",
transferEncoding: undefined,
connection: "close",
body: "hello world",
});
} finally {
server.close();
}
});
test("HTTP/1.0 request with TE: chunked still gets a well-formed response", async () => {
const { server, port } = await serve(removeContentLengthHandler);
try {
const res = parseResponse(await rawRequest(port, "GET / HTTP/1.0\r\nHost: localhost\r\nTE: chunked\r\n\r\n"));
// The native writer never chunk-frames HTTP/1.0 responses, so the header
// must not be advertised and the body goes out close-delimited.
expect({
transferEncoding: res.headers["transfer-encoding"],
connection: res.headers.connection,
body: res.body,
}).toEqual({
transferEncoding: undefined,
connection: "close",
body: "hello world",
});
} finally {
server.close();
}
});
test("HTTP/1.0 response with removed Content-Length, end(data) only", async () => {
const { server, port } = await serve((req, res) => {
res.removeHeader("Content-Length");
res.end("hello world");
});
try {
const res = parseResponse(await rawRequest(port, "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n"));
expect({
transferEncoding: res.headers["transfer-encoding"],
body: res.body,
}).toEqual({
transferEncoding: undefined,
body: "hello world",
});
} finally {
server.close();
}
});
test("HTTP/1.1 response with removed Content-Length stays chunked with valid framing", async () => {
const { server, port } = await serve(removeContentLengthHandler);
try {
const res = parseResponse(await rawRequest(port, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"));
expect(res.headers["transfer-encoding"]).toBe("chunked");
expect(decodeChunked(res.body)).toBe("hello world");
} finally {
server.close();
}
});