Skip to content

feat(server): add HeadMethodHandlerPlugin - #2353

Merged
dinwwwh merged 4 commits into
mainfrom
claude/sharp-lovelace-gplvx9
Oct 29, 2026
Merged

dinwwwh merged 4 commits into
mainfrom
claude/sharp-lovelace-gplvx9

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Adds HeadMethodHandlerPlugin, which answers HEAD requests with the status and headers of the matching GET procedure, without the body. It works like Fastify's exposeHeadRoutes.

const handler = new OpenAPIHandler(router, {
  plugins: [new HeadMethodHandlerPlugin()],
})

Behavior

  • A procedure routed as HEAD takes precedence. The plugin retries as GET only when nothing matches HEAD.
  • The response keeps the headers adapters derive from the GET body, such as Content-Type, Content-Disposition and ETag. It adds Content-Length when the size is known up front: JSON, text, URL-encoded and blob bodies.
  • The body is discarded:
    • Streams are cancelled, and event iterators end before their generator starts. Without this, the Node adapter keeps reading an event stream forever on HEAD.
    • Form data reports its multipart Content-Type without any part being serialized, so no Content-Length is sent. The Response used to read the boundary is never cancelled, because undici rejects unhandled with ERR_INVALID_STATE when cancelled mid-serialization, which crashes Node 24 by default.
  • The request reaches the procedure as GET, so GetMethodCsrfProtectionHandlerPlugin guards HEAD too.
  • With RPCHandler, HEAD only works for procedures allowed for GET in allowMethods.

The plugin uses two routing interceptors:

  • Body stripper, prepended. Declared after = ['~response-compression'], so it wraps compression and HEAD reports the same Content-Encoding as GET. Without this, compression gzips the empty body and sends about 20 bytes with the HEAD response.
  • GET fallback, appended. Being last means a retry repeats only the route lookup, not the other routing interceptors.

StaticFileHandlerPlugin now declares before = ['~head-method']. Static files keep answering HEAD themselves, and procedures still beat files. The hint lives in @orpc/node, so @orpc/server doesn'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

  • Unit tests: routing (HEAD precedence, GET fallback, unmatched) and every body type: JSON, string, null, URLSearchParams, File, a blob of unknown size, a stream, an event iterator and FormData. Each header test checks the sent headers against what the fetch adapter sends for the same body over GET.
  • Handler tests:
    • RPCHandler over fetch and the Node adapter (supertest)
    • error responses
    • CSRF protection
    • response compression (this test fails without the ordering hint)
    • an event iterator that never starts
  • OpenAPI e2e tests (tests/openapi/head-method.test.ts): HEAD on a GET route, precedence of a HEAD route on the same path, and unmatched paths.
  • All-plugins harness: HEAD answers both a procedure and a static file with exactly the GET headers.
  • Node 24: the FormData test catches the undici rejection above. It failed test_matrix (24) before the fix and passes on every Node version in CI since.
  • Checks: pnpm type:check, lint, the JSDoc backlink check and blume validate --strict are clean.
  • Pre-existing failure: static-file-handler-plugin.test.ts > resolves dot segments within the root instead of following them fails locally on main too, so it is unrelated.

Docs

  • New HEAD Method plugin page, linked from the plugins index.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q

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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@pkg-pr-new

pkg-pr-new Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2353

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2353

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2353

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2353

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2353

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2353

@orpc/effect

npm i https://pkg.pr.new/@orpc/effect@2353

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2353

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2353

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2353

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2353

@orpc/mcp

npm i https://pkg.pr.new/@orpc/mcp@2353

@orpc/msw

npm i https://pkg.pr.new/@orpc/msw@2353

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2353

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2353

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2353

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2353

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2353

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2353

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2353

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2353

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2353

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2353

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2353

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2353

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2353

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2353

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2353

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2353

commit: 8666275

@codspeed

codspeed Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

1 warning

⚠️ 5 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/sharp-lovelace-gplvx9 (8666275) with main (54e6271)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

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

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 answers HEAD with the headers of the matching GET procedure via a prepended body-stripping routing interceptor and an appended GET fallback, discarding the body per type.
  • Static file ordering — StaticFileHandlerPlugin gains before = ['~head-method'] so files answer HEAD themselves while a GET procedure still wins over a file.
  • Exports & docs — plugin exported from @orpc/server/plugins, asserted in the index test, and documented in a new head-method.mdx page 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 HEAD case.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using 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

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

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

  • FormData HEAD branch no longer cancels the encoding Response — packages/server/src/plugins/head-method.ts derives the multipart content-type from a throwaway Response without reading or cancelling its body, resolving the unhandled undici rejection (025fa4ce1).

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@codecov

codecov Bot commented Oct 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@dinwwwh
dinwwwh merged commit 82a7108 into main Oct 29, 2026
14 checks passed
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.

2 participants