Repository navigation
feat(server): add HeadMethodHandlerPlugin - #2353
Conversation
Answers HEAD requests with the status and headers of the matching GET procedure, like Fastify's `exposeHeadRoutes`. A procedure routed as HEAD still takes precedence, and the fallback runs as the innermost routing interceptor so only routing repeats. The body is discarded but the headers adapters derive from it are kept, including Content-Length when the size is known up front. Streams are cancelled and event iterators end before they start. The plugin runs outside response compression, so HEAD reports GET's Content-Encoding without sending a compressed empty body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q
Derive stream and form data headers through `toFetchBody` like the other bodies, so only event iterators need their headers set by hand. Move the static file ordering hint to `StaticFileHandlerPlugin`, so the server package no longer names a plugin from a package that depends on it, and share one helper across the header tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
orpc | 8666275 | Commit Preview URL Branch Preview URL |
Oct 29 2026, 08:43 AM |
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-lock
@orpc/mcp
@orpc/msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Merging this PR will not alter performance1 warning
Comparing Footnotes
|
There was a problem hiding this comment.
Caution
toHeadResponse's FormData branch cancels the body of a throwaway new Response(formData) mid-serialization, which raises an unhandled ERR_INVALID_STATE rejection from undici. A HEAD request to a GET route whose detailed output is FormData crashes a default Node process. Details inline.
Reviewed changes
Read the complete diff (9 files, 2 commits) and ran the new tests plus the affected suites; the ordering design (body stripper after compression, GET fallback last, static-file before = ['~head-method']) checks out, and the fetch/Node adapters do keep Content-Length while sending an empty body.
HeadMethodHandlerPlugin— new handler plugin (packages/server/src/plugins/head-method.ts) that answersHEADwith the headers of the matchingGETprocedure via a prepended body-stripping routing interceptor and an appendedGETfallback, discarding the body per type.- Static file ordering —
StaticFileHandlerPlugingainsbefore = ['~head-method']so files answerHEADthemselves while aGETprocedure still wins over a file. - Exports & docs — plugin exported from
@orpc/server/plugins, asserted in the index test, and documented in a newhead-method.mdxpage linked from the plugins index. - Tests — 28 unit tests (routing, every body type, handler/CSRF/compression/Node-adapter cases), an OpenAPI e2e file, and an all-plugins
HEADcase.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…ethodHandlerPlugin The `Response` built to read a form data body's boundary was cancelled, which makes undici reject unhandled with `ERR_INVALID_STATE` on Node 24 and failed the test run there. Its body is now left unread instead, so no part is serialized and nothing needs cancelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Reviewed the delta since the prior pullfrog review (2dbe26a55 → 025fa4ce1): a single fix to the FormData branch of toHeadResponse that resolves the prior critical finding. The branch now reads only new Response(encoded).headers.get('content-type') and never reads or cancels the body, so no multipart part is serialized — matching the suggested fix exactly and eliminating the unhandled ERR_INVALID_STATE rejection. The rest of the plugin is unchanged.
FormDataHEAD branch no longer cancels the encodingResponse—packages/server/src/plugins/head-method.tsderives the multipartcontent-typefrom a throwawayResponsewithout reading or cancelling its body, resolving the unhandled undici rejection (025fa4ce1).
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q

Adds
HeadMethodHandlerPlugin, which answersHEADrequests with the status and headers of the matchingGETprocedure, without the body. It works like Fastify'sexposeHeadRoutes.Behavior
HEADtakes precedence. The plugin retries asGETonly when nothing matchesHEAD.GETbody, such asContent-Type,Content-DispositionandETag. It addsContent-Lengthwhen the size is known up front: JSON, text, URL-encoded and blob bodies.HEAD.Content-Typewithout any part being serialized, so noContent-Lengthis sent. TheResponseused to read the boundary is never cancelled, because undici rejects unhandled withERR_INVALID_STATEwhen cancelled mid-serialization, which crashes Node 24 by default.GET, soGetMethodCsrfProtectionHandlerPluginguardsHEADtoo.RPCHandler,HEADonly works for procedures allowed forGETinallowMethods.The plugin uses two routing interceptors:
after = ['~response-compression'], so it wraps compression andHEADreports the sameContent-EncodingasGET. Without this, compression gzips the empty body and sends about 20 bytes with theHEADresponse.GETfallback, appended. Being last means a retry repeats only the route lookup, not the other routing interceptors.StaticFileHandlerPluginnow declaresbefore = ['~head-method']. Static files keep answeringHEADthemselves, and procedures still beat files. The hint lives in@orpc/node, so@orpc/serverdoesn't name a plugin from a package that depends on it.Known limitation: pages served by routing interceptors rather than procedures, such as the OpenAPI reference UI and spec, stay
GET-only.Testing
HEADprecedence,GETfallback, unmatched) and every body type: JSON, string, null,URLSearchParams,File, a blob of unknown size, a stream, an event iterator andFormData. Each header test checks the sent headers against what the fetch adapter sends for the same body overGET.RPCHandlerover fetch and the Node adapter (supertest)tests/openapi/head-method.test.ts):HEADon aGETroute, precedence of aHEADroute on the same path, and unmatched paths.HEADanswers both a procedure and a static file with exactly theGETheaders.FormDatatest catches the undici rejection above. It failedtest_matrix (24)before the fix and passes on every Node version in CI since.pnpm type:check, lint, the JSDoc backlink check andblume validate --strictare clean.static-file-handler-plugin.test.ts > resolves dot segments within the root instead of following themfails locally onmaintoo, so it is unrelated.Docs
🤖 Generated with Claude Code
https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q