Bun.serve: drop Content-Encoding when serving a fetch()-decoded Response
What
The return fetch(upstream) reverse-proxy one-liner served undecodable responses for any upstream that compresses:
Bun.serve({ fetch: req => fetch(upstream + new URL(req.url).pathname) });
fetch() decodes the gzip/deflate/br/zstd body but keeps Content-Encoding in the Response headers (intentionally, per #5668). Bun.serve then recomputes Content-Length from the decoded bytes while forwarding Content-Encoding verbatim, so clients receive e.g. Content-Encoding: gzip + Content-Length: 2700 + 2700 bytes of plaintext, and every client that honours the header fails to decompress (ZlibError in Bun's own fetch, ERR_CONTENT_DECODING_FAILED in browsers, curl --compressed errors).
Since Bun's fetch always sends Accept-Encoding: gzip, deflate, br, zstd, any compressing upstream (i.e. most of them) was unreadable through the naive proxy.
Fix
The HTTP client records on the cloned response metadata whether it will decode the body (HTTPResponseMetadata::did_decompress, derived from state.encoding.is_compressed()). FetchTasklet::to_response copies that onto the new Response::body_decoded flag. When Bun.serve writes such a Response, do_write_headers now strips Content-Encoding alongside the Content-Length/Transfer-Encoding it already drops.
Unaffected:
response.headers.get("content-encoding")on a fetch Response still returns the upstream value (#5668).- A handler-built
new Response(gzippedBytes, { headers: { "content-encoding": "gzip" } })is served as-is. fetch(url, { decompress: false })forwards the compressed bytes with the header intact.
Verification
test/js/bun/http/bun-serve-headers.test.ts gains a proxied fetch() response drops decoded Content-Encoding suite: a raw-socket client asserts the wire head carries no Content-Encoding and the body is the decoded payload, for each of gzip/deflate/br/zstd, plus the two negative cases above. Fails on main with the stale header on the wire; passes with this change. The existing presence of content-encoding header(issue #5668) test in fetch.test.ts continues to pass.
[review] gate passed · iteration 0 · 5 files touched
fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-headers.test.ts
bun test v1.4.0 (dc1ce9469)
test/js/bun/http/bun-serve-headers.test.ts:
(pass) weird headers [572.50ms]
(pass) response Connection: close closes the socket > string body [399.96ms]
(pass) response Connection: close closes the socket > case-insensitive value [61.28ms]
(pass) response Connection: close closes the socket > token list [54.42ms]
(pass) response Connection: close closes the socket > streaming body [62.06ms]
(pass) response Connection: close closes the socket > keep-alive still the default [76.18ms]
224 | return res;
225 | },
226 | });
227 |
228 | const { head, body } = await rawGet(proxy.port, payload.length);
229 | expect(head.toLowerCase()).not.toContain("content-encoding");
^
error: expect(received).not.toContain(expected)
Expected to not contain: "content-encoding"
Received: "http/1.1 200 ok\r\ncontent-encoding: gzip\r\ncontent-type: application/octet-stream\r\ndate: sat, 25 jul 2026 04:12:54 gmt\r
... (truncated)
release without fix: 8 FAILED
bun test v1.4.0-canary.1 (1498d7b77)
test/js/bun/http/bun-serve-headers.test.ts:
(pass) weird headers [10.18ms]
84 | });
85 |
86 | const responses = (result.raw.match(/HTTP\/1\.1 200/g) ?? []).length;
87 | const head = result.raw.split("\r\n\r\n")[0];
88 | expect(head).toMatch(/\r\nconnection:[^\r\n]*\bclose\b/i);
89 | expect({ responses, handled, closedByServer: result.closedByServer }).toEqual({
^
error: expect(received).toEqual(expected)
{
- "closedByServer": true,
- "handled": 1,
- "responses": 1,
+ "closedByServer": false,
+ "handled": 2,
+ "responses": 2,
}
- Expected - 3
+ Received + 3
at check (/workspace/bun/test/js/bun/http/bun-serve-headers.test.ts:89:77)
at async <anonymous> (/workspace/bun/test/js/bun/http/bun-serve-headers.test.ts:100:11)
(fail) response Connection: close closes the socket > string body [7.64ms]
84 | });
85 |
86 | const responses = (result.raw.match(/HTTP\/1\.1 200/g) ?? []).length;
87 | const head = result.raw.split("\r\n\r\n")[0];
88 | expect(head).toMatch(/\r\nconn
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-headers.test.ts
bun test v1.4.0 (dc1ce9469)
test/js/bun/http/bun-serve-headers.test.ts:
(pass) weird headers [541.44ms]
(pass) response Connection: close closes the socket > string body [401.68ms]
(pass) response Connection: close closes the socket > case-insensitive value [68.63ms]
(pass) response Connection: close closes the socket > token list [61.07ms]
(pass) response Connection: close closes the socket > streaming body [78.86ms]
(pass) response Connection: close closes the socket > keep-alive still the default [74.74ms]
(pass) proxied fetch() response drops decoded Content-Encoding > gzip [99.69ms]
(pass) proxied fetch() response drops decoded Content-Encoding > deflate [41.09ms]
(pass) proxied fetch() response drops decoded Content-Encoding > br [37.11ms]
(pass) proxied fetch() response drops decoded Content-Encoding > zstd [40.76ms]
(pass) proxied fetch() response drops decoded Content-Encoding > explicit Content-Encoding on a handler-built Response is preserved [40.73ms]
(pass) proxied fetch() respon
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
target linux-x64-gnu
build type Release
build dir ./build/release
revision dc1ce94699
features baseline
22 deps, 108 codegen, 1171 objects in 2931ms
ninja: Entering directory `/workspace/bun/build/release'
[1/1234] gen ErrorCode+*.h
[2/1234] gen bindgenv2
[3/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[4/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[5/1234] fetch zlib
[zlib] up to date
[6/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1234] fetch tinycc
[tinycc] up to date
[8/1234] fetch picohttpparser
[picohttpparser] up to date
[9/1234] gen .bind.ts → GeneratedBindings.cpp
[10/1234] subst deps/zlib/zconf.h
[11/1234] subst deps/zlib/zlib.h
[12/1234] gen JSEvent.lut.h
Generating /workspace/bun/build/release/codegen/JSEvent.lut.h from /workspace/bun/src/jsc/bindings/webcore/JSEvent.cpp
[13/1234] fetch brotli
[brotl
... (truncated)
diff hotspot
src/http/lib.rs | 5 ++
src/runtime/server/RequestContext.rs | 11 ++-
src/runtime/webcore/Response.rs | 16 ++++
src/runtime/webcore/fetch/FetchTasklet.rs | 7 +-
test/js/bun/http/bun-serve-headers.test.ts | 115 +++++++++++++++++++++++++++++
5 files changed, 149 insertions(+), 5 deletions(-)
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
file reads edits tests
src/http/lib.rs 4 3 0
src/runtime/server/RequestContext.rs 3 4 0
src/runtime/webcore/Response.rs 3 4 0
src/runtime/webcore/fetch/FetchTasklet.rs 2 2 0
test/js/bun/http/bun-serve-headers.test.ts 2 8 0