Skip to content

Automatically match GET routes for HEAD requests #1451

Description

@pi0x

Problem

Users currently have to register HEAD routes manually for every GET-able route. A HEAD /foo request to an app with only app.get("/foo", ...) returns 404, because route matching in ~findRoute asks rou3 for an exact method match (src/h3.ts):

override "~findRoute"(_event: H3Event): MatchedRoute<H3Route> | void {
  return findRoute(this["~rou3"], _event.req.method, _event.url.pathname);
}

Per RFC 9110, HEAD is identical to GET except the response has no body, and servers SHOULD support it for any GET-able resource. Express supports this implicitly, and Fastify enables it by default (exposeHeadRoutes: true). h3 users coming from those frameworks hit surprise 404s (e.g. from load balancer health checks, link checkers, CDNs probing resources).

Notably, half the feature already exists in core: toResponseprepareResponse already strips response bodies for HEAD requests via nullBody() (src/response.ts). Only the routing half is missing. There is also internal precedent for caring about HEAD: serveStatic special-cases it, and isMethod/assertMethod have an allowHead parameter.

Proposal

When a HEAD request does not match any explicit HEAD (or all()) route, fall back to the matching GET route. The request method is not rewritten — event.req.method stays "HEAD", so the existing nullBody() logic strips the body automatically while the handler produces correct headers.

  • Zero cost on non-HEAD traffic (the extra lookup only runs on a HEAD route miss).
  • Explicit app.head() routes still take precedence (fallback only fires on a miss), preserving the escape hatch for cheap HEAD handlers that skip body computation.
  • Route-level middleware attached to the GET route (auth, etc.) runs as expected.
  • Default-on, no config flag (matching Express semantics and keeping H3Config minimal). A flag can be added later if someone needs strict 405-style behavior.

Implementation steps

1. Routing fallback (src/h3.ts)

Change the ~findRoute override in the H3 class:

override "~findRoute"(_event: H3Event): MatchedRoute<H3Route> | void {
  const match = findRoute(this["~rou3"], _event.req.method, _event.url.pathname);
  if (match === undefined && _event.req.method === "HEAD") {
    return findRoute(this["~rou3"], "GET", _event.url.pathname);
  }
  return match;
}

Note: event.context.matchedRoute will reference the GET route for a fallback HEAD request — this is intended.

2. Strip body on the raw-Response fast path (src/response.ts)

prepareResponse already applies nullBody() for non-Response return values. But when a handler returns a raw Response and no prepared headers need merging, the fast path returns it with the body intact:

if (!preparedHeaders || nested || !val.ok) {
  return val; // Fast path: no headers to merge
}

Runtimes (Node, Deno, Bun, workerd) discard HEAD bodies at the server layer anyway, but for self-consistency add a HEAD check on this path: if event.req.method === "HEAD" and val.body is not null, return a body-less FastResponse with the same status/statusText/headers. Keep the check cheap (method comparison first) to avoid affecting the hot path.

3. Tests (test/router.test.ts, using describeMatrix)

Add a describe("HEAD fallback") block covering:

  1. HEAD to a route registered with .get() → status 200, empty body (await res.text() is ""), and headers produced by the handler are preserved (e.g. content-type: application/json... for an object return).
  2. Explicit .head() route takes precedence over the GET fallback (register both with distinguishable headers, assert the HEAD handler ran).
  3. HEAD to a path with no GET route either → still 404.
  4. Handler returning a raw Response on a HEAD request → empty body, headers/status preserved (covers step 2).
  5. HEAD matching an all() route still works (no behavior change; guards against regressions in the fallback ordering).
  6. Route-level middleware on the GET route runs for the fallback HEAD request.

The existing test "Not matching route method" (HEAD /preemptive/404) remains valid — that path has no GET route, so the fallback still yields 404. Confirm it passes unchanged.

Follow the bug-fix workflow from AGENTS.md: write tests first, confirm the new ones fail before the code change, then implement.

4. Docs

Update docs/1.guide/1.basics/2.routing.md: add a short "HEAD requests" section stating that HEAD requests automatically match GET routes with the response body omitted, and that registering an explicit head() route overrides this for handlers that want to skip body computation.

5. Verify

pnpm vitest run test/router.test.ts
pnpm test          # full suite: lint + typecheck + coverage
pnpm bench:node    # confirm no regression on GET hot path

Known trade-offs (accepted)

  • The GET handler computes the full body which is then discarded. This is the correct default (accurate headers matter more); explicit head() registration remains the optimization path.
  • Method-scoped global middleware (app.use(fn, { method: "GET" })) will not run for fallback HEAD requests since event.req.method stays "HEAD". Out of scope for this issue — can be revisited if it comes up in practice.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions