Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
63632d0152 fix(http): use Transfer-Encoding: chunked when req.write() is called
When `req.write()` is used to send body data followed by `req.end()`,
Node.js sends `Transfer-Encoding: chunked`. Bun was eagerly materializing
the body chunks into a concrete value and sending `Content-Length` instead.

This fix tracks whether `req.write()` was explicitly called (vs the
internal write from `req.end(data)`). When explicit writes are detected
and no `Content-Length` header was set by the user, the body is sent as
a streaming async generator, which causes the HTTP layer to correctly
use `Transfer-Encoding: chunked`.

- `req.write() + req.end()` → Transfer-Encoding: chunked (matches Node.js)
- `req.end(data)` → Content-Length (matches Node.js)
- Explicit Content-Length header → preserved as-is

Fixes #23751

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 05:02:13 +00:00
2 changed files with 191 additions and 1 deletions

View File

@@ -93,10 +93,12 @@ function ClientRequest(input, options, cb) {
callback = undefined;
}
hasExplicitWrite = true;
return write_(chunk, encoding, callback);
};
let writeCount = 0;
let hasExplicitWrite = false;
let resolveNextChunk: ((end: boolean) => void) | undefined = _end => {};
const pushChunk = chunk => {
@@ -566,7 +568,22 @@ function ClientRequest(input, options, cb) {
this[kAbortController] ??= new AbortController();
this[kAbortController].signal.addEventListener("abort", onAbort, { once: true });
var body = this[kBodyChunks] && this[kBodyChunks].length > 1 ? new Blob(this[kBodyChunks]) : this[kBodyChunks]?.[0];
var body;
// Match Node.js behavior: when req.write() was explicitly called to send body data
// without an explicit Content-Length header, use a streaming body so that the HTTP
// layer sends Transfer-Encoding: chunked instead of Content-Length.
// When only req.end(data) is used (no prior req.write()), use a concrete body with
// Content-Length, which also matches Node.js behavior.
if (hasExplicitWrite && this[kBodyChunks]?.length > 0 && !this.getHeader("content-length")) {
const chunks = this[kBodyChunks];
body = async function* () {
for (const chunk of chunks) {
yield chunk;
}
};
} else {
body = this[kBodyChunks] && this[kBodyChunks].length > 1 ? new Blob(this[kBodyChunks]) : this[kBodyChunks]?.[0];
}
try {
startFetch(body);

View File

@@ -0,0 +1,173 @@
import { expect, test } from "bun:test";
import http from "node:http";
// https://github.com/oven-sh/bun/issues/23751
// When using req.write() followed by req.end(), Bun should send
// Transfer-Encoding: chunked instead of Content-Length, matching Node.js behavior.
test("http.request with req.write() uses Transfer-Encoding: chunked", async () => {
const { promise, resolve } = Promise.withResolvers<{
te: string | null;
cl: string | null;
body: string;
}>();
using server = Bun.serve({
port: 0,
async fetch(req) {
const body = await req.text();
resolve({
te: req.headers.get("transfer-encoding"),
cl: req.headers.get("content-length"),
body,
});
return new Response("OK");
},
});
const req = http.request(
{
hostname: server.hostname,
port: server.port,
path: "/",
method: "POST",
},
res => {
res.on("data", () => {});
res.on("end", () => {});
},
);
req.write("hello");
req.end();
const result = await promise;
expect(result.te).toBe("chunked");
expect(result.cl).toBeNull();
expect(result.body).toBe("hello");
});
test("http.request with req.write() multiple chunks uses Transfer-Encoding: chunked", async () => {
const { promise, resolve } = Promise.withResolvers<{
te: string | null;
cl: string | null;
body: string;
}>();
using server = Bun.serve({
port: 0,
async fetch(req) {
const body = await req.text();
resolve({
te: req.headers.get("transfer-encoding"),
cl: req.headers.get("content-length"),
body,
});
return new Response("OK");
},
});
const req = http.request(
{
hostname: server.hostname,
port: server.port,
path: "/",
method: "POST",
},
res => {
res.on("data", () => {});
res.on("end", () => {});
},
);
req.write("hello ");
req.write("world");
req.end();
const result = await promise;
expect(result.te).toBe("chunked");
expect(result.cl).toBeNull();
expect(result.body).toBe("hello world");
});
test("http.request with explicit Content-Length preserves it", async () => {
const { promise, resolve } = Promise.withResolvers<{
te: string | null;
cl: string | null;
body: string;
}>();
using server = Bun.serve({
port: 0,
async fetch(req) {
const body = await req.text();
resolve({
te: req.headers.get("transfer-encoding"),
cl: req.headers.get("content-length"),
body,
});
return new Response("OK");
},
});
const req = http.request(
{
hostname: server.hostname,
port: server.port,
path: "/",
method: "POST",
headers: {
"Content-Length": "5",
},
},
res => {
res.on("data", () => {});
res.on("end", () => {});
},
);
req.write("hello");
req.end();
const result = await promise;
expect(result.cl).toBe("5");
expect(result.te).toBeNull();
expect(result.body).toBe("hello");
});
test("http.request with req.end(data) and no req.write() uses Content-Length", async () => {
const { promise, resolve } = Promise.withResolvers<{
te: string | null;
cl: string | null;
body: string;
}>();
using server = Bun.serve({
port: 0,
async fetch(req) {
const body = await req.text();
resolve({
te: req.headers.get("transfer-encoding"),
cl: req.headers.get("content-length"),
body,
});
return new Response("OK");
},
});
const req = http.request(
{
hostname: server.hostname,
port: server.port,
path: "/",
method: "POST",
},
res => {
res.on("data", () => {});
res.on("end", () => {});
},
);
req.end("hello");
const result = await promise;
expect(result.cl).toBe("5");
expect(result.te).toBeNull();
expect(result.body).toBe("hello");
});