Skip to content

Bun.serve: treat empty header values as absent so auto Content-Type/Date are not duplicated - #35336

Open
robobun wants to merge 7 commits into
mainfrom
farm/c48b394b/serve-empty-header-dup
Open

Bun.serve: treat empty header values as absent so auto Content-Type/Date are not duplicated#35336
robobun wants to merge 7 commits into
mainfrom
farm/c48b394b/serve-empty-header-dup

Conversation

@robobun

@robobun robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Repro

import net from "node:net";
const s = Bun.serve({ port: 0, hostname: "127.0.0.1",
  fetch() { return new Response("x", { headers: { "content-type": "" } }); } });
const head = await new Promise<string>(res => { const c = net.connect(s.port, "127.0.0.1"); let b = "";
  c.on("connect", () => c.write("GET / HTTP/1.1\r\nHost: h\r\nConnection: close\r\n\r\n"));
  c.on("data", d => b += d.toString("latin1")); c.on("close", () => res(b.split("\r\n\r\n")[0])); });
console.log(head.split("\r\n").filter(l => /^content-type:/i.test(l)));
// [ "Content-Type: ", "content-type: text/plain;charset=utf-8" ]
s.stop(true);

RFC 9110 forbids duplicate Content-Type. The Date case inverts: { date: "" } emits a lone empty Date: and suppresses the auto Date, so the origin sends no valid Date at all. Same for " " / "\t", headers.set("content-type", ""), Response.json(v, { headers: { "content-type": "" } }), and the static/file-route siblings (static: { "/": new Response("x", { headers: { date: "" } }) }).

Cause

Two serializers write a FetchHeaders to the wire, and both let an empty value through while the presence check that gates the matching auto-header disagrees about whether empty means present:

  • Dynamic handler (writeFetchHeadersToUWSResponse, src/jsc/bindings/NodeHTTP.cpp): writes every header unconditionally and sets wrote-this-header state as it goes. render_metadata decides whether to append the auto content-type: via FetchHeaders::fast_get, which returns None for a zero-length value, so for content-type: "" the serializer writes the empty line and the auto value is appended as a second line. The Date check is key-presence, so the empty entry sets HTTP_WROTE_DATE_HEADER and only the empty line remains.
  • Static / file routes (StaticRoute::do_write_headers, FileRoute::write_headers): snapshot the FetchHeaders via from_fetch_headers/copyTo at registration time and replay the snapshot per request. The snapshot preserves empty entries, has_date is headers.get(b"date").is_some() (true for b""), and the replay loop writes every entry, so an empty Date: is written with the auto-Date suppressed.

Fix

Treat an empty value as absent at each serializer-layer site. FetchHeaders already strips leading/trailing HTTP whitespace, so isEmpty() / len == 0 covers whitespace-only too.

  • writeFetchHeadersToUWSResponse and its HTTP/3 sibling: continue past empty values in all three loops (Set-Cookie, common, uncommon) before any state bit is touched.
  • StaticRoute::do_write_headers, FileRoute::write_headers: skip zero-length values in the replay loop.
  • StaticRoute/FileRoute has_date* and the other header-presence bits: .is_some_and(|v| !v.is_empty()).
  • render_metadata's has_content_disposition / has_content_range and the user_handles_range check: gate on fast_get(...).is_some() so an empty user value does not both suppress the auto-header and be dropped by the serializer (a 206 must carry Content-Range).
  • The StaticRoute was-string path overwrites an empty Content-Type with text/plain at the Bun-specific static: registration call site rather than inside put_default, so Response.json(v, { headers: { "content-type": "" } }).headers.get("content-type") stays "" per the Fetch spec.

node:http's setHeader("content-type", "") still writes the single empty line it does today; that path serializes through the flat name/value array rather than FetchHeaders and is unchanged.

After:

HTTP/1.1 200 OK
content-type: text/plain;charset=utf-8
Date: Thu, 23 Jul 2026 23:55:41 GMT
Content-Length: 1

Verification

test/js/bun/http/bun-serve-headers.test.ts gains a describe block that reads the raw response head over a plain socket and asserts exactly one non-empty content-type / date line for "" and whitespace-only values, via the init dict, headers.set, Response.json, a static route, and a Bun.file() route; that an empty custom header and an empty set-cookie are dropped on both the dynamic and static paths; that an empty etag on a static route is dropped rather than written as an empty line; and that non-empty user values are unchanged. The Response.json case also asserts the Headers object itself keeps the empty value. 22 cases total; the behaviour-asserting ones fail on main and all pass with the fix. serve.test.ts, bun-serve-date.test.ts, bun-serve-routes.test.ts, bun-serve-file.test.ts, bun-serve-cookies.test.ts, and response.test.ts are unchanged.


[review] gate passed · iteration 2 · 5 files touched

fails on main (without fix)
ASAN without fix: 14 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 (9976fc858)

test/js/bun/http/bun-serve-headers.test.ts:
43 |     ["whitespace", "  \t "],
44 |   ] as const) {
45 |     test(`content-type: ${label}`, async () => {
46 |       const head = await rawHead(() => new Response("x", { headers: { "content-type": value } }));
47 |       const ct = lines(head, "content-type");
48 |       expect(ct).toHaveLength(1);
                      ^
error: expect(received).toHaveLength(expected)

Expected length: 1
Received length: 2

      at <anonymous> (/workspace/bun/test/js/bun/http/bun-serve-headers.test.ts:48:18)
(fail) empty header value does not duplicate auto-headers > content-type: empty [417.87ms]
52 |     test(`date: ${label}`, async () => {
53 |       const head = await rawHead(() => new Response("x", { headers: { date: value } }));
54 |       const date = lines(head, "date");
55 |       expect(date).toHaveLength(1);
56 |       // auto Date is a valid IMF-fixdate, never an empty value
57 |       expect(date[0]).toMatch(/^Date: \S/);
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (a0fef71f9)

test/js/bun/http/bun-serve-headers.test.ts:
(pass) empty header value does not duplicate auto-headers > content-type: empty [9.96ms]
(pass) empty header value does not duplicate auto-headers > date: empty [2.53ms]
(pass) empty header value does not duplicate auto-headers > content-type: whitespace [1.38ms]
(pass) empty header value does not duplicate auto-headers > date: whitespace [2.18ms]
(pass) empty header value does not duplicate auto-headers > headers.set('content-type', '') [1.60ms]
(pass) empty header value does not duplicate auto-headers > Response.json with empty content-type [1.61ms]
(pass) empty header value does not duplicate auto-headers > both empty at once: one of each [1.74ms]
(pass) empty header value does not duplicate auto-headers > static route: content-type empty [1.55ms]
(pass) empty header value does not duplicate auto-headers > static route: date empty [1.38ms]
(pass) empty header value does not duplicate auto-headers > static route: both empty [1.31ms]
(pass) empty header value does not duplicate auto-headers > static route: non-empty user values preserved [1.65ms]
(pass) empty header value does not dup
... (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 (9976fc858)

test/js/bun/http/bun-serve-headers.test.ts:
(pass) empty header value does not duplicate auto-headers > content-type: empty [425.30ms]
(pass) empty header value does not duplicate auto-headers > date: empty [69.87ms]
(pass) empty header value does not duplicate auto-headers > content-type: whitespace [48.33ms]
(pass) empty header value does not duplicate auto-headers > date: whitespace [45.70ms]
(pass) empty header value does not duplicate auto-headers > headers.set('content-type', '') [61.95ms]
(pass) empty header value does not duplicate auto-headers > Response.json with empty content-type [60.24ms]
(pass) empty header value does not duplicate auto-headers > both empty at once: one of each [55.64ms]
(pass) empty header value does not duplicate auto-headers > static route: content-type empty [62.60ms]
(pass) empty header value does not duplicate auto-headers > static route: date empty [45.33ms]
(pass) empty header value does not duplicate auto-headers > static rou
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 659ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[2/8] gen cpp.rs (cppbind)
[3/8] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[3/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m 
... (truncated)
diff hotspot
src/jsc/bindings/NodeHTTP.cpp              |  26 ++++-
 src/runtime/server/FileRoute.rs            |  33 ++++--
 src/runtime/server/RequestContext.rs       |  10 +-
 src/runtime/server/StaticRoute.rs          |  26 +++--
 test/js/bun/http/bun-serve-headers.test.ts | 177 +++++++++++++++++++++++++++++
 5 files changed, 251 insertions(+), 21 deletions(-)

gate history · 4 passed · 0 rejected · iteration 2

evidence per changed file
file                                        reads  edits  tests
src/jsc/bindings/NodeHTTP.cpp                   6      5      0
src/runtime/server/FileRoute.rs                 2      1      0
src/runtime/server/RequestContext.rs            3      1      0
src/runtime/server/StaticRoute.rs               8      5      0
test/js/bun/http/bun-serve-headers.test.ts      7     11      0

…ate are not duplicated

An empty or whitespace-only Content-Type on a Response was written to the
wire as 'Content-Type: ' and, because FetchHeaders.fast_get treats a
zero-length value as not present, render_metadata then appended the
auto-derived 'content-type: text/plain;charset=utf-8' as a second line.
The Date case inverted: the commonHeaders loop set the wrote-date flag on
key presence and emitted 'Date: ', so the auto Date was suppressed and
only the empty line remained.

Skip empty-valued common headers in writeFetchHeadersToUWSResponse and
its H3 sibling before any wrote-this-header state is touched. FetchHeaders
already normalises leading/trailing HTTP whitespace, so a single
isEmpty() check covers both '' and whitespace-only.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Empty header values are now treated as absent when applying response defaults and are skipped during HTTP/1, HTTP/3, file, and static response emission. Raw socket tests cover duplicate prevention, header omission, preserved values, and ETag generation.

Empty header semantics

Layer / File(s) Summary
Header value semantics
src/runtime/server/FileRoute.rs, src/runtime/server/StaticRoute.rs, src/runtime/server/RequestContext.rs
Cached default-header flags require non-empty values, empty ETags trigger generation, and range metadata presence uses header lookups.
Response header emission
src/jsc/bindings/NodeHTTP.cpp, src/runtime/server/FileRoute.rs, src/runtime/server/StaticRoute.rs
uWS, HTTP/3, file, and static response writers omit headers with empty values.
Header regression coverage
test/js/bun/http/bun-serve-headers.test.ts
Raw response-head tests cover dynamic, static, and file responses with empty, whitespace-only, non-empty, custom, cookie, and ETag headers.

Possibly related PRs

  • oven-sh/bun#34242: Both changes adjust Content-Range and related header handling in the server pipeline.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: treating empty header values as absent to avoid duplicated auto headers.
Description check ✅ Passed The description is detailed and covers the change and verification, though it uses custom headings instead of the template ones.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:12 PM PT - Jul 23rd, 2026

@robobun, your commit 9976fc8 has 1 failures in Build #79129 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35336

That installs a local version of the PR into your bun-35336 executable, so you can run:

bun-35336 --bun

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI: the only hard failure on the latest build (79129, at 9976fc8) is test/js/bun/spawn/spawn.test.ts (gcTick > spawn > pipe > hello > should allow reading stdout > before exit, EPIPE) on linux-x64 lanes. The same test is red on main's build for 43372bd (this branch's base), so it is a pre-existing main break unrelated to the header-serializer change here. The remaining annotations are retries that passed. bun-serve-headers.test.ts is green on every lane.

Comment thread src/jsc/bindings/NodeHTTP.cpp
robobun and others added 2 commits July 24, 2026 00:28
StaticRoute and FileRoute snapshot the Response's FetchHeaders through
from_fetch_headers/copyTo and replay the snapshot per request via their
own write loops, bypassing writeFetchHeadersToUWSResponse. An empty
user Date there still marked the wrote-date state and wrote 'Date: ' with
the auto-Date suppressed; an empty Content-Type was written verbatim.

- FetchHeaders::put_default now gates on fast_get().is_some() (empty is
  None) instead of fast_has, so Response.json and StaticRoute's
  was_string path overwrite an empty Content-Type with the real default.
- StaticRoute::do_write_headers and FileRoute::write_headers skip
  zero-length values.
- has_date / has_date_header (and the other FileRoute presence bits)
  treat an empty value as absent so the auto-header fires.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/http/bun-serve-headers.test.ts`:
- Around line 140-146: Add sibling FileRoute tests alongside “file route: date
empty” for empty and whitespace-only Last-Modified, Content-Length, and
Content-Range headers, including a Range request for Content-Range. Assert each
response uses the corresponding automatic fallback and covers every changed
FileRoute presence-flag state.
- Around line 21-28: Update both raw-head socket helpers in
test/js/bun/http/bun-serve-headers.test.ts at lines 21-28 and 94-101 so their
awaited response-read promises reject when socket emits an error, rather than
relying on the no-op error listener. Preserve data accumulation and close
resolution, wiring the socket failure event to promise rejection at both sites.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: be00d320-7492-4f29-bb1a-d00c06e1b0d7

📥 Commits

Reviewing files that changed from the base of the PR and between 43372bd and e572adc.

📒 Files selected for processing (5)
  • src/jsc/FetchHeaders.rs
  • src/jsc/bindings/NodeHTTP.cpp
  • src/runtime/server/FileRoute.rs
  • src/runtime/server/StaticRoute.rs
  • test/js/bun/http/bun-serve-headers.test.ts

Comment thread test/js/bun/http/bun-serve-headers.test.ts Outdated
Comment thread test/js/bun/http/bun-serve-headers.test.ts
Comment thread src/runtime/server/StaticRoute.rs
Comment thread test/js/bun/http/bun-serve-headers.test.ts
The snapshot that StaticRoute/FileRoute replay carries no common/uncommon
distinction (copyTo flattens both), so its value.length == 0 skip applies
to every entry. Apply the same skip to the uncommon loop in
writeFetchHeadersToUWSResponse and the H3 variant so an empty x-custom is
dropped on every Bun.serve response path rather than only on static
routes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/http/bun-serve-headers.test.ts`:
- Around line 149-157: The test named “empty custom header is dropped on both
paths” currently omits the file-backed FileRoute path. Extend its response
matrix using the existing temporary-file setup and FileRoute writer so it
exercises an empty “x-custom” header, then assert that header is absent while
preserving the existing content-type assertion across all paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 34b88a7f-9d64-4236-a5ea-acbbb790f05a

📥 Commits

Reviewing files that changed from the base of the PR and between e572adc and 3d70b96.

📒 Files selected for processing (3)
  • src/jsc/bindings/NodeHTTP.cpp
  • src/runtime/server/FileRoute.rs
  • test/js/bun/http/bun-serve-headers.test.ts

Comment thread test/js/bun/http/bun-serve-headers.test.ts
Comment thread src/jsc/FetchHeaders.rs Outdated
Comment thread src/jsc/bindings/NodeHTTP.cpp
Comment thread src/runtime/server/StaticRoute.rs
…ing siblings

FetchHeaders::put_default is called at Response construction time by
Response.json, so overwriting an empty Content-Type there is a
JS-observable Fetch-spec deviation (the spec's 'header list contains' is
key-presence; Node/Chrome/Firefox leave the empty value). Revert it to
fast_has and move the empty-as-absent check to the Bun-specific
StaticRoute was_string call site, where mutating the registered Response
is acceptable.

Close the remaining serializer-layer siblings:
- getSetCookieHeaders() loops in both writeFetchHeadersTo* variants: skip
  empty values so the dynamic and static paths agree.
- StaticRoute etag gate: treat empty as absent so the auto content-hash
  ETag is still generated.
- RequestContext render_metadata / user_handles_range: gate
  has_content_disposition / has_content_range on fast_get().is_some() so
  an empty user value does not both suppress the auto-header and be
  dropped by the serializer (a 206 must carry Content-Range).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/js/bun/http/bun-serve-headers.test.ts (1)

41-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use test.each for the content-type matrix.

This manually generated matrix should follow the repository’s Bun test convention so each variant remains a first-class, independently named test.

Proposed refactor
-  for (const [label, value] of [
+  test.each([
     ["empty", ""],
     ["whitespace", "  \t "],
-  ] as const) {
-    test(`content-type: ${label}`, async () => {
+  ] as const)("content-type: %s", async (label, value) => {
       const head = await rawHead(() => new Response("x", { headers: { "content-type": value } }));
       const ct = lines(head, "content-type");
       expect(ct).toHaveLength(1);
+  });

As per coding guidelines, tests should use test.each for matrices.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/http/bun-serve-headers.test.ts` around lines 41 - 45, Refactor
the content-type test matrix in the surrounding test block to use Bun’s
test.each convention instead of manually iterating with a for loop. Preserve the
existing “empty” and “whitespace” labels, values, assertions, and independently
named test cases.

Source: Coding guidelines

src/jsc/bindings/NodeHTTP.cpp (1)

1539-1572: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add HTTP/3 coverage for the empty-value header branches. serve-http3.test.ts exercises Date/Alt-Svc, but it does not drive writeFetchHeadersToH3Response through the empty Set-Cookie, common, and uncommon header paths. Add a focused H3 case for those empty values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/NodeHTTP.cpp` around lines 1539 - 1572, The HTTP/3 tests
lack coverage for empty header values in writeFetchHeadersToH3Response. Add a
focused case in serve-http3.test.ts that sends empty Set-Cookie, common, and
uncommon headers, then verifies the response handles them without writing those
headers while preserving normal H3 behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/NodeHTTP.cpp`:
- Around line 1539-1572: The HTTP/3 tests lack coverage for empty header values
in writeFetchHeadersToH3Response. Add a focused case in serve-http3.test.ts that
sends empty Set-Cookie, common, and uncommon headers, then verifies the response
handles them without writing those headers while preserving normal H3 behavior.

In `@test/js/bun/http/bun-serve-headers.test.ts`:
- Around line 41-45: Refactor the content-type test matrix in the surrounding
test block to use Bun’s test.each convention instead of manually iterating with
a for loop. Preserve the existing “empty” and “whitespace” labels, values,
assertions, and independently named test cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c6749658-a432-4c0f-b049-1435570c5133

📥 Commits

Reviewing files that changed from the base of the PR and between 3d70b96 and a0fef71.

📒 Files selected for processing (4)
  • src/jsc/bindings/NodeHTTP.cpp
  • src/runtime/server/RequestContext.rs
  • src/runtime/server/StaticRoute.rs
  • test/js/bun/http/bun-serve-headers.test.ts

Comment thread src/runtime/server/StaticRoute.rs Outdated
append_etag pushes a second entry via Headers::append, so an empty user
etag plus the auto content-hash leaves two snapshot entries.
do_write_headers skips the empty one so the wire looks right, but
render_precondition reads headers.get(b"etag") (first match, the empty
one) and filters it to None, so the served tag is never honoured for
If-None-Match/If-Match. Keep the original .is_none() gate: an empty user
etag is dropped by the write loop and the route simply has no ETag, which
is valid and consistent.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found after 9976fc8, but this touches the hot Bun.serve response-header serialization path across four writers (uWS/H3/StaticRoute/FileRoute) and makes a user-observable design choice — dropping all empty-valued headers on the wire, not just the auto-header ones — so a maintainer should sign off on the semantics.

What was reviewed: the six isEmpty()/value.length == 0 skips are placed before any state bits are set, so auto Date/Content-Length/TE flags stay unset for empty values; the fast_hasfast_get(...).is_some() swap in render_metadata/user_handles_range was traced through the 206/Content-Range logic and now correctly falls back to the auto Content-Range instead of emitting a 206 with none; the reverted etag gate keeps render_precondition consistent with the wire; the was_string StaticRoute override is scoped to the Bun-specific static: API and Response.json's spec-observable Headers are unchanged (test asserts ""); node:http's flat-array path is untouched.

Extended reasoning...

Overview

The PR fixes an RFC 9110 violation in Bun.serve: a user-supplied empty header value (content-type: "", date: "", whitespace-only) was written to the wire and the matching auto-header was appended (or, for Date, the empty line suppressed the auto-Date entirely). The fix treats empty values as absent at each of the three response-header serializers — writeFetchHeadersToUWSResponse / writeFetchHeadersToH3Response (NodeHTTP.cpp), StaticRoute::do_write_headers, and FileRoute::write_headers — and converts the presence checks that gate each auto-header (has_date, has_content_range, has_content_disposition, the four FileRoute has_* bits, and user_handles_range) from key-presence to value-non-empty. 22 new raw-socket test cases cover the dynamic/static/file paths, "" and whitespace-only, headers.set, Response.json, custom headers, Set-Cookie, and non-empty preservation.

Security risks

None identified. This is output-side header serialization; the empty-value skip cannot inject or reorder bytes, and no untrusted input is parsed differently. The Content-Range change avoids emitting a 206 without a Content-Range header (which the pre-PR code combined with the new serializer skip would have produced).

Level of scrutiny

High. Bun.serve's header writer is a per-request hot path with wide blast radius, and the change is a user-observable behavioral shift: after 3d70b96/a0fef71f, every empty-valued header — including custom ones and Set-Cookie — is dropped from the wire, not just the ones that collide with an auto-header. That was a deliberate consistency decision (dynamic and static/file paths now agree) reached during review, but it is a design call a maintainer should ratify. The PR went through five fix commits addressing sibling sites, a Fetch-spec deviation in put_default (reverted), a missed Set-Cookie loop, and an ETag/precondition regression (reverted in 9976fc8) — each round exposed a subtle interaction, which is exactly the profile that benefits from a human read.

Other factors

All seven prior inline findings (from me and CodeRabbit) are resolved. The final diff keeps put_default on fast_has (spec-compliant Response.json Headers), scopes the empty-CT overwrite to the Bun-specific StaticRoute was_string registration path, and leaves the etag gate on .is_none() so render_precondition and the wire agree. The remaining known gap — a Blob-body static route with content-type: "" ends up with no CT rather than the blob's type — was explicitly scoped out (touching from_fetch_headers also affects the outgoing fetch() client) and is strictly better than the pre-PR lone empty line. Test coverage is thorough and follows the harness conventions (port: 0, using, tempDir, error wired to reject).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants