Skip to content

fix(server): answer HEAD like GET instead of 404 on published pages - #312

Open
ngocht wants to merge 1 commit into
CoreBunch:mainfrom
ngocht:fix/head-requests-on-published-pages
Open

fix(server): answer HEAD like GET instead of 404 on published pages#312
ngocht wants to merge 1 commit into
CoreBunch:mainfrom
ngocht:fix/head-requests-on-published-pages

Conversation

@ngocht

@ngocht ngocht commented Jul 30, 2026

Copy link
Copy Markdown

Fixes #306.

What changed

handleServerRequest now normalises HEAD to GET once, before walking the route table.

Every route handler gated on req.method !== 'GET', so a HEAD matched none of them and fell through to the dispatcher's terminal jsonResponse({ error: 'Not found' }, { status: 404 }). Published pages answered GET with 200 and HEAD with 404. /health and /admin were unaffected only because those two handlers never check the method, which is what made the symptom look selective.

RFC 9110 §9.3.2 defines HEAD as identical to GET except that the server must not send content, so the invariant belongs in the dispatcher rather than in each handler — that way it holds for routes added later too.

Two behaviours this relies on, both verified before writing the fix:

  • Bun.serve already drops the body from a HEAD response while preserving the headers a GET would have produced, content-length included. Handlers stay body-agnostic.
  • new Request(req, { method: 'GET' }) preserves the URL, cookies, and the synthetic x-bun-socket-ip header that stampSocketIp stamps at the Bun.serve boundary, so clientIp() attribution is unaffected.

It also settles the third behaviour the issue noted: /admin/api/cms/setup/status answered HEAD with 405 and now answers 200 like GET. Methods a route genuinely doesn't support still get their 405.

With the invariant centralised, the two handlers that carried their own && req.method !== 'HEAD' clause (tryServeMediaRedirect, tryServeRuntimePackage) no longer need it. Removed, so there is one rule: handlers gate on GET, the dispatcher guarantees a HEAD arrives as a GET.

Impact

  • Users/operators: uptime monitors and link checkers that probe with HEAD now see published pages as reachable. No configuration change, no migration.
  • Developers: new route handlers should gate on GET only. Documented in docs/server.md.

Verification

Tests were written first and observed failing with 404, 404, 405 — reproducing the issue — before the fix.

bun test          # 6318 pass, 1 skip, 0 fail (683 files)
bun run build     # tsc -b && vite build — clean
bun run lint      # clean

Wire-level check through a real Bun.serve using the actual dispatcher, mirroring the reproduction in the issue:

path GET HEAD HEAD body bytes
/health 200 200 0
/admin/api/cms/setup/status 200 200 (was 405) 0
/ 200 200 (was 404) 0
/kontakt 200 200 (was 404) 0
/does-not-exist 404 404 0

RFC 9110 §9.3.2 defines HEAD as identical to GET except that the server
must not send content. Every route handler in the dispatcher table gated
on `req.method !== 'GET'`, so a HEAD matched none of them and fell all
the way through to the terminal JSON 404 — published pages answered GET
with 200 and HEAD with 404 {"error":"Not found"}. Uptime monitors and
link checkers probe with HEAD by convention, so a healthy site read as
permanently down and every internal link read as broken.

Normalise HEAD to GET once in `handleServerRequest` rather than patching
each method-gated handler: that makes the guarantee hold for every route,
including ones added later, and it also settles the third behaviour the
issue noted — /admin/api/cms/setup/status answered HEAD with 405 and now
answers 200 like GET. Only genuinely unsupported methods still get a 405.

Dropping the body stays Bun.serve's job; it already emits HEAD responses
with the headers a GET would have produced, content-length included, and
no content. The rewritten Request keeps the URL, cookies, and the
synthetic x-bun-socket-ip header stamped at the Bun.serve boundary, so
clientIp() attribution is unaffected.

With the invariant centralised, the two handlers that carried their own
`&& req.method !== 'HEAD'` clause no longer need it — removed so there is
one rule: handlers gate on GET, the dispatcher guarantees HEAD arrives
as GET.

Fixes CoreBunch#306
@ngocht
ngocht marked this pull request as ready for review July 30, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Published pages return 404 to HEAD requests but 200 to GET

1 participant