From bc8868eb4e7d2eb9a97faf5d23acf690cfad042f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:23:36 +0000 Subject: [PATCH 1/4] feat(server): add HeadMethodHandlerPlugin 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 Claude-Session: https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q --- apps/content/docs/plugins/head-method.mdx | 40 ++ apps/content/docs/plugins/index.mdx | 1 + .../server/src/plugins/head-method.test.ts | 383 ++++++++++++++++++ packages/server/src/plugins/head-method.ts | 146 +++++++ packages/server/src/plugins/index.test.ts | 1 + packages/server/src/plugins/index.ts | 1 + tests/openapi/head-method.test.ts | 82 ++++ tests/plugins/all-plugins.test.ts | 17 + 8 files changed, 671 insertions(+) create mode 100644 apps/content/docs/plugins/head-method.mdx create mode 100644 packages/server/src/plugins/head-method.test.ts create mode 100644 packages/server/src/plugins/head-method.ts create mode 100644 tests/openapi/head-method.test.ts diff --git a/apps/content/docs/plugins/head-method.mdx b/apps/content/docs/plugins/head-method.mdx new file mode 100644 index 0000000000..b300e4e20a --- /dev/null +++ b/apps/content/docs/plugins/head-method.mdx @@ -0,0 +1,40 @@ +--- +title: "HEAD Method Plugin" +description: "Use HeadMethodHandlerPlugin to answer HEAD requests with the status and headers of the matching GET procedure, without sending its body." +sidebar: + label: "HEAD Method" +--- + +## How It Works + +A [`HEAD`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/HEAD) request asks for the headers a `GET` request would receive, without the body, so clients, caches, and link checkers can learn whether a resource exists, its type, and its size without downloading it. Like Fastify's [`exposeHeadRoutes`](https://fastify.dev/docs/latest/Reference/Server/#exposeheadroutes), the plugin answers a `HEAD` request that matches no procedure with the matching `GET` procedure: + +- The response keeps the status and headers of `GET`, such as `Content-Type`, `Content-Encoding`, and `ETag`, plus `Content-Length` when the size is known without reading a stream. +- The body is discarded: streams are cancelled and [event iterators](/docs/async-iterator-object) end before they start. +- A procedure routed as `HEAD` takes precedence over the `GET` one. +- The request reaches the procedure as `GET`, so plugins guarding `GET`, such as the [GET Method CSRF Protection Plugin](/docs/plugins/get-method-csrf-protection), guard `HEAD` too. + +## Setup + +```ts +import { OpenAPIHandler } from '@orpc/openapi/fetch' +import { HeadMethodHandlerPlugin } from '@orpc/server/plugins' + +const handler = new OpenAPIHandler(router, { + plugins: [ + new HeadMethodHandlerPlugin(), + ], +}) +``` + +:::info +The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. `RPCHandler` only answers `HEAD` for procedures it allows `GET` for, see [Enabling the GET Method](/docs/rpc/handler#enabling-the-get-method). +::: + +:::tip +The `GET` procedure runs in full to produce the headers. When that is expensive, route a cheaper procedure as `HEAD` on the same path, for example one that only looks up the `ETag`. +::: + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/head-method.ts). diff --git a/apps/content/docs/plugins/index.mdx b/apps/content/docs/plugins/index.mdx index f699e1c286..1e8ba3dee9 100644 --- a/apps/content/docs/plugins/index.mdx +++ b/apps/content/docs/plugins/index.mdx @@ -34,6 +34,7 @@ Plugins add reusable behavior through the `plugins` option. Handler plugins (`.. - [Request Headers](/docs/plugins/request-headers) (server): read headers through `context.reqHeaders`. - [Response Headers](/docs/plugins/response-headers) (server): set headers and cookies through `context.resHeaders`. +- [HEAD Method](/docs/plugins/head-method) (server): answer `HEAD` requests with the headers of `GET` procedures. - [Method Override](/docs/plugins/method-override) (server): let HTML forms call `PUT`, `PATCH`, and `DELETE`. - [Rethrow](/docs/plugins/rethrow) (server): pass errors on to your framework's error handling. diff --git a/packages/server/src/plugins/head-method.test.ts b/packages/server/src/plugins/head-method.test.ts new file mode 100644 index 0000000000..d22dfbf5a0 --- /dev/null +++ b/packages/server/src/plugins/head-method.test.ts @@ -0,0 +1,383 @@ +import type { StandardResponse } from '@standard-server/core' +import { toFetchResponse } from '@standard-server/fetch' +import supertest from 'supertest' +import { RPCHandler } from '../adapters/fetch' +import { RPCHandler as NodeRPCHandler } from '../adapters/node' +import { os } from '../builder' +import { GetMethodCsrfProtectionHandlerPlugin } from './get-method-csrf-protection' +import { HeadMethodHandlerPlugin } from './head-method' +import { ResponseCompressionHandlerPlugin } from './response-compression' + +function getInterceptors() { + const existingInterceptor = vi.fn() + + const handlerOptions = new HeadMethodHandlerPlugin().init({ + routingInterceptors: [existingInterceptor], + } as any) + + const routingInterceptors = handlerOptions.routingInterceptors! + + return { + bodyInterceptor: routingInterceptors[0]!, + fallbackInterceptor: routingInterceptors.at(-1)!, + existingInterceptor, + routingInterceptors, + } +} + +/** + * Runs a response through the plugin as the answer to a `HEAD` request. + */ +async function toHeadResponse(response: StandardResponse): Promise { + const { bodyInterceptor } = getInterceptors() + + const result = await bodyInterceptor({ + context: {}, + request: { method: 'HEAD', url: '/', headers: {} }, + next: async () => ({ matched: true, response }), + } as any) + + return result.response! +} + +/** + * The headers an adapter sends for the response, besides those the runtime adds while sending it. + */ +function getSentHeaders(response: StandardResponse): Record { + return Object.fromEntries(toFetchResponse(response).headers) +} + +describe('headMethodHandlerPlugin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('wraps existing routing interceptors', () => { + const { routingInterceptors, existingInterceptor } = getInterceptors() + + expect(routingInterceptors).toHaveLength(3) + expect(routingInterceptors[1]).toBe(existingInterceptor) + }) + + describe('routing', () => { + it.each(['GET', 'POST'])('ignores %s requests', async (method) => { + const { bodyInterceptor, fallbackInterceptor } = getInterceptors() + const nextResult = { matched: true, response: { status: 200, headers: {}, body: { value: 1 } } } + + for (const interceptor of [bodyInterceptor, fallbackInterceptor]) { + const next = vi.fn().mockResolvedValue(nextResult) + + await expect(interceptor({ context: {}, request: { method, url: '/' }, next } as any)).resolves.toBe(nextResult) + expect(next).toHaveBeenCalledExactlyOnceWith() + } + }) + + it('prefers a procedure routed as HEAD', async () => { + const { fallbackInterceptor } = getInterceptors() + const nextResult = { matched: true, response: 'head' } + const next = vi.fn().mockResolvedValue(nextResult) + + await expect(fallbackInterceptor({ context: {}, request: { method: 'HEAD', url: '/' }, next } as any)).resolves.toBe(nextResult) + expect(next).toHaveBeenCalledExactlyOnceWith() + }) + + it('falls back to GET when no procedure is routed as HEAD', async () => { + const { fallbackInterceptor } = getInterceptors() + const nextResult = { matched: true, response: 'get' } + const next = vi.fn() + .mockResolvedValueOnce({ matched: false }) + .mockResolvedValueOnce(nextResult) + + const context = { a: 1 } + const signal = AbortSignal.timeout(1000) + const request = { method: 'HEAD', url: '/ping?x=1', headers: { 'x-custom': '1' }, signal } + + await expect(fallbackInterceptor({ context, prefix: '/api', request, next } as any)).resolves.toBe(nextResult) + expect(next).toHaveBeenCalledTimes(2) + expect(next).toHaveBeenNthCalledWith(1) + expect(next).toHaveBeenNthCalledWith(2, { + context, + prefix: '/api', + request: { method: 'GET', url: '/ping?x=1', headers: { 'x-custom': '1' }, signal }, + }) + }) + + it('stays unmatched when no procedure is routed as GET either', async () => { + const { fallbackInterceptor } = getInterceptors() + const next = vi.fn().mockResolvedValue({ matched: false }) + + await expect(fallbackInterceptor({ context: {}, request: { method: 'HEAD', url: '/' }, next } as any)).resolves.toEqual({ matched: false }) + expect(next).toHaveBeenCalledTimes(2) + }) + }) + + describe('response', () => { + it('leaves unmatched results as is', async () => { + const { bodyInterceptor } = getInterceptors() + const nextResult = { matched: false } + + await expect(bodyInterceptor({ context: {}, request: { method: 'HEAD', url: '/' }, next: async () => nextResult } as any)).resolves.toBe(nextResult) + }) + + it('leaves a response without a body as is', async () => { + const response = { status: 204, headers: { etag: '"1"' }, body: undefined } + + await expect(toHeadResponse(response)).resolves.toBe(response) + }) + + it.each([ + ['json', () => ({ name: 'Ελλάδα' })], + ['string', () => 'ő'], + ['null', () => null], + ['url-search-params', () => new URLSearchParams({ name: 'Ελλάδα' })], + ])('keeps the headers of a %s body and reports its size', async (_, createBody) => { + const response = await toHeadResponse({ status: 201, headers: { 'x-custom': '1' }, body: createBody() }) + + const getResponse = toFetchResponse({ status: 201, headers: { 'x-custom': '1' }, body: createBody() }) + const size = (await getResponse.arrayBuffer()).byteLength + + expect(response.status).toBe(201) + expect(getSentHeaders(response)).toEqual({ + ...Object.fromEntries(getResponse.headers), + 'content-length': size.toString(), + }) + await expect(toFetchResponse(response).text()).resolves.toBe('') + }) + + it('respects a custom content type', async () => { + const response = await toHeadResponse({ status: 200, headers: { 'content-type': 'application/vnd.api+json' }, body: { a: 1 } }) + + expect(getSentHeaders(response)).toEqual({ + ...getSentHeaders({ status: 200, headers: { 'content-type': 'application/vnd.api+json' }, body: { a: 1 } }), + 'content-length': '7', + }) + expect(getSentHeaders(response)['standard-server']).toBe('json') + }) + + it('keeps the headers of a file body', async () => { + const createBody = () => new File(['hello'], 'hello world.txt', { type: 'text/plain' }) + const response = await toHeadResponse({ status: 200, headers: {}, body: createBody() }) + + expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers: {}, body: createBody() })) + expect(getSentHeaders(response)).toEqual(expect.objectContaining({ + 'content-type': 'text/plain', + 'content-length': '5', + 'content-disposition': expect.stringContaining('hello world.txt'), + })) + await expect(toFetchResponse(response).text()).resolves.toBe('') + }) + + it('keeps the headers of a blob body of unknown size and cancels its stream', async () => { + const cancel = vi.fn() + + class UnknownSizeBlob extends Blob { + override get size() { + return Number.NaN + } + + override stream() { + return new ReadableStream>({ cancel }) + } + } + + const response = await toHeadResponse({ status: 200, headers: {}, body: new UnknownSizeBlob([], { type: 'text/plain' }) }) + + expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers: {}, body: new UnknownSizeBlob([], { type: 'text/plain' }) })) + expect(getSentHeaders(response)).not.toHaveProperty('content-length') + expect(cancel).toHaveBeenCalledOnce() + }) + + it('keeps the headers of a stream body and cancels it', async () => { + const cancel = vi.fn() + const pull = vi.fn() + const createBody = () => new ReadableStream({ pull, cancel }) + const headers = { 'content-type': 'video/mp4', 'content-length': '100' } + + const response = await toHeadResponse({ status: 200, headers, body: createBody() }) + + expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers, body: createBody() })) + expect(getSentHeaders(response)).toEqual(expect.objectContaining({ 'content-length': '100' })) + await expect(toFetchResponse(response).text()).resolves.toBe('') + expect(cancel).toHaveBeenCalledOnce() + }) + + it('keeps the headers of an event iterator body and ends it without starting it', async () => { + let started = false + async function* createBody() { + started = true + yield 1 + } + + const body = createBody() + const response = await toHeadResponse({ status: 200, headers: {}, body }) + + expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers: {}, body: (async function* () {})() })) + expect(getSentHeaders(response)).toEqual(expect.objectContaining({ 'content-type': 'text/event-stream' })) + await expect(toFetchResponse(response).text()).resolves.toBe('') + await expect(body.next()).resolves.toEqual({ done: true, value: undefined }) + expect(started).toBe(false) + }) + + it('keeps the hint of an event iterator body with a custom content type', async () => { + const headers = { 'content-type': 'text/plain' } + const response = await toHeadResponse({ status: 200, headers, body: (async function* () {})() }) + + expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers, body: (async function* () {})() })) + expect(getSentHeaders(response)['standard-server']).toBe('event-stream') + }) + + it('keeps the headers of a form data body', async () => { + const body = new FormData() + body.set('file', new File(['hello'], 'hello.txt')) + + const response = await toHeadResponse({ status: 200, headers: { 'x-custom': '1', 'content-length': '1' }, body }) + + expect(getSentHeaders(response)).toEqual({ + 'x-custom': '1', + 'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/), + }) + await expect(toFetchResponse(response).text()).resolves.toBe('') + }) + }) + + describe('with handlers', () => { + const procedure = vi.fn(() => ({ name: 'Ελλάδα' })) + const router = { ping: os.handler(procedure) } + + it('answers HEAD with the headers of GET', async () => { + const handler = new RPCHandler(router, { + allowMethods: ['GET'], + plugins: [new HeadMethodHandlerPlugin()], + }) + + const url = `https://example.com/ping?data=${encodeURIComponent(JSON.stringify({ json: 'input' }))}` + const { response: getResponse } = await handler.handle(new Request(url)) + const { matched, response } = await handler.handle(new Request(url, { method: 'HEAD' })) + + expect(matched).toBe(true) + expect(response!.status).toBe(200) + expect(Object.fromEntries(response!.headers)).toEqual({ + ...Object.fromEntries(getResponse!.headers), + 'content-length': (await getResponse!.arrayBuffer()).byteLength.toString(), + }) + await expect(response!.text()).resolves.toBe('') + expect(procedure).toHaveBeenCalledTimes(2) + expect(procedure).toHaveBeenLastCalledWith(expect.any(Object), 'input') + }) + + it('answers HEAD with the error of GET', async () => { + const handler = new RPCHandler({ + ping: os.handler(() => { + throw new Error('boom') + }), + }, { + allowMethods: ['GET'], + plugins: [new HeadMethodHandlerPlugin()], + }) + + const { response } = await handler.handle(new Request('https://example.com/ping', { method: 'HEAD' })) + + expect(response!.status).toBe(500) + expect(response!.headers.get('content-type')).toBe('application/json') + expect(Number(response!.headers.get('content-length'))).toBeGreaterThan(0) + await expect(response!.text()).resolves.toBe('') + }) + + it('routes a procedure allowed for HEAD itself', async () => { + const methods: string[] = [] + const handler = new RPCHandler(router, { + allowMethods: ['GET', 'HEAD'], + interceptors: [({ request, next }) => { + methods.push(request.method) + return next() + }], + plugins: [new HeadMethodHandlerPlugin()], + }) + + const { matched, response } = await handler.handle(new Request('https://example.com/ping', { method: 'HEAD' })) + + expect(matched).toBe(true) + await expect(response!.text()).resolves.toBe('') + expect(methods).toEqual(['HEAD']) + }) + + it('stays unmatched when GET is not allowed', async () => { + const handler = new RPCHandler(router, { + plugins: [new HeadMethodHandlerPlugin()], + }) + + await expect(handler.handle(new Request('https://example.com/ping', { method: 'HEAD' }))).resolves.toEqual({ matched: false }) + expect(procedure).not.toHaveBeenCalled() + }) + + it('guards HEAD like GET', async () => { + const handler = new RPCHandler(router, { + allowMethods: ['GET'], + plugins: [new HeadMethodHandlerPlugin(), new GetMethodCsrfProtectionHandlerPlugin()], + }) + + const { response } = await handler.handle(new Request('https://example.com/ping', { + method: 'HEAD', + headers: { 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }, + })) + + expect(response!.status).toBe(403) + expect(procedure).not.toHaveBeenCalled() + }) + + it('reports the encoding of a compressed GET response', async () => { + const handler = new RPCHandler({ ping: os.handler(() => 'x'.repeat(2000)) }, { + allowMethods: ['GET'], + // listed first to show the order does not matter + plugins: [new HeadMethodHandlerPlugin(), new ResponseCompressionHandlerPlugin()], + }) + + const init = { headers: { 'accept-encoding': 'gzip' } } + const { response: getResponse } = await handler.handle(new Request('https://example.com/ping', init)) + const { response } = await handler.handle(new Request('https://example.com/ping', { ...init, method: 'HEAD' })) + + expect(getResponse!.headers.get('content-encoding')).toBe('gzip') + expect(Object.fromEntries(response!.headers)).toEqual(Object.fromEntries(getResponse!.headers)) + await expect(response!.text()).resolves.toBe('') + }) + + it('ends an event iterator without running it', async () => { + let started = false + const handler = new RPCHandler({ + + stream: os.handler(async function* () { + started = true + await new Promise(() => {}) // never settles + }), + }, { + allowMethods: ['GET'], + plugins: [new HeadMethodHandlerPlugin()], + }) + + const { response } = await handler.handle(new Request('https://example.com/stream', { method: 'HEAD' })) + + expect(response!.headers.get('content-type')).toBe('text/event-stream') + await expect(response!.text()).resolves.toBe('') + expect(started).toBe(false) + }) + + it('sends the size of the GET body with the Node.js adapter', async () => { + const handler = new NodeRPCHandler(router, { + allowMethods: ['GET'], + plugins: [new HeadMethodHandlerPlugin()], + }) + + const server = supertest((req: any, res: any) => { + handler.handle(req, res) + }) + + const getResponse = await server.get('/ping') + const response = await server.head('/ping') + + expect(response.status).toBe(200) + expect(response.headers['content-type']).toBe('application/json') + expect(response.headers['content-length']).toBe(getResponse.headers['content-length']) + expect(response.text).toBeUndefined() + }) + }) +}) diff --git a/packages/server/src/plugins/head-method.ts b/packages/server/src/plugins/head-method.ts new file mode 100644 index 0000000000..213a9b7606 --- /dev/null +++ b/packages/server/src/plugins/head-method.ts @@ -0,0 +1,146 @@ +import type { StandardBody, StandardHeaders, StandardResponse } from '@standard-server/core' +import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '../adapters/standard' +import type { Context } from '../context' +import { isAsyncIteratorObject, toArray } from '@orpc/shared' +import { cancelStandardBody, getRequiredStandardBodyHint } from '@standard-server/core' +import { toFetchBody } from '@standard-server/fetch' + +const textEncoder = new TextEncoder() + +/** + * Answers `HEAD` requests with the headers of the matching `GET` procedure, like Fastify's + * `exposeHeadRoutes`, so clients can check a resource's existence, type, and size without + * downloading it. A procedure routed as `HEAD` itself takes precedence over the `GET` one. + * + * @remarks + * - `HEAD` responses never carry a body: the plugin discards it, cancelling streams and + * event iterators, and keeps the headers it implies, such as `Content-Type`, and + * `Content-Length` when its size is known up front. + * - `GET` procedures still need to be reachable, so with `RPCHandler`, `GET` must be in `allowMethods`. + * + * @see {@link https://orpc.dev/docs/plugins/head-method | HEAD Method Plugin} + */ +export class HeadMethodHandlerPlugin implements StandardHandlerPlugin { + name = '~head-method' + + /** + * - Discard the body after response compression, so `HEAD` reports the same `Content-Encoding` as `GET`. + * - Fall back to `GET` after static files, so they keep answering `HEAD` themselves and procedures still take precedence over them. + */ + after = ['~response-compression', '~static-file'] + + init(options: StandardHandlerOptions): StandardHandlerOptions { + const bodyInterceptor: StandardHandlerRoutingInterceptor = async ({ next, request }) => { + if (request.method !== 'HEAD') { + return next() + } + + const result = await next() + + if (!result.matched) { + return result + } + + return { ...result, response: toHeadResponse(result.response) } + } + + /** + * Placed last, so falling back to `GET` repeats routing rather than the routing interceptors before this one. + */ + const fallbackInterceptor: StandardHandlerRoutingInterceptor = async ({ next, ...interceptorOptions }) => { + const { request } = interceptorOptions + + if (request.method !== 'HEAD') { + return next() + } + + const result = await next() + + if (result.matched) { + return result + } + + return next({ + ...interceptorOptions, + request: { ...request, method: 'GET' }, + }) + } + + return { + ...options, + routingInterceptors: [ + bodyInterceptor, + ...toArray(options.routingInterceptors), + fallbackInterceptor, + ], + } + } +} + +/** + * Replaces the body with an empty stream, keeping the headers adapters derive from it. + * An empty stream rather than no body, because adapters drop `Content-Length` when the body is `undefined`, + * and an empty `Standard-Server` hint rather than none, so they do not hint the stream as `octet-stream`. + */ +function toHeadResponse(response: StandardResponse): StandardResponse { + const { body } = response + + if (body === undefined) { + return response + } + + let headers: StandardHeaders + + if (body instanceof ReadableStream) { + discardBody(body) + headers = response.headers // adapters derive the same headers from any stream + } + + else if (isAsyncIteratorObject(body)) { + discardBody(body) + + // Encoding it would start iterating, so the headers are derived the way adapters do + headers = { ...response.headers, 'content-length': undefined } + headers['content-type'] ??= 'text/event-stream' + headers['standard-server'] = getRequiredStandardBodyHint('event-stream', headers) ?? [] + } + + else if (body instanceof FormData) { + // Only encoding it reveals the boundary. The size is left out, since it would require reading every file. + const encoded = new Response(body) + discardBody(encoded.body) + + headers = { + ...response.headers, + 'standard-server': [], + 'content-type': encoded.headers.get('content-type')!, + 'content-length': undefined, + } + } + + else { + const [encoded, encodedHeaders] = toFetchBody(body, response.headers) + headers = encodedHeaders + + if (encoded instanceof ReadableStream) { + discardBody(encoded) // a blob of unknown size + } + else if (typeof encoded === 'string' || encoded instanceof URLSearchParams) { + headers['standard-server'] ??= [] + headers['content-length'] = textEncoder.encode(encoded.toString()).byteLength.toString() + } + } + + return { ...response, headers, body: createEmptyStream() } +} + +function createEmptyStream(): ReadableStream> { + return new ReadableStream({ start: controller => controller.close() }) +} + +/** + * Nothing reads the body anymore, so a failure to cancel it has nowhere to go. + */ +function discardBody(body: StandardBody): void { + cancelStandardBody(body).catch(() => {}) +} diff --git a/packages/server/src/plugins/index.test.ts b/packages/server/src/plugins/index.test.ts index 0685227862..9cd50a16d6 100644 --- a/packages/server/src/plugins/index.test.ts +++ b/packages/server/src/plugins/index.test.ts @@ -7,6 +7,7 @@ it('exports plugins', async () => { RequestHeadersHandlerPlugin: expect.any(Function), ResponseHeadersHandlerPlugin: expect.any(Function), GetMethodCsrfProtectionHandlerPlugin: expect.any(Function), + HeadMethodHandlerPlugin: expect.any(Function), MethodOverrideHandlerPlugin: expect.any(Function), RethrowHandlerPlugin: expect.any(Function), RequestCompressionHandlerPlugin: expect.any(Function), diff --git a/packages/server/src/plugins/index.ts b/packages/server/src/plugins/index.ts index 53687b3772..9377987ca3 100644 --- a/packages/server/src/plugins/index.ts +++ b/packages/server/src/plugins/index.ts @@ -7,6 +7,7 @@ export { CORSHandlerPlugin as CORSPlugin, } from './cors' export * from './get-method-csrf-protection' +export * from './head-method' export * from './method-override' export * from './request-compression' export * from './request-headers' diff --git a/tests/openapi/head-method.test.ts b/tests/openapi/head-method.test.ts new file mode 100644 index 0000000000..9d8c408fec --- /dev/null +++ b/tests/openapi/head-method.test.ts @@ -0,0 +1,82 @@ +import { openapi } from '@orpc/openapi' +import { OpenAPIHandler } from '@orpc/openapi/fetch' +import { os } from '@orpc/server' +import { HeadMethodHandlerPlugin } from '@orpc/server/plugins' +import { z } from 'zod' + +describe('headMethodHandlerPlugin with OpenAPIHandler', () => { + const getHandler = vi.fn(({ input }) => ({ + headers: { etag: `"${input.id}"` }, + body: { id: input.id, title: 'Ελλάδα' }, + })) + const headHandler = vi.fn(() => ({ headers: { 'x-head': '1' } })) + + const router = { + getTodo: os + .input(z.object({ id: z.string() })) + .meta(openapi({ + method: 'GET', + path: '/todos/{id}', + outputStructure: 'detailed', + })) + .handler(getHandler), + getUser: os + .input(z.object({ id: z.string() })) + .meta(openapi({ + method: 'GET', + path: '/users/{id}', + })) + .handler(({ input }) => input), + headUser: os + .input(z.object({ id: z.string() })) + .meta(openapi({ + method: 'HEAD', + path: '/users/{id}', + outputStructure: 'detailed', + })) + .handler(headHandler), + createTodo: os + .meta(openapi({ + method: 'POST', + path: '/todos', + })) + .handler(() => 'created'), + } + + const handler = new OpenAPIHandler(router, { + plugins: [new HeadMethodHandlerPlugin()], + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('answers HEAD with the headers of the GET route', async () => { + const { response: getResponse } = await handler.handle(new Request('https://example.com/todos/1')) + const { matched, response } = await handler.handle(new Request('https://example.com/todos/1', { method: 'HEAD' })) + + expect(matched).toBe(true) + expect(response!.status).toBe(200) + expect(Object.fromEntries(response!.headers)).toEqual({ + ...Object.fromEntries(getResponse!.headers), + 'content-length': (await getResponse!.arrayBuffer()).byteLength.toString(), + }) + expect(response!.headers.get('etag')).toBe('"1"') + await expect(response!.text()).resolves.toBe('') + expect(getHandler).toHaveBeenCalledTimes(2) + expect(getHandler).toHaveBeenLastCalledWith(expect.any(Object), { id: '1' }) + }) + + it('prefers the HEAD route of the same path', async () => { + const { matched, response } = await handler.handle(new Request('https://example.com/users/1', { method: 'HEAD' })) + + expect(matched).toBe(true) + expect(response!.headers.get('x-head')).toBe('1') + await expect(response!.text()).resolves.toBe('') + expect(headHandler).toHaveBeenCalledOnce() + }) + + it('stays unmatched without a GET route', async () => { + await expect(handler.handle(new Request('https://example.com/todos', { method: 'HEAD' }))).resolves.toEqual({ matched: false }) + }) +}) diff --git a/tests/plugins/all-plugins.test.ts b/tests/plugins/all-plugins.test.ts index 0785c126f3..db629f7a51 100644 --- a/tests/plugins/all-plugins.test.ts +++ b/tests/plugins/all-plugins.test.ts @@ -28,6 +28,7 @@ import { BatchHandlerPlugin, CORSHandlerPlugin, GetMethodCsrfProtectionHandlerPlugin, + HeadMethodHandlerPlugin, MethodOverrideHandlerPlugin, RequestCompressionHandlerPlugin, RequestHeadersHandlerPlugin, @@ -85,6 +86,7 @@ function createHandlerPlugins() { new CORSHandlerPlugin(), new EvlogHandlerPlugin(), new GetMethodCsrfProtectionHandlerPlugin(), + new HeadMethodHandlerPlugin(), new HibernationHandlerPlugin(), new MethodOverrideHandlerPlugin(), new OpenAPIReferenceHandlerPlugin({ spec, docsPath: '/docs', specPath: '/spec.json' }), @@ -208,6 +210,21 @@ describe('all plugins on one handler and one link', () => { expect(exchanges[0]!.response.headers.get('content-encoding')).toEqual('gzip') }) + it('answers HEAD with the headers of GET behind the other routing plugins', async () => { + const { handler } = createClientServer() + + for (const path of [`/rpc/ping?data=${encodeURIComponent(JSON.stringify({ json: { value: 1 } }))}`, '/rpc/static/all-plugins.test.ts']) { + const init = { headers: { 'accept-encoding': 'gzip' } } + const { response: getResponse } = await handler.handle(new Request(`http://localhost${path}`, init), { context: {}, prefix: '/rpc' }) + const { response } = await handler.handle(new Request(`http://localhost${path}`, { ...init, method: 'HEAD' }), { context: {}, prefix: '/rpc' }) + + expect(getResponse?.status).toEqual(200) + expect(response?.status).toEqual(200) + expect(Object.fromEntries(response!.headers)).toEqual(Object.fromEntries(getResponse!.headers)) + await expect(response!.text()).resolves.toEqual('') + } + }) + it('serves the openapi reference behind the other routing plugins', async () => { const { handler } = createClientServer() From 2dbe26a553773957db608c3593fabf00385e44ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:36:18 +0000 Subject: [PATCH 2/4] refactor(server): simplify HeadMethodHandlerPlugin 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 Claude-Session: https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q --- .../node/src/static-file-handler-plugin.ts | 6 + .../server/src/plugins/head-method.test.ts | 117 +++++++++--------- packages/server/src/plugins/head-method.ts | 58 ++++----- tests/plugins/all-plugins.test.ts | 22 ++-- 4 files changed, 96 insertions(+), 107 deletions(-) diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index 1110215868..865278893f 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -133,6 +133,12 @@ export class StaticFileHandlerPlugin implements StandardHandl */ after = ['~tracing'] + /** + * Should run outside the HEAD method plugin's `GET` fallback, so files keep answering `HEAD` + * themselves and procedures routed as `GET` still take precedence over them. + */ + before = ['~head-method'] + private readonly rootDir: string /** `rootDir` with a trailing separator, precomputed for the lexical containment check. */ private readonly rootDirPrefix: string diff --git a/packages/server/src/plugins/head-method.test.ts b/packages/server/src/plugins/head-method.test.ts index d22dfbf5a0..f6b0834f8d 100644 --- a/packages/server/src/plugins/head-method.test.ts +++ b/packages/server/src/plugins/head-method.test.ts @@ -47,6 +47,24 @@ function getSentHeaders(response: StandardResponse): Record { return Object.fromEntries(toFetchResponse(response).headers) } +/** + * Expects the `HEAD` response to send the status and headers of `GET`, plus `extraHeaders`, without a body. + */ +async function expectHeadersOfGet( + createResponse: () => StandardResponse, + extraHeaders: Record = {}, +): Promise> { + const reference = createResponse() + const response = await toHeadResponse(createResponse()) + const headers = getSentHeaders(response) + + expect(response.status).toBe(reference.status) + expect(headers).toEqual({ ...getSentHeaders(reference), ...extraHeaders }) + await expect(toFetchResponse(response).text()).resolves.toBe('') + + return headers +} + describe('headMethodHandlerPlugin', () => { beforeEach(() => { vi.clearAllMocks() @@ -98,6 +116,7 @@ describe('headMethodHandlerPlugin', () => { expect(next).toHaveBeenNthCalledWith(2, { context, prefix: '/api', + next, request: { method: 'GET', url: '/ping?x=1', headers: { 'x-custom': '1' }, signal }, }) }) @@ -126,45 +145,28 @@ describe('headMethodHandlerPlugin', () => { }) it.each([ - ['json', () => ({ name: 'Ελλάδα' })], - ['string', () => 'ő'], - ['null', () => null], - ['url-search-params', () => new URLSearchParams({ name: 'Ελλάδα' })], - ])('keeps the headers of a %s body and reports its size', async (_, createBody) => { - const response = await toHeadResponse({ status: 201, headers: { 'x-custom': '1' }, body: createBody() }) - - const getResponse = toFetchResponse({ status: 201, headers: { 'x-custom': '1' }, body: createBody() }) - const size = (await getResponse.arrayBuffer()).byteLength - - expect(response.status).toBe(201) - expect(getSentHeaders(response)).toEqual({ - ...Object.fromEntries(getResponse.headers), - 'content-length': size.toString(), - }) - await expect(toFetchResponse(response).text()).resolves.toBe('') - }) - - it('respects a custom content type', async () => { - const response = await toHeadResponse({ status: 200, headers: { 'content-type': 'application/vnd.api+json' }, body: { a: 1 } }) - - expect(getSentHeaders(response)).toEqual({ - ...getSentHeaders({ status: 200, headers: { 'content-type': 'application/vnd.api+json' }, body: { a: 1 } }), - 'content-length': '7', - }) - expect(getSentHeaders(response)['standard-server']).toBe('json') + ['json', {}, () => ({ name: 'Ελλάδα' })], + ['json with a custom content type', { 'content-type': 'application/vnd.api+json' }, () => ({ a: 1 })], + ['string', {}, () => 'ő'], + ['null', {}, () => null], + ['url-search-params', {}, () => new URLSearchParams({ name: 'Ελλάδα' })], + ])('keeps the headers of a %s body and reports its size', async (_, headers, createBody) => { + const size = (await toFetchResponse({ status: 200, headers, body: createBody() }).arrayBuffer()).byteLength + + await expectHeadersOfGet( + () => ({ status: 201, headers: { ...headers, 'x-custom': '1' }, body: createBody() }), + { 'content-length': size.toString() }, + ) }) it('keeps the headers of a file body', async () => { - const createBody = () => new File(['hello'], 'hello world.txt', { type: 'text/plain' }) - const response = await toHeadResponse({ status: 200, headers: {}, body: createBody() }) - - expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers: {}, body: createBody() })) - expect(getSentHeaders(response)).toEqual(expect.objectContaining({ - 'content-type': 'text/plain', - 'content-length': '5', - 'content-disposition': expect.stringContaining('hello world.txt'), + const headers = await expectHeadersOfGet(() => ({ + status: 200, + headers: {}, + body: new File(['hello'], 'hello world.txt', { type: 'text/plain' }), })) - await expect(toFetchResponse(response).text()).resolves.toBe('') + + expect(headers).toMatchObject({ 'content-type': 'text/plain', 'content-length': '5' }) }) it('keeps the headers of a blob body of unknown size and cancels its stream', async () => { @@ -180,52 +182,45 @@ describe('headMethodHandlerPlugin', () => { } } - const response = await toHeadResponse({ status: 200, headers: {}, body: new UnknownSizeBlob([], { type: 'text/plain' }) }) + const headers = await expectHeadersOfGet(() => ({ status: 200, headers: {}, body: new UnknownSizeBlob([], { type: 'text/plain' }) })) - expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers: {}, body: new UnknownSizeBlob([], { type: 'text/plain' }) })) - expect(getSentHeaders(response)).not.toHaveProperty('content-length') + expect(headers).not.toHaveProperty('content-length') expect(cancel).toHaveBeenCalledOnce() }) it('keeps the headers of a stream body and cancels it', async () => { const cancel = vi.fn() - const pull = vi.fn() - const createBody = () => new ReadableStream({ pull, cancel }) - const headers = { 'content-type': 'video/mp4', 'content-length': '100' } - const response = await toHeadResponse({ status: 200, headers, body: createBody() }) + const headers = await expectHeadersOfGet(() => ({ + status: 200, + headers: { 'content-type': 'video/mp4', 'content-length': '100' }, + body: new ReadableStream({ cancel }), + })) - expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers, body: createBody() })) - expect(getSentHeaders(response)).toEqual(expect.objectContaining({ 'content-length': '100' })) - await expect(toFetchResponse(response).text()).resolves.toBe('') + expect(headers['content-length']).toBe('100') expect(cancel).toHaveBeenCalledOnce() }) - it('keeps the headers of an event iterator body and ends it without starting it', async () => { + it.each([ + [{}], + [{ 'content-type': 'text/plain' }], + ])('keeps the headers of an event iterator body with headers %o', async (headers) => { + await expectHeadersOfGet(() => ({ status: 200, headers, body: (async function* () {})() })) + }) + + it('ends an event iterator body without starting it', async () => { let started = false - async function* createBody() { + const body = (async function* () { started = true yield 1 - } + })() - const body = createBody() - const response = await toHeadResponse({ status: 200, headers: {}, body }) + await toHeadResponse({ status: 200, headers: {}, body }) - expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers: {}, body: (async function* () {})() })) - expect(getSentHeaders(response)).toEqual(expect.objectContaining({ 'content-type': 'text/event-stream' })) - await expect(toFetchResponse(response).text()).resolves.toBe('') await expect(body.next()).resolves.toEqual({ done: true, value: undefined }) expect(started).toBe(false) }) - it('keeps the hint of an event iterator body with a custom content type', async () => { - const headers = { 'content-type': 'text/plain' } - const response = await toHeadResponse({ status: 200, headers, body: (async function* () {})() }) - - expect(getSentHeaders(response)).toEqual(getSentHeaders({ status: 200, headers, body: (async function* () {})() })) - expect(getSentHeaders(response)['standard-server']).toBe('event-stream') - }) - it('keeps the headers of a form data body', async () => { const body = new FormData() body.set('file', new File(['hello'], 'hello.txt')) diff --git a/packages/server/src/plugins/head-method.ts b/packages/server/src/plugins/head-method.ts index 213a9b7606..5ca95e4780 100644 --- a/packages/server/src/plugins/head-method.ts +++ b/packages/server/src/plugins/head-method.ts @@ -24,10 +24,9 @@ export class HeadMethodHandlerPlugin implements StandardHandl name = '~head-method' /** - * - Discard the body after response compression, so `HEAD` reports the same `Content-Encoding` as `GET`. - * - Fall back to `GET` after static files, so they keep answering `HEAD` themselves and procedures still take precedence over them. + * Discard the body after response compression, so `HEAD` reports the same `Content-Encoding` as `GET`. */ - after = ['~response-compression', '~static-file'] + after = ['~response-compression'] init(options: StandardHandlerOptions): StandardHandlerOptions { const bodyInterceptor: StandardHandlerRoutingInterceptor = async ({ next, request }) => { @@ -47,8 +46,8 @@ export class HeadMethodHandlerPlugin implements StandardHandl /** * Placed last, so falling back to `GET` repeats routing rather than the routing interceptors before this one. */ - const fallbackInterceptor: StandardHandlerRoutingInterceptor = async ({ next, ...interceptorOptions }) => { - const { request } = interceptorOptions + const fallbackInterceptor: StandardHandlerRoutingInterceptor = async (interceptorOptions) => { + const { next, request } = interceptorOptions if (request.method !== 'HEAD') { return next() @@ -89,46 +88,33 @@ function toHeadResponse(response: StandardResponse): StandardResponse { return response } - let headers: StandardHeaders - - if (body instanceof ReadableStream) { - discardBody(body) - headers = response.headers // adapters derive the same headers from any stream - } - - else if (isAsyncIteratorObject(body)) { + if (isAsyncIteratorObject(body)) { discardBody(body) // Encoding it would start iterating, so the headers are derived the way adapters do - headers = { ...response.headers, 'content-length': undefined } + const headers: StandardHeaders = { ...response.headers, 'content-length': undefined } headers['content-type'] ??= 'text/event-stream' headers['standard-server'] = getRequiredStandardBodyHint('event-stream', headers) ?? [] - } - else if (body instanceof FormData) { - // Only encoding it reveals the boundary. The size is left out, since it would require reading every file. - const encoded = new Response(body) - discardBody(encoded.body) - - headers = { - ...response.headers, - 'standard-server': [], - 'content-type': encoded.headers.get('content-type')!, - 'content-length': undefined, - } + return { ...response, headers, body: createEmptyStream() } } - else { - const [encoded, encodedHeaders] = toFetchBody(body, response.headers) - headers = encodedHeaders + const [encoded, headers] = toFetchBody(body, response.headers) - if (encoded instanceof ReadableStream) { - discardBody(encoded) // a blob of unknown size - } - else if (typeof encoded === 'string' || encoded instanceof URLSearchParams) { - headers['standard-server'] ??= [] - headers['content-length'] = textEncoder.encode(encoded.toString()).byteLength.toString() - } + if (encoded instanceof ReadableStream) { + discardBody(encoded) // a stream, or a blob of unknown size + } + else if (encoded instanceof FormData) { + // Only encoding it reveals the boundary. The size is left out, since it would require reading every file. + const encodedResponse = new Response(encoded) + discardBody(encodedResponse.body) + + headers['standard-server'] = [] + headers['content-type'] = encodedResponse.headers.get('content-type')! + } + else if (typeof encoded === 'string' || encoded instanceof URLSearchParams) { + headers['standard-server'] ??= [] + headers['content-length'] = textEncoder.encode(encoded.toString()).byteLength.toString() } return { ...response, headers, body: createEmptyStream() } diff --git a/tests/plugins/all-plugins.test.ts b/tests/plugins/all-plugins.test.ts index db629f7a51..061a63845a 100644 --- a/tests/plugins/all-plugins.test.ts +++ b/tests/plugins/all-plugins.test.ts @@ -210,19 +210,21 @@ describe('all plugins on one handler and one link', () => { expect(exchanges[0]!.response.headers.get('content-encoding')).toEqual('gzip') }) - it('answers HEAD with the headers of GET behind the other routing plugins', async () => { + it.each([ + `/rpc/ping?data=${encodeURIComponent(JSON.stringify({ json: { value: 1 } }))}`, + '/rpc/static/all-plugins.test.ts', + ])('answers HEAD with the headers of GET behind the other routing plugins: %s', async (path) => { const { handler } = createClientServer() + const options = { context: {}, prefix: '/rpc' } as const + const init = { headers: { 'accept-encoding': 'gzip' } } - for (const path of [`/rpc/ping?data=${encodeURIComponent(JSON.stringify({ json: { value: 1 } }))}`, '/rpc/static/all-plugins.test.ts']) { - const init = { headers: { 'accept-encoding': 'gzip' } } - const { response: getResponse } = await handler.handle(new Request(`http://localhost${path}`, init), { context: {}, prefix: '/rpc' }) - const { response } = await handler.handle(new Request(`http://localhost${path}`, { ...init, method: 'HEAD' }), { context: {}, prefix: '/rpc' }) + const { response: getResponse } = await handler.handle(new Request(`http://localhost${path}`, init), options) + const { response } = await handler.handle(new Request(`http://localhost${path}`, { ...init, method: 'HEAD' }), options) - expect(getResponse?.status).toEqual(200) - expect(response?.status).toEqual(200) - expect(Object.fromEntries(response!.headers)).toEqual(Object.fromEntries(getResponse!.headers)) - await expect(response!.text()).resolves.toEqual('') - } + expect(getResponse?.status).toEqual(200) + expect(response?.status).toEqual(200) + expect(Object.fromEntries(response!.headers)).toEqual(Object.fromEntries(getResponse!.headers)) + await expect(response!.text()).resolves.toEqual('') }) it('serves the openapi reference behind the other routing plugins', async () => { From 025fa4ce19bf21f7ef5949d02d2a0b20c764ff56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 00:29:23 +0000 Subject: [PATCH 3/4] fix(server): stop cancelling the form data boundary response in HeadMethodHandlerPlugin 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 Claude-Session: https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q --- packages/server/src/plugins/head-method.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/server/src/plugins/head-method.ts b/packages/server/src/plugins/head-method.ts index 5ca95e4780..f164e2e30d 100644 --- a/packages/server/src/plugins/head-method.ts +++ b/packages/server/src/plugins/head-method.ts @@ -105,12 +105,13 @@ function toHeadResponse(response: StandardResponse): StandardResponse { discardBody(encoded) // a stream, or a blob of unknown size } else if (encoded instanceof FormData) { - // Only encoding it reveals the boundary. The size is left out, since it would require reading every file. - const encodedResponse = new Response(encoded) - discardBody(encodedResponse.body) - + /** + * Only encoding it reveals the boundary. The encoded body is left unread, so no part is serialized, + * and uncancelled, since undici rejects unhandled when it is cancelled mid-serialization. + * The size is left out, since it would require reading every file. + */ headers['standard-server'] = [] - headers['content-type'] = encodedResponse.headers.get('content-type')! + headers['content-type'] = new Response(encoded).headers.get('content-type')! } else if (typeof encoded === 'string' || encoded instanceof URLSearchParams) { headers['standard-server'] ??= [] From 8666275e02515ff6087197cb118aa6e9283b29fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:39:49 +0000 Subject: [PATCH 4/4] refactor(server): trim HeadMethodHandlerPlugin comments covered by tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RbdUPzM3KpRnTFx9q --- packages/server/src/plugins/head-method.test.ts | 8 +------- packages/server/src/plugins/head-method.ts | 9 ++------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/server/src/plugins/head-method.test.ts b/packages/server/src/plugins/head-method.test.ts index f6b0834f8d..66a2c446af 100644 --- a/packages/server/src/plugins/head-method.test.ts +++ b/packages/server/src/plugins/head-method.test.ts @@ -25,9 +25,6 @@ function getInterceptors() { } } -/** - * Runs a response through the plugin as the answer to a `HEAD` request. - */ async function toHeadResponse(response: StandardResponse): Promise { const { bodyInterceptor } = getInterceptors() @@ -47,9 +44,6 @@ function getSentHeaders(response: StandardResponse): Record { return Object.fromEntries(toFetchResponse(response).headers) } -/** - * Expects the `HEAD` response to send the status and headers of `GET`, plus `extraHeaders`, without a body. - */ async function expectHeadersOfGet( createResponse: () => StandardResponse, extraHeaders: Record = {}, @@ -342,7 +336,7 @@ describe('headMethodHandlerPlugin', () => { stream: os.handler(async function* () { started = true - await new Promise(() => {}) // never settles + await new Promise(() => {}) }), }, { allowMethods: ['GET'], diff --git a/packages/server/src/plugins/head-method.ts b/packages/server/src/plugins/head-method.ts index f164e2e30d..65dd765e23 100644 --- a/packages/server/src/plugins/head-method.ts +++ b/packages/server/src/plugins/head-method.ts @@ -91,7 +91,6 @@ function toHeadResponse(response: StandardResponse): StandardResponse { if (isAsyncIteratorObject(body)) { discardBody(body) - // Encoding it would start iterating, so the headers are derived the way adapters do const headers: StandardHeaders = { ...response.headers, 'content-length': undefined } headers['content-type'] ??= 'text/event-stream' headers['standard-server'] = getRequiredStandardBodyHint('event-stream', headers) ?? [] @@ -102,14 +101,10 @@ function toHeadResponse(response: StandardResponse): StandardResponse { const [encoded, headers] = toFetchBody(body, response.headers) if (encoded instanceof ReadableStream) { - discardBody(encoded) // a stream, or a blob of unknown size + discardBody(encoded) } else if (encoded instanceof FormData) { - /** - * Only encoding it reveals the boundary. The encoded body is left unread, so no part is serialized, - * and uncancelled, since undici rejects unhandled when it is cancelled mid-serialization. - * The size is left out, since it would require reading every file. - */ + // The encoded body is never cancelled, since undici rejects unhandled when cancelled mid-serialization headers['standard-server'] = [] headers['content-type'] = new Response(encoded).headers.get('content-type')! }