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/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 new file mode 100644 index 0000000000..66a2c446af --- /dev/null +++ b/packages/server/src/plugins/head-method.test.ts @@ -0,0 +1,372 @@ +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, + } +} + +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) +} + +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() + }) + + 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', + next, + 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: 'Ελλάδα' })], + ['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 headers = await expectHeadersOfGet(() => ({ + status: 200, + headers: {}, + body: new File(['hello'], 'hello world.txt', { type: 'text/plain' }), + })) + + 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 () => { + const cancel = vi.fn() + + class UnknownSizeBlob extends Blob { + override get size() { + return Number.NaN + } + + override stream() { + return new ReadableStream>({ cancel }) + } + } + + const headers = await expectHeadersOfGet(() => ({ status: 200, headers: {}, body: new UnknownSizeBlob([], { type: 'text/plain' }) })) + + 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 headers = await expectHeadersOfGet(() => ({ + status: 200, + headers: { 'content-type': 'video/mp4', 'content-length': '100' }, + body: new ReadableStream({ cancel }), + })) + + expect(headers['content-length']).toBe('100') + expect(cancel).toHaveBeenCalledOnce() + }) + + 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 + const body = (async function* () { + started = true + yield 1 + })() + + await toHeadResponse({ status: 200, headers: {}, body }) + + await expect(body.next()).resolves.toEqual({ done: true, value: undefined }) + expect(started).toBe(false) + }) + + 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(() => {}) + }), + }, { + 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..65dd765e23 --- /dev/null +++ b/packages/server/src/plugins/head-method.ts @@ -0,0 +1,128 @@ +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`. + */ + after = ['~response-compression'] + + 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 (interceptorOptions) => { + const { next, 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 + } + + if (isAsyncIteratorObject(body)) { + discardBody(body) + + const headers: StandardHeaders = { ...response.headers, 'content-length': undefined } + headers['content-type'] ??= 'text/event-stream' + headers['standard-server'] = getRequiredStandardBodyHint('event-stream', headers) ?? [] + + return { ...response, headers, body: createEmptyStream() } + } + + const [encoded, headers] = toFetchBody(body, response.headers) + + if (encoded instanceof ReadableStream) { + discardBody(encoded) + } + else if (encoded instanceof FormData) { + // 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')! + } + 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..061a63845a 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,23 @@ describe('all plugins on one handler and one link', () => { expect(exchanges[0]!.response.headers.get('content-encoding')).toEqual('gzip') }) + 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' } } + + 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('') + }) + it('serves the openapi reference behind the other routing plugins', async () => { const { handler } = createClientServer()