Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/http/routing/route-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.status(204).send());
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.writeStatus).toHaveBeenCalledWith('204 No Content');
expect(mockUwsRes.end).toHaveBeenCalledWith();
});

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', () => {
Expand Down
84 changes: 66 additions & 18 deletions src/http/routing/route-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}

/**
Expand Down Expand Up @@ -892,7 +926,7 @@ export class RouteRegistry {
* @returns Map of route keys to route information
*/
getRoutes(): Map<string, RouteInfo> {
return new Map(this.routes);
return new Map([...this.routes].filter(([, route]) => !route.implicitHead));
}

/**
Expand All @@ -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;
}
}

/**
Expand Down