Conversation
…l one
WebCore__FetchHeaders__put converted the BunString with toWTFString(),
which yields the null WTF::String for an empty value. HTTPHeaderMap
stored it, so has() reported the header while get() returned null and
iteration skipped the entry. Normalize the null String to emptyString()
before calling FetchHeaders::set, so every put caller (Response.redirect
with "" or no argument, Response.render in the dev server) stores the
same empty value that headers.set("location", "") does.
|
Warning Review limit reached
Next review available in: 6 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 3:05 PM PT - Aug 12th, 2026
❌ @robobun, your commit fd69355 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 37893That installs a local version of the PR into your bun-37893 --bun |
|
Status: fix pushed, waiting on CI. Automated review raised no concerns; a second review pass is in progress and anything it turns up will be addressed here. Reproduced on bun 1.4.0 with |
There was a problem hiding this comment.
LGTM — a targeted 4-line normalization at the FFI boundary, following the same isNull() → emptyString() pattern already used elsewhere in the bindings.
What was reviewed:
- Confirmed
BunString::toWTFString()maps theEmptytag to a nullWTF::String, and that the fix mirrors the existingtag == Empty ? emptyString() : toWTFString()idiom atbindings.cpp:4210,bindings.cpp:4601,BunString.cpp:909/944. - Audited all
FetchHeaders::put/put_defaultcallers on the Rust side: theContentTypesites already guard on!is_empty(), so only the twoLocationwriters (Response.redirectandBakeResponserender/redirect) reach this path with an empty value — matches the PR description. - Tests cover every
Response.redirectarity plusclone(), and exercisehas/get/iteration/forEach/copy against thenew Headers({ location: "" })reference; snapshot bump is the expected self-referential file-size change.
Extended reasoning...
Overview
The PR fixes an inconsistency where Response.redirect("") produces a Headers object whose Location entry is visible to has() but returns null from get() and is skipped by iteration. The root cause is that WebCore__FetchHeaders__put converts the incoming BunString with toWTFString(), which maps the Empty tag to a null WTF::String; HTTPHeaderMap then stores a null value that contains() treats as present but get() and the iterator treat as absent. The fix inserts a single null-to-empty normalization before FetchHeaders::set, and adds a test.each covering every Response.redirect arity (plus clone()) that reaches this path.
Security risks
None. This is a header-value normalization inside an internal C++/Rust binding that is only reached by Bun's own Response.redirect / Response.render / content-type paths, not by user JS (the JS-facing Headers.set goes through WebIDL and already produces non-null strings). The wire behavior (toUWSResponse) already emitted Location: for this case, so no new response content is exposed — only the in-memory Headers object is brought into agreement.
Level of scrutiny
Low-to-medium. The C++ change is 4 lines and is a direct instance of an idiom already used at four other sites in the same bindings layer (key->tag == BunStringTag::Empty ? WTF::emptyString() : key->toWTFString()). Using value.isNull() after conversion is equivalent and slightly more robust (also covers a Dead tag). No allocation, ownership, or exception-scope changes; the existing throwScope / propagateException structure is preserved.
Other factors
I audited every HTTPHeaderName::* put/put_default caller in src/runtime/webcore/ and src/runtime/server/: all ContentType writers gate on !content_type.is_empty(), and the S3-response Location writer uses a non-empty resolved URL, so the only behavioral change lands on Response.redirect("")/Response.redirect() and the dev-server Response.render("") — exactly as the PR description claims. The design decision to store "" rather than throw is consistent with Bun's documented acceptance of non-absolute redirect targets (Response.redirect("/login")), so no new API surface is introduced. Test coverage is strong: 7 parametrized cases asserting through five independent read paths, plus an equality check against new Headers({ location: "" }), all stated to fail on the released binary. The print size snapshot bump is the standard consequence of this file's self-referential size test.
Problem
Response.redirect("")(orResponse.redirect()) gives headers wherehas("location")is true butget("location")isnulland iteration,forEachandnew Headers(h)skip it.new Headers({ location: "" })reads back as""everywhere.putpath stores an empty value as WTF's null string, and the header map reads a null value as "absent" inget()and iteration but not inhas().putis affected; the JSset/append/ constructor paths go through WebIDL and never produce a null string. Predates Response.redirect: parse and serialize the url into the Location header #33126.Fix
putswaps a null conversion result for the empty string before storing. Otherputcallers skip empty values or never pass one, so onlyResponse.redirectand the dev server'sResponse.render("")change.Response.redirect("").headersnow equalsnew Headers({ location: "" }). The wire already sent an emptyLocation:, so only the in-memory object changes.""rather than throwing follows Bun's rule of keeping non-absolute redirect targets verbatim; Node throws here, but also throws forResponse.redirect("/login"), which Bun accepts.Background
Headersis WebKit'sFetchHeadersover anHTTPHeaderMap, which treats a null value as "absent" inget()and its iterator whilecontains()(behindhas()) still finds the entry.WTF::Stringhas a null string distinct from the empty string; both have length 0, onlyisNull()differs.BunStringis the string type crossing the Zig/C++ boundary. Its oneEmptytag converts to the nullWTF::String.WebCore__FetchHeaders__putis the binding Zig uses to set a header directly, bypassing the WebIDL conversion that JSheaders.set()gets.Original description
Reproduction
size()andhas()say the header exists, butget()returnsnullandentries()/forEach/new Headers(h)never yield it.headers.set("location", ""),new Headers({ location: "" })andResponse.redirect(" ")(trimmed to empty) all produce a Headers object that reads back as""everywhere.Cause
WebCore__FetchHeaders__put(bindings.cpp) converts the value withBunString::toWTFString(). ABunStringhas exactly one representation of the empty string, theEmptytag, andtoWTFString()maps it to the nullWTF::String.FetchHeaders::setstores it as-is, andHTTPHeaderMapuses a null value to mean "no such header" inget()and inFetchHeaders::Iterator::next(), whilecontains()still finds the entry.putis the only way aBunStringreaches the header map; the JS-facingset/append/ constructor paths go through WebIDL conversion and always get a non-null string, which is whyheaders.set("location", "")works.This predates #33126: the previous
Zig::toStringCopyon a zero-lengthZigStringreturned the null String as well.Fix
The binding turns a null conversion result into
WTF::emptyString()before callingFetchHeaders::set. That covers everyputcaller with an empty value (Response.redirect(""),Response.redirect()andResponse.render("")in the dev server); the content-type callers already skip empty values and the S3 Location is never empty, so nothing else changes.Storing
""rather than throwing is the consistent choice for Bun: non-absolute redirect targets are documented to be stored verbatim, and""is just the degenerate one. Node throws aTypeErrorhere, but it throws forResponse.redirect("/login")for the same reason, which Bun deliberately accepts, so rejecting only""would be an arbitrary carve-out. The response on the wire already sentLocation:with an empty value before this change (toUWSResponsewrites the entry), so this only makes the in-memory Headers object agree with what is sent and with every other way of producing the same header.Tests
test/js/web/fetch/response.test.ts: eachResponse.redirectarity with an empty url (plus no argument, andclone()) is checked throughhas(),get(), iteration,forEachand copying into a newHeaders, and compared againstnew Headers({ location: "" }). All 7 new cases fail on the released binary and pass with this change. The self-referentialprint sizesnapshot in the same file is bumped for the added bytes, as in earlier changes to this file.Also ran
headers.test.ts,fetch_headers.test.js,headers-case.test.ts,headers.undici.test.tsandtest/bake/dev/react-response.test.ts(coversResponse.render/Response.redirectin the dev server) against the debug build with no failures.