From 6c78b35af7f3c82d035099be1dab758017d7422b Mon Sep 17 00:00:00 2001 From: nvphungdev <283886185+nvphungdev@users.noreply.github.com> Date: Sat, 16 May 2026 05:23:35 +0700 Subject: [PATCH 1/2] fix: support implicit HEAD responses --- src/http/core/response.spec.ts | 14 ++++- src/http/core/response.ts | 17 +++++ src/http/routing/route-registry.spec.ts | 41 ++++++++++++ src/http/routing/route-registry.ts | 84 +++++++++++++++++++------ 4 files changed, 137 insertions(+), 19 deletions(-) diff --git a/src/http/core/response.spec.ts b/src/http/core/response.spec.ts index 222aae6..02d8d47 100644 --- a/src/http/core/response.spec.ts +++ b/src/http/core/response.spec.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import type { HttpResponse } from 'uWebSockets.js'; import { UwsResponse, HIGH_WATERMARK } from './response'; +import { UwsRequest } from './request'; import { Readable } from 'stream'; -import { createMockUwsResponse } from '../test-helpers'; +import { createMockUwsRequest, createMockUwsResponse } from '../test-helpers'; describe('UwsResponse', () => { let mockUwsRes: jest.Mocked; @@ -520,6 +521,17 @@ describe('UwsResponse', () => { expect(mockUwsRes.end).not.toHaveBeenCalled(); }); + it('should suppress response body for HEAD requests', () => { + const mockUwsReq = createMockUwsRequest({ method: 'HEAD' }); + const req = new UwsRequest(mockUwsReq, mockUwsRes); + res.bindRequest(req); + + res.send('Hello World'); + + expect(mockUwsRes.endWithoutBody).toHaveBeenCalledWith(Buffer.byteLength('Hello World')); + expect(mockUwsRes.end).not.toHaveBeenCalled(); + }); + it('should use custom status code', () => { res.status(404).send('Not Found'); expect(mockUwsRes.writeStatus).toHaveBeenCalledWith('404 Not Found'); diff --git a/src/http/core/response.ts b/src/http/core/response.ts index 46d8b8f..4aadcc8 100644 --- a/src/http/core/response.ts +++ b/src/http/core/response.ts @@ -1390,6 +1390,23 @@ export class UwsResponse extends Writable { } private endResponse(body: string | Buffer | undefined): void { + if (this.req?.method === 'HEAD') { + const bodyLength = + body === undefined + ? this.contentLengthTotal + : Buffer.isBuffer(body) + ? body.length + : Buffer.byteLength(body); + + if (bodyLength !== undefined) { + this.uwsRes.endWithoutBody(bodyLength); + return; + } + + this.uwsRes.end(); + return; + } + if (body !== undefined) { this.uwsRes.end(body); return; diff --git a/src/http/routing/route-registry.spec.ts b/src/http/routing/route-registry.spec.ts index 05305b3..fd1c72c 100644 --- a/src/http/routing/route-registry.spec.ts +++ b/src/http/routing/route-registry.spec.ts @@ -69,6 +69,47 @@ describe('RouteRegistry', () => { // Verify route lookup is also case-insensitive expect(registry.hasRoute('GET', '/users')).toBe(true); }); + + it('should implicitly register HEAD for GET routes', async () => { + const handler = jest.fn((_req, res) => res.send('item')); + registry.register('GET', '/items/:id', handler); + + expect(mockUwsApp.get).toHaveBeenCalledWith('/items/:id', expect.any(Function)); + expect(mockUwsApp.head).toHaveBeenCalledWith('/items/:id', expect.any(Function)); + expect(registry.hasRoute('HEAD', '/items/:id')).toBe(true); + expect(registry.getRouteCount()).toBe(1); + + const route = registeredRoutes.get('HEAD:/items/:id'); + expect(route).toBeDefined(); + + const { mockUwsRes, mockUwsReq } = createMockUwsReqRes('head', '/items/42'); + await route!.handler(mockUwsRes, mockUwsReq); + + expect(handler).toHaveBeenCalledTimes(1); + expect(mockUwsRes.endWithoutBody).toHaveBeenCalledWith(Buffer.byteLength('item')); + expect(mockUwsRes.end).not.toHaveBeenCalled(); + }); + + it('should let an explicit HEAD route override an implicit GET fallback', async () => { + const getHandler = jest.fn((_req, res) => res.send('get')); + const headHandler = jest.fn((_req, res) => res.status(204).send()); + + registry.register('GET', '/items/:id', getHandler); + registry.register('HEAD', '/items/:id', headHandler); + + expect(mockUwsApp.head).toHaveBeenCalledTimes(1); + + const route = registeredRoutes.get('HEAD:/items/:id'); + expect(route).toBeDefined(); + + const { mockUwsRes, mockUwsReq } = createMockUwsReqRes('head', '/items/42'); + await route!.handler(mockUwsRes, mockUwsReq); + + expect(getHandler).not.toHaveBeenCalled(); + expect(headHandler).toHaveBeenCalledTimes(1); + expect(mockUwsRes.writeStatus).toHaveBeenCalledWith('204 No Content'); + expect(mockUwsRes.end).toHaveBeenCalledWith(); + }); }); describe('path handling', () => { diff --git a/src/http/routing/route-registry.ts b/src/http/routing/route-registry.ts index ea03379..8381012 100644 --- a/src/http/routing/route-registry.ts +++ b/src/http/routing/route-registry.ts @@ -84,6 +84,7 @@ export interface RouteInfo { isComplex: boolean; // Uses regex matching instead of native uWS handler: RouteHandler; // Store the handler metadata?: RouteMetadata; // Middleware metadata + implicitHead?: boolean; // Auto-registered HEAD fallback for GET routes } /** @@ -211,7 +212,13 @@ export class RouteRegistry { * @param metadata - Optional middleware metadata (guards, pipes, filters) * @throws Error if route is already registered */ - register(method: string, path: string, handler: RouteHandler, metadata?: RouteMetadata): void { + register( + method: string, + path: string, + handler: RouteHandler, + metadata?: RouteMetadata, + implicitHead = false + ): void { // Convert method to uWS format and normalize to uppercase for consistency const uwsMethod = this.convertMethod(method); const normalizedMethod = method.toUpperCase(); @@ -226,15 +233,41 @@ export class RouteRegistry { // Check for duplicate route registration using normalized method const routeKey = `${normalizedMethod}:${path}`; - if (this.routes.has(routeKey)) { + const existingRoute = this.routes.get(routeKey); + if (existingRoute) { + if (implicitHead) { + return; + } + + if (normalizedMethod === 'HEAD' && existingRoute.implicitHead) { + const routeInfo = { + method: normalizedMethod, + path, + uwsPath, + pattern, + paramNames, + isComplex, + handler, + metadata, + }; + + this.routes.set(routeKey, routeInfo); + if (isComplex) { + const staticPrefix = this.extractStaticPrefix(path); + const registrationPath = staticPrefix ? `${staticPrefix}/*` : '/*'; + const wildcardKey = `${uwsMethod}:${registrationPath}`; + this.replaceComplexRoute(wildcardKey, routeInfo); + } + return; + } + throw new Error( `Route already registered: ${normalizedMethod} ${path}. ` + `Duplicate route registration is not allowed as it would cause multiple handlers to execute for the same route.` ); } - // Track registered route with normalized method - this.routes.set(routeKey, { + const routeInfo = { method: normalizedMethod, path, uwsPath, @@ -243,7 +276,11 @@ export class RouteRegistry { isComplex, handler, metadata, - }); + implicitHead, + }; + + // Track registered route with normalized method + this.routes.set(routeKey, routeInfo); // Get the uWS method function const uwsMethodFn = this.uwsApp[uwsMethod as keyof uWS.TemplatedApp] as any; @@ -345,22 +382,15 @@ export class RouteRegistry { } // Add this route to the wildcard's route list - this.complexRoutesByWildcard.get(wildcardKey)!.push({ - method: normalizedMethod, - path, - uwsPath, - pattern, - paramNames, - isComplex, - handler, - metadata, - }); + this.complexRoutesByWildcard.get(wildcardKey)!.push(routeInfo); } else { // Simple route - use native uWS routing uwsMethodFn.call( this.uwsApp, uwsPath, async (uwsRes: uWS.HttpResponse, uwsReq: uWS.HttpRequest) => { + const activeRoute = this.routes.get(routeKey) ?? routeInfo; + // Create request/response wrappers const req = new UwsRequest(uwsReq, uwsRes, paramNames); const res = new UwsResponse(uwsRes); @@ -383,10 +413,14 @@ export class RouteRegistry { ); // Execute handler with error handling - await this.executeHandler(handler, req, res, metadata); + await this.executeHandler(activeRoute.handler, req, res, activeRoute.metadata); } ); } + + if (normalizedMethod === 'GET' && !implicitHead) { + this.register('HEAD', path, handler, metadata, true); + } } /** @@ -892,7 +926,7 @@ export class RouteRegistry { * @returns Map of route keys to route information */ getRoutes(): Map { - return new Map(this.routes); + return new Map([...this.routes].filter(([, route]) => !route.implicitHead)); } /** @@ -914,7 +948,21 @@ export class RouteRegistry { * @returns Number of registered routes */ getRouteCount(): number { - return this.routes.size; + return [...this.routes.values()].filter((route) => !route.implicitHead).length; + } + + private replaceComplexRoute(wildcardKey: string, routeInfo: RouteInfo): void { + const routes = this.complexRoutesByWildcard.get(wildcardKey); + if (!routes) { + return; + } + + const routeIndex = routes.findIndex( + (route) => route.method === routeInfo.method && route.path === routeInfo.path + ); + if (routeIndex !== -1) { + routes[routeIndex] = routeInfo; + } } /** From c00bcb4553ab9e9712ca4dbc3a04cd6773d66fec Mon Sep 17 00:00:00 2001 From: nvphungdev <283886185+nvphungdev@users.noreply.github.com> Date: Sat, 16 May 2026 14:50:07 +0700 Subject: [PATCH 2/2] fix: narrow HEAD fallback changes to routing --- src/http/core/response.spec.ts | 14 +------------- src/http/core/response.ts | 17 ----------------- src/http/routing/route-registry.spec.ts | 6 +++--- 3 files changed, 4 insertions(+), 33 deletions(-) diff --git a/src/http/core/response.spec.ts b/src/http/core/response.spec.ts index 02d8d47..222aae6 100644 --- a/src/http/core/response.spec.ts +++ b/src/http/core/response.spec.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import type { HttpResponse } from 'uWebSockets.js'; import { UwsResponse, HIGH_WATERMARK } from './response'; -import { UwsRequest } from './request'; import { Readable } from 'stream'; -import { createMockUwsRequest, createMockUwsResponse } from '../test-helpers'; +import { createMockUwsResponse } from '../test-helpers'; describe('UwsResponse', () => { let mockUwsRes: jest.Mocked; @@ -521,17 +520,6 @@ describe('UwsResponse', () => { expect(mockUwsRes.end).not.toHaveBeenCalled(); }); - it('should suppress response body for HEAD requests', () => { - const mockUwsReq = createMockUwsRequest({ method: 'HEAD' }); - const req = new UwsRequest(mockUwsReq, mockUwsRes); - res.bindRequest(req); - - res.send('Hello World'); - - expect(mockUwsRes.endWithoutBody).toHaveBeenCalledWith(Buffer.byteLength('Hello World')); - expect(mockUwsRes.end).not.toHaveBeenCalled(); - }); - it('should use custom status code', () => { res.status(404).send('Not Found'); expect(mockUwsRes.writeStatus).toHaveBeenCalledWith('404 Not Found'); diff --git a/src/http/core/response.ts b/src/http/core/response.ts index 4aadcc8..46d8b8f 100644 --- a/src/http/core/response.ts +++ b/src/http/core/response.ts @@ -1390,23 +1390,6 @@ export class UwsResponse extends Writable { } private endResponse(body: string | Buffer | undefined): void { - if (this.req?.method === 'HEAD') { - const bodyLength = - body === undefined - ? this.contentLengthTotal - : Buffer.isBuffer(body) - ? body.length - : Buffer.byteLength(body); - - if (bodyLength !== undefined) { - this.uwsRes.endWithoutBody(bodyLength); - return; - } - - this.uwsRes.end(); - return; - } - if (body !== undefined) { this.uwsRes.end(body); return; diff --git a/src/http/routing/route-registry.spec.ts b/src/http/routing/route-registry.spec.ts index fd1c72c..3bfa6af 100644 --- a/src/http/routing/route-registry.spec.ts +++ b/src/http/routing/route-registry.spec.ts @@ -71,7 +71,7 @@ describe('RouteRegistry', () => { }); it('should implicitly register HEAD for GET routes', async () => { - const handler = jest.fn((_req, res) => res.send('item')); + const handler = jest.fn((_req, res) => res.status(204).send()); registry.register('GET', '/items/:id', handler); expect(mockUwsApp.get).toHaveBeenCalledWith('/items/:id', expect.any(Function)); @@ -86,8 +86,8 @@ describe('RouteRegistry', () => { await route!.handler(mockUwsRes, mockUwsReq); expect(handler).toHaveBeenCalledTimes(1); - expect(mockUwsRes.endWithoutBody).toHaveBeenCalledWith(Buffer.byteLength('item')); - expect(mockUwsRes.end).not.toHaveBeenCalled(); + expect(mockUwsRes.writeStatus).toHaveBeenCalledWith('204 No Content'); + expect(mockUwsRes.end).toHaveBeenCalledWith(); }); it('should let an explicit HEAD route override an implicit GET fallback', async () => {