Skip to content

Commit a0b1083

Browse files
Merge pull request #152 from nvphungdev/fix/head-request-handling
fix: support HEAD requests for GET routes
2 parents 626344e + 3c28051 commit a0b1083

2 files changed

Lines changed: 107 additions & 18 deletions

File tree

src/http/routing/route-registry.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,47 @@ describe('RouteRegistry', () => {
6969
// Verify route lookup is also case-insensitive
7070
expect(registry.hasRoute('GET', '/users')).toBe(true);
7171
});
72+
73+
it('should implicitly register HEAD for GET routes', async () => {
74+
const handler = jest.fn((_req, res) => res.status(204).send());
75+
registry.register('GET', '/items/:id', handler);
76+
77+
expect(mockUwsApp.get).toHaveBeenCalledWith('/items/:id', expect.any(Function));
78+
expect(mockUwsApp.head).toHaveBeenCalledWith('/items/:id', expect.any(Function));
79+
expect(registry.hasRoute('HEAD', '/items/:id')).toBe(true);
80+
expect(registry.getRouteCount()).toBe(1);
81+
82+
const route = registeredRoutes.get('HEAD:/items/:id');
83+
expect(route).toBeDefined();
84+
85+
const { mockUwsRes, mockUwsReq } = createMockUwsReqRes('head', '/items/42');
86+
await route!.handler(mockUwsRes, mockUwsReq);
87+
88+
expect(handler).toHaveBeenCalledTimes(1);
89+
expect(mockUwsRes.writeStatus).toHaveBeenCalledWith('204 No Content');
90+
expect(mockUwsRes.end).toHaveBeenCalledWith();
91+
});
92+
93+
it('should let an explicit HEAD route override an implicit GET fallback', async () => {
94+
const getHandler = jest.fn((_req, res) => res.send('get'));
95+
const headHandler = jest.fn((_req, res) => res.status(204).send());
96+
97+
registry.register('GET', '/items/:id', getHandler);
98+
registry.register('HEAD', '/items/:id', headHandler);
99+
100+
expect(mockUwsApp.head).toHaveBeenCalledTimes(1);
101+
102+
const route = registeredRoutes.get('HEAD:/items/:id');
103+
expect(route).toBeDefined();
104+
105+
const { mockUwsRes, mockUwsReq } = createMockUwsReqRes('head', '/items/42');
106+
await route!.handler(mockUwsRes, mockUwsReq);
107+
108+
expect(getHandler).not.toHaveBeenCalled();
109+
expect(headHandler).toHaveBeenCalledTimes(1);
110+
expect(mockUwsRes.writeStatus).toHaveBeenCalledWith('204 No Content');
111+
expect(mockUwsRes.end).toHaveBeenCalledWith();
112+
});
72113
});
73114

74115
describe('path handling', () => {

src/http/routing/route-registry.ts

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export interface RouteInfo {
8484
isComplex: boolean; // Uses regex matching instead of native uWS
8585
handler: RouteHandler; // Store the handler
8686
metadata?: RouteMetadata; // Middleware metadata
87+
implicitHead?: boolean; // Auto-registered HEAD fallback for GET routes
8788
}
8889

8990
/**
@@ -211,7 +212,13 @@ export class RouteRegistry {
211212
* @param metadata - Optional middleware metadata (guards, pipes, filters)
212213
* @throws Error if route is already registered
213214
*/
214-
register(method: string, path: string, handler: RouteHandler, metadata?: RouteMetadata): void {
215+
register(
216+
method: string,
217+
path: string,
218+
handler: RouteHandler,
219+
metadata?: RouteMetadata,
220+
implicitHead = false
221+
): void {
215222
// Convert method to uWS format and normalize to uppercase for consistency
216223
const uwsMethod = this.convertMethod(method);
217224
const normalizedMethod = method.toUpperCase();
@@ -226,15 +233,41 @@ export class RouteRegistry {
226233

227234
// Check for duplicate route registration using normalized method
228235
const routeKey = `${normalizedMethod}:${path}`;
229-
if (this.routes.has(routeKey)) {
236+
const existingRoute = this.routes.get(routeKey);
237+
if (existingRoute) {
238+
if (implicitHead) {
239+
return;
240+
}
241+
242+
if (normalizedMethod === 'HEAD' && existingRoute.implicitHead) {
243+
const routeInfo = {
244+
method: normalizedMethod,
245+
path,
246+
uwsPath,
247+
pattern,
248+
paramNames,
249+
isComplex,
250+
handler,
251+
metadata,
252+
};
253+
254+
this.routes.set(routeKey, routeInfo);
255+
if (isComplex) {
256+
const staticPrefix = this.extractStaticPrefix(path);
257+
const registrationPath = staticPrefix ? `${staticPrefix}/*` : '/*';
258+
const wildcardKey = `${uwsMethod}:${registrationPath}`;
259+
this.replaceComplexRoute(wildcardKey, routeInfo);
260+
}
261+
return;
262+
}
263+
230264
throw new Error(
231265
`Route already registered: ${normalizedMethod} ${path}. ` +
232266
`Duplicate route registration is not allowed as it would cause multiple handlers to execute for the same route.`
233267
);
234268
}
235269

236-
// Track registered route with normalized method
237-
this.routes.set(routeKey, {
270+
const routeInfo = {
238271
method: normalizedMethod,
239272
path,
240273
uwsPath,
@@ -243,7 +276,11 @@ export class RouteRegistry {
243276
isComplex,
244277
handler,
245278
metadata,
246-
});
279+
implicitHead,
280+
};
281+
282+
// Track registered route with normalized method
283+
this.routes.set(routeKey, routeInfo);
247284

248285
// Get the uWS method function
249286
const uwsMethodFn = this.uwsApp[uwsMethod as keyof uWS.TemplatedApp] as any;
@@ -345,22 +382,15 @@ export class RouteRegistry {
345382
}
346383

347384
// Add this route to the wildcard's route list
348-
this.complexRoutesByWildcard.get(wildcardKey)!.push({
349-
method: normalizedMethod,
350-
path,
351-
uwsPath,
352-
pattern,
353-
paramNames,
354-
isComplex,
355-
handler,
356-
metadata,
357-
});
385+
this.complexRoutesByWildcard.get(wildcardKey)!.push(routeInfo);
358386
} else {
359387
// Simple route - use native uWS routing
360388
uwsMethodFn.call(
361389
this.uwsApp,
362390
uwsPath,
363391
async (uwsRes: uWS.HttpResponse, uwsReq: uWS.HttpRequest) => {
392+
const activeRoute = this.routes.get(routeKey)!;
393+
364394
// Create request/response wrappers
365395
const req = new UwsRequest(uwsReq, uwsRes, paramNames);
366396
const res = new UwsResponse(uwsRes);
@@ -383,10 +413,14 @@ export class RouteRegistry {
383413
);
384414

385415
// Execute handler with error handling
386-
await this.executeHandler(handler, req, res, metadata);
416+
await this.executeHandler(activeRoute.handler, req, res, activeRoute.metadata);
387417
}
388418
);
389419
}
420+
421+
if (normalizedMethod === 'GET' && !implicitHead) {
422+
this.register('HEAD', path, handler, metadata, true);
423+
}
390424
}
391425

392426
/**
@@ -896,7 +930,7 @@ export class RouteRegistry {
896930
* @returns Map of route keys to route information
897931
*/
898932
getRoutes(): Map<string, RouteInfo> {
899-
return new Map(this.routes);
933+
return new Map([...this.routes].filter(([, route]) => !route.implicitHead));
900934
}
901935

902936
/**
@@ -918,7 +952,21 @@ export class RouteRegistry {
918952
* @returns Number of registered routes
919953
*/
920954
getRouteCount(): number {
921-
return this.routes.size;
955+
return [...this.routes.values()].filter((route) => !route.implicitHead).length;
956+
}
957+
958+
private replaceComplexRoute(wildcardKey: string, routeInfo: RouteInfo): void {
959+
const routes = this.complexRoutesByWildcard.get(wildcardKey);
960+
if (!routes) {
961+
return;
962+
}
963+
964+
const routeIndex = routes.findIndex(
965+
(route) => route.method === routeInfo.method && route.path === routeInfo.path
966+
);
967+
if (routeIndex !== -1) {
968+
routes[routeIndex] = routeInfo;
969+
}
922970
}
923971

924972
/**

0 commit comments

Comments
 (0)