Skip to content

feat: automatically match GET routes for HEAD requests#1452

Merged
pi0 merged 4 commits into
mainfrom
feat/head-fallback
Jul 10, 2026
Merged

feat: automatically match GET routes for HEAD requests#1452
pi0 merged 4 commits into
mainfrom
feat/head-fallback

Conversation

@pi0x

@pi0x pi0x commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements #1451. HEAD requests now automatically fall back to the matching GET route when no explicit HEAD (or all()) route is registered, following RFC 9110. This matches Express (implicit) and Fastify (exposeHeadRoutes: true) semantics, so users no longer hit surprise 404s from load balancer health checks, link checkers, or CDNs probing resources.

The request method is not rewritten — event.req.method stays "HEAD", so the existing nullBody() logic in prepareResponse strips the body while the GET handler produces correct headers.

Changes

  • src/h3.ts~findRoute falls back to the GET route on a HEAD miss. Zero cost on non-HEAD traffic (the extra lookup only runs on a HEAD route miss). Explicit head() routes still take precedence.
  • src/response.ts — the raw-Response fast path now strips the body for HEAD requests for self-consistency (runtimes discard HEAD bodies at the server layer anyway, but this keeps h3 internally consistent).
  • src/middleware.ts — GET-method-scoped global middleware (app.use(fn, { method: "GET" })) now also runs for HEAD requests, mirroring the routing fallback.
  • test/router.test.ts — new HEAD fallback block (via describeMatrix, runs web + node): GET fallback with empty body & preserved headers, explicit head() precedence, 404 when no GET route, raw-Response body stripping, all() route, route-level middleware, and content-length preservation.
  • test/middleware.test.ts — GET-scoped global middleware runs for HEAD.
  • docs/1.guide/1.basics/2.routing.md — new "HEAD Requests" section.
  • test/bench/bundle.test.ts — bumped bundle-size thresholds to accommodate the feature.

Ecosystem alignment

Validated the behavior against three other frameworks (live repro + source inspection). All confirm both design decisions in this PR:

Behavior Express Hono Elysia This PR
HEAD auto-falls back to GET (200 + empty body)
Explicit head() route takes precedence ❌ (rewrites pre-routing)
GET-scoped middleware runs for HEAD
Content-Length preserved for HEAD

h3 matches the Express/Elysia model (fallback only on a HEAD miss, so explicit head() remains a usable escape hatch), rather than Hono's rewrite-before-routing model where an explicit head() route is unreachable.

Trade-offs (accepted, per the issue)

  • The GET handler computes the full body which is then discarded — the correct default (accurate headers matter more); explicit head() registration remains the optimization path.

Test plan

  • pnpm vitest run test/router.test.ts test/middleware.test.ts — pass; confirmed the new tests fail before their respective fixes.
  • pnpm test — full suite green (1341 passed, 23 skipped, no type errors).

Closes #1451

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added automatic HEAD support by falling back to matching GET routes while omitting the response body.
    • Explicit HEAD handlers override the GET fallback.
  • Bug Fixes
    • Unmatched HEAD requests now return 404.
    • Preserves status and headers for HEAD, including content-length (even when GET returns a raw response).
    • GET-scoped middleware now also applies to HEAD.
  • Documentation
    • Updated the routing basics guide with a new “HEAD Requests” section and examples.
  • Tests
    • Added comprehensive HEAD fallback coverage, including middleware and all() routes.
  • Chores
    • Tightened bundle size benchmark thresholds.

HEAD requests now fall back to the matching GET route when no explicit
HEAD (or all()) route is registered, following RFC 9110. The request
method stays "HEAD", so the existing nullBody() logic strips the response
body while the GET handler produces correct headers.

Explicit head() routes still take precedence, and the raw-Response fast
path now strips the body for HEAD requests for self-consistency.

Closes #1451

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@pi0x
pi0x requested a review from pi0 as a code owner July 10, 2026 07:23
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

HEAD request fallback

Layer / File(s) Summary
Routing, middleware, and response handling
src/h3.ts, src/middleware.ts, src/response.ts
Unmatched HEAD requests retry GET route matching, GET-scoped middleware also matches HEAD, and responses preserve metadata while omitting bodies.
Behavior coverage and documentation
test/router.test.ts, test/middleware.test.ts, docs/1.guide/1.basics/2.routing.md
Tests cover fallback, precedence, headers, middleware, raw responses, and all() routes; routing documentation describes HEAD behavior.
Bundle size thresholds
test/bench/bundle.test.ts
Bundle byte and gzip-size limits are tightened for three benchmark cases.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: pi0

Poem

A rabbit knocks: “HEAD, come through!”
GET routes answer, fresh with dew.
Headers stay, the body hides,
Explicit paths take their rides.
Tests and docs now softly cheer.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The bundle benchmark threshold changes appear unrelated to the HEAD-routing objectives and are not part of the linked issue scope. Move the bundle-size threshold updates to a separate PR or justify them with a direct code-size change tied to this feature.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements HEAD-to-GET fallback, preserves explicit HEAD/all routing, strips HEAD bodies, updates middleware, docs, and tests as required.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: automatic HEAD fallback to matching GET routes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/head-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

The H3 gzip size was unchanged and did not need the bump.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 (1)
src/response.ts (1)

129-131: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

HEAD body not stripped on the mutable-headers merge path.

When preparedHeaders exist, the response is OK, and mergeHeaders succeeds (mutable headers), return val at line 131 returns the raw Response with its body intact for HEAD requests. Standard Response objects have immutable headers so the catch path (which calls nullBody) handles them, but custom Response implementations with mutable headers would slip through. This is a gap in the PR objective to strip bodies from all raw Response objects on HEAD.

🛡️ Proposed fix: strip body for HEAD before returning merged response
   try {
     mergeHeaders(val.headers, preparedHeaders, val.headers);
+    if (event.req.method === "HEAD" && val.body !== null) {
+      return new FastResponse(null, {
+        status: val.status,
+        statusText: val.statusText,
+        headers: val.headers,
+      }) as Response;
+    }
     return val; // Fast path: no headers to merge
   } catch {
🤖 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/response.ts` around lines 129 - 131, In the mutable-header merge path
within the response handling try block, ensure HEAD responses have their body
stripped before returning. After mergeHeaders succeeds, call the existing
nullBody helper (or equivalent HEAD body-removal logic) on val before return
val, while preserving the current behavior for non-HEAD requests and the catch
path.
🤖 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/response.ts`:
- Around line 129-131: In the mutable-header merge path within the response
handling try block, ensure HEAD responses have their body stripped before
returning. After mergeHeaders succeeds, call the existing nullBody helper (or
equivalent HEAD body-removal logic) on val before return val, while preserving
the current behavior for non-HEAD requests and the catch path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 53d646b8-fa3f-4c15-9720-d79aef323739

📥 Commits

Reviewing files that changed from the base of the PR and between 3eb3a57 and 04df3d7.

📒 Files selected for processing (5)
  • docs/1.guide/1.basics/2.routing.md
  • src/h3.ts
  • src/response.ts
  • test/bench/bundle.test.ts
  • test/router.test.ts

pi0 and others added 2 commits July 10, 2026 07:52
Guards against dropping a legitimately-set Content-Length on the HEAD
fallback, for both event.res.headers and raw Response cases (RFC 9110:
HEAD Content-Length should equal what GET would send).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Method-scoped global middleware registered with { method: "GET" } now
also runs for HEAD requests, mirroring the routing fallback (HEAD is
served by GET handlers per RFC 9110). This matches Express, Hono, and
Elysia, which all run GET-scoped middleware for HEAD.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
pi0x pushed a commit that referenced this pull request Jul 10, 2026
Relies on the HEAD->GET route fallback (#1452): with the `get` option,
plain .get()/.query() registration now also covers HEAD requests.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0
pi0 merged commit c495c6c into main Jul 10, 2026
9 checks passed
@pi0
pi0 deleted the feat/head-fallback branch July 10, 2026 08:05
pi0x pushed a commit that referenced this pull request Jul 10, 2026
Relies on the HEAD->GET route fallback (#1452): with the `get` option,
plain .get()/.query() registration now also covers HEAD requests.

Co-Authored-By: Claude Fable 5 <[email protected]>
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.

Automatically match GET routes for HEAD requests

2 participants