Skip to content

Bun.serve: serve HEAD requests with the GET handler in per-method route objects - #32822

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/f10496aa/serve-head-per-method-routes
Jun 28, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
farm/f10496aa/serve-head-per-method-routes

Conversation

@robobun

@robobun robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator

A routes value of the per-method object form never answers HEAD unless a HEAD key is spelled out. A HEAD request to that path is served by whatever matches next, so HEAD and GET on the same URL return different representations, or it 404s when nothing else matches.

Repro

using srv = Bun.serve({
  port: 0,
  routes: {
    "/m": { GET: () => new Response("hello-get") },
    "/*": () => new Response("from-catch-all"),
  },
});
// GET  /m -> 200, Content-Length: 9  ("hello-get")
// HEAD /m -> 200, Content-Length: 14 (the "/*" route's "from-catch-all")
// ...and with no "/*" route, HEAD /m -> 404

RFC 9110 section 9.3.2 requires HEAD to return the same header fields a GET of the same target would. Every other Bun.serve route form already derives HEAD from GET: plain function routes (registered for any method), static Response / Bun.file routes (which register a dedicated HEAD handler), and the fetch fallback. Only the per-method object form is missing it, and an intermediary that caches based on a HEAD probe gets a different answer than GET.

Cause

Three sites, same class. Note that HttpRouter::add removes an existing handler for the same method, pattern, and priority before inserting, so the last registration for a method and path wins, and set_routes registers static routes after user routes.

  1. ServerConfig::from_js turns each key of a per-method route object into a user route registered for that one method only. Nothing registers the path under HEAD, so the router never matches it.
  2. apply_static_route registered a HEAD handler for every static entry regardless of its declared methods, so a static Response under a non-GET key both answers HEAD on its own and captures HEAD away from a sibling GET handler:
using a = Bun.serve({ port: 0, routes: {
  "/m": { GET: () => new Response("hello-get"), POST: new Response("static-post-response") },
}});
// GET  /m -> Content-Length: 9
// HEAD /m -> Content-Length: 20   (the POST response's framing)

using b = Bun.serve({ port: 0, routes: { "/p": { POST: new Response("p") } } });
// GET /p -> 404,  HEAD /p -> 200
  1. For the same reason, a static Response under the GET key silently displaced an explicit callable HEAD handler on the same path:
using c = Bun.serve({ port: 0, routes: {
  "/m": { GET: new Response("g"), HEAD: () => new Response(null, { headers: { "x-h": "1" } }) },
}});
// HEAD /m -> Content-Length: 1, no x-h header; the HEAD handler never runs

Fix

  • When a per-method route object has a callable GET handler and no HEAD entry, register the GET handler under HEAD as well. The request context is created with method == HEAD, so the existing HEAD rendering path strips the body and reports the Content-Length GET would have produced; the handler observes req.method === "HEAD", the same as a plain function route does today.
  • apply_static_route (and its HTTP/3 twin) register the implicit HEAD handler only for an entry that serves any method, GET, or HEAD, and never for a path that already has an explicit HEAD handler route. A static Response under a non-GET, non-HEAD key no longer answers or captures HEAD, and a static GET Response no longer displaces a declared HEAD handler.

With that, an explicit HEAD key, handler or static Response, always takes precedence, and other methods are unchanged: they still fall through to later routes, which is how per-method routes compose with a "/*" catch-all.

Verification

New describe("implicit HEAD for per-method route objects") in test/js/bun/http/bun-serve-routes.test.ts with nine tests. Six fail on the unmodified build:

  • HEAD falls through to the "/*" catch-all instead of the GET handler
  • HEAD 404s when there is no catch-all
  • the req.method and Content-Length the GET handler observes
  • { GET: handler, POST: new Response(...) } answers HEAD with the POST response's framing
  • { POST: new Response(...) } answers HEAD at all
  • { GET: new Response(...), HEAD: handler } drops the explicit HEAD handler

The other three are positive controls: an explicit HEAD handler over a GET handler, an explicit static HEAD Response, and a route object with no GET handler.

bun-serve-routes.test.ts (50), bun-serve-static.test.ts (34), serve-http3.test.ts (45), bun-serve-file.test.ts (66), serve-if-none-match.test.ts (17), and bun-serve-html-manifest.test.ts (4) all pass. serve.test.ts and bun-server.test.ts have the same failures as the unmodified build (environment dependent: IPv6, privileged ports).

Related: #32800 fixes HEAD response framing (what goes on the wire for a HEAD response). This fixes HEAD route dispatch (which handler a HEAD request reaches). The two change disjoint files. #32823 fixes the route-parameter percent-decoder, reported together with this but independent.

A route value of the form `{ GET: handler }` only registers the declared
method in the router, so a HEAD request to that path falls through to the
next matching route (returning a different representation than GET) or
404s when nothing else matches. Every other route form already derives
HEAD from GET.

When a per-method route object has a GET handler and no HEAD entry,
register the GET handler under HEAD as well. The existing HEAD response
path then strips the body and reports the Content-Length a GET would have
sent (RFC 9110 section 9.3.2). An explicit HEAD handler still takes
precedence, and other methods still fall through to later routes.
@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@autofix-ci[bot], we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 27 minutes and 11 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 credits.

🚦 How do rate 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 see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4d421fda-1e03-4d5c-987c-5f3f8d93aa08

📥 Commits

Reviewing files that changed from the base of the PR and between 6aebd64 and 8af6df7.

📒 Files selected for processing (3)
  • src/runtime/server/ServerConfig.rs
  • src/runtime/server/mod.rs
  • test/js/bun/http/bun-serve-routes.test.ts

Walkthrough

The PR changes server routing so HEAD is only registered where supported, derives implicit HEAD routes from GET in route-object parsing, and adds tests for explicit, implicit, and missing-GET HEAD behavior.

Changes

Implicit HEAD routing

Layer / File(s) Summary
Static HEAD registration
src/runtime/server/ServerConfig.rs
apply_static_route and apply_static_route_h3 now register HEAD conditionally, using serves_head(&method) to decide when a route should answer HEAD.
Derived HEAD routes
src/runtime/server/ServerConfig.rs
Route-object parsing tracks whether HEAD was explicit, derives a synthetic HEAD builder from GET when needed, and skips derivation when HEAD already exists.
HEAD route tests
test/js/bun/http/bun-serve-routes.test.ts
Adds coverage for implicit HEAD, explicit HEAD precedence, fallthrough, request method observation, and non-GET routes returning 404 for HEAD.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: implicit HEAD handling for GET handlers in per-method route objects.
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.
Description check ✅ Passed The description clearly explains the change and includes a thorough verification section, even though it doesn’t use the exact template headings.

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

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:26 AM PT - Jun 27th, 2026

❌ @autofix-ci[bot], your commit 8af6df7 has 2 failures in Build #65416 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32822

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

bun-32822 --bun

@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.

I didn't find any issues — the logic and tests look correct — but this is a user-visible routing behavior change in Bun.serve, so it's worth a maintainer confirming the semantics (e.g. that the GET handler should observe req.method === "HEAD" rather than having the method rewritten).

Extended reasoning...

Overview

This PR modifies ServerConfig::from_js in src/runtime/server/ServerConfig.rs so that when a per-method route object ({ GET: handler, ... }) defines a callable GET handler but no HEAD entry, an implicit HEAD user route is registered that shares the GET callback. It also adds a new describe block with five tests in test/js/bun/http/bun-serve-routes.test.ts covering fall-through, 404, observed method, explicit-HEAD precedence, and no-GET cases.

The implementation is small (~30 lines net): it hoists with_async_context_if_needed into a local callback (JSValue is Copy), tracks derived_head_route and has_head_route while iterating the fixed METHODS array (GET precedes HEAD in iteration order, so ordering is correct), and pushes the derived builder after the loop only if no explicit HEAD was found. has_head_route is set for both callable HEAD handlers and static-Response HEAD entries, so explicit HEAD always wins. If the derived route goes unused, its Strong is dropped normally. Static (non-callable) GET entries are intentionally not handled here because apply_static_route already registers a HEAD handler unconditionally.

Security risks

None identified. The change only adds a route registration that reuses an already-trusted user-provided handler; it doesn't touch parsing, auth, TLS, or request body handling. If anything it improves correctness for intermediaries that probe with HEAD.

Level of scrutiny

Medium. The diff is mechanically simple and well-tested, but it changes user-visible dispatch semantics in Bun.serve, a production-critical API. Previously HEAD /m on { GET: ... } fell through to the next route or 404'd; now it invokes the GET handler with req.method === "HEAD". That's RFC-9110-correct and consistent with every other Bun.serve route form, but it is a behavior change a maintainer should sign off on — particularly the choice to surface "HEAD" to the handler (matching plain-function routes) rather than transparently rewriting to "GET".

Other factors

No CODEOWNERS entries cover these paths. No prior human review comments. The bug-hunting system found nothing. The PR description is thorough and the test suite results reported there are consistent with the change being correct. I'm deferring solely because this is a semantic change to core routing, not because of any concern with the implementation.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

On the req.method === "HEAD" question: that matches what every other route form already does. A plain function route ("/m": handler) is registered for any method, so a HEAD request reaches it with req.method === "HEAD" and the body is stripped afterward. The per-method object form now behaves the same way instead of being the one exception.

Rewriting the method to "GET" would also defeat the body strip, since the HEAD rendering path keys off the request context's method. And a handler that wants to skip building an expensive body for HEAD can only do that if req.method tells the truth.

The "the GET handler observes the real request method" test locks the choice in.

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green on every lane that actually runs.

Across every build of this PR (65334, 65395, 65402, 65416), the only failing job is :darwin: 26 aarch64 - test-bun, and in every case it fails before the test runner starts:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. Refusing to continue with a partial download

The same job fails the same way on unrelated branches, and everything else (280+ jobs per build) passes. This is the macOS agent pool, not this change; retrying that one job from Buildkite once the agents can download artifacts again is all that is needed.

Comment thread src/runtime/server/ServerConfig.rs
Comment thread src/runtime/server/ServerConfig.rs
apply_static_route registered a HEAD handler for every static route
entry regardless of the methods it was declared for. In a per-method
route object that mixes a GET handler with a static Response under a
different key, that HEAD handler is registered after the user routes
and replaces them in the router, so HEAD returned the other method's
representation instead of GET's. A static Response under a single
non-GET key also answered HEAD on its own.

Register the static HEAD handler only when the entry serves any method,
GET, or HEAD itself, in both the HTTP/1 and HTTP/3 registration paths.
Comment thread src/runtime/server/ServerConfig.rs
robobun and others added 2 commits June 27, 2026 08:53
…dler

apply_static_route registers its HEAD handler after the user routes, and
uWS keeps the last registration for a method and path, so a per-method
route object with a static GET Response and a callable HEAD handler
served HEAD from the static GET entry instead of the declared handler.

Skip the static entry's HEAD registration when a HEAD handler route
already exists for the path.
@Jarred-Sumner
Jarred-Sumner merged commit 2c53f80 into main Jun 28, 2026
77 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/f10496aa/serve-head-per-method-routes branch June 28, 2026 01:49
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