Bun.serve: drop Content-Encoding when serving a fetch()-decoded Response

Closed
RO
16d ago
+193-5

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