Skip to content

Commit dbae482

Browse files
Merge pull request #183 from FOSSFORGE/fix/routing
fix(routing): match trailing-slash paths for Express compatibility
2 parents d358f73 + 4dcf02f commit dbae482

3 files changed

Lines changed: 45 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.0.1] - 2026-05-20
11+
12+
### Fixed
13+
- trailing slash routes return 404 #182
14+
1015
## [2.0.0] - 2026-05-20
1116

1217
### Added
@@ -149,6 +154,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
149154
- Exception handling with WsException class
150155
- Broadcast operator with room targeting and client exclusion
151156

152-
[Unreleased]: https://github.com/FOSSFORGE/uWestJS/compare/v1.0.1...HEAD
157+
[Unreleased]: https://github.com/FOSSFORGE/uWestJS/compare/v2.0.1...HEAD
158+
[2.0.1]: https://github.com/FOSSFORGE/uWestJS/compare/v2.0.0...v2.0.1
159+
[2.0.0]: https://github.com/FOSSFORGE/uWestJS/compare/v1.0.1...v2.0.0
153160
[1.0.1]: https://github.com/FOSSFORGE/uWestJS/compare/v1.0.0...v1.0.1
154161
[1.0.0]: https://github.com/FOSSFORGE/uWestJS/releases/tag/v1.0.0

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('RouteRegistry', () => {
9797
registry.register('GET', '/items/:id', getHandler);
9898
registry.register('HEAD', '/items/:id', headHandler);
9999

100-
expect(mockUwsApp.head).toHaveBeenCalledTimes(1);
100+
expect(mockUwsApp.head).toHaveBeenCalledTimes(2);
101101

102102
const route = registeredRoutes.get('HEAD:/items/:id');
103103
expect(route).toBeDefined();

src/http/routing/route-registry.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export interface RouteInfo {
8585
handler: RouteHandler; // Store the handler
8686
metadata?: RouteMetadata; // Middleware metadata
8787
implicitHead?: boolean; // Auto-registered HEAD fallback for GET routes
88+
trailingSlash?: boolean; // Auto-registered trailing-slash variant for Express compatibility
8889
}
8990

9091
/**
@@ -252,6 +253,11 @@ export class RouteRegistry {
252253
};
253254

254255
this.routes.set(routeKey, routeInfo);
256+
// Also update trailing-slash variant if it exists
257+
const slashRouteKey = `${normalizedMethod}:${path}/`;
258+
if (this.routes.has(slashRouteKey)) {
259+
this.routes.set(slashRouteKey, { ...routeInfo, trailingSlash: true });
260+
}
255261
if (isComplex) {
256262
const staticPrefix = this.extractStaticPrefix(path);
257263
const registrationPath = staticPrefix ? `${staticPrefix}/*` : '/*';
@@ -385,11 +391,9 @@ export class RouteRegistry {
385391
this.complexRoutesByWildcard.get(wildcardKey)!.push(routeInfo);
386392
} else {
387393
// Simple route - use native uWS routing
388-
uwsMethodFn.call(
389-
this.uwsApp,
390-
uwsPath,
391-
async (uwsRes: uWS.HttpResponse, uwsReq: uWS.HttpRequest) => {
392-
const activeRoute = this.routes.get(routeKey)!;
394+
const createHandler =
395+
(key: string) => async (uwsRes: uWS.HttpResponse, uwsReq: uWS.HttpRequest) => {
396+
const activeRoute = this.routes.get(key)!;
393397

394398
// Create request/response wrappers
395399
const req = new UwsRequest(uwsReq, uwsRes, paramNames);
@@ -414,8 +418,26 @@ export class RouteRegistry {
414418

415419
// Execute handler with error handling
416420
await this.executeHandler(activeRoute.handler, req, res, activeRoute.metadata);
421+
};
422+
423+
uwsMethodFn.call(this.uwsApp, uwsPath, createHandler(routeKey));
424+
425+
// Also register trailing-slash variant for Express compatibility
426+
// (e.g. /api and /api/ should both match the same route)
427+
if (!uwsPath.endsWith('/') && uwsPath !== '/') {
428+
const slashRouteKey = `${normalizedMethod}:${path}/`;
429+
if (!this.routes.has(slashRouteKey)) {
430+
this.routes.set(slashRouteKey, { ...routeInfo, trailingSlash: true });
431+
uwsMethodFn.call(this.uwsApp, uwsPath + '/', createHandler(slashRouteKey));
417432
}
418-
);
433+
} else if (uwsPath.endsWith('/') && uwsPath !== '/') {
434+
const nonTrailingPath = uwsPath.slice(0, -1);
435+
const companionKey = `${normalizedMethod}:${path.slice(0, -1)}`;
436+
if (!this.routes.has(companionKey)) {
437+
this.routes.set(companionKey, { ...routeInfo, trailingSlash: false });
438+
uwsMethodFn.call(this.uwsApp, nonTrailingPath, createHandler(companionKey));
439+
}
440+
}
419441
}
420442

421443
if (normalizedMethod === 'GET' && !implicitHead) {
@@ -935,7 +957,11 @@ export class RouteRegistry {
935957
* @returns Map of route keys to route information
936958
*/
937959
getRoutes(): Map<string, RouteInfo> {
938-
return new Map([...this.routes].filter(([, route]) => !route.implicitHead));
960+
return new Map(
961+
[...this.routes].filter(
962+
([, route]) => !route.implicitHead && route.trailingSlash === undefined
963+
)
964+
);
939965
}
940966

941967
/**
@@ -957,7 +983,9 @@ export class RouteRegistry {
957983
* @returns Number of registered routes
958984
*/
959985
getRouteCount(): number {
960-
return [...this.routes.values()].filter((route) => !route.implicitHead).length;
986+
return [...this.routes.values()].filter(
987+
(route) => !route.implicitHead && route.trailingSlash === undefined
988+
).length;
961989
}
962990

963991
private replaceComplexRoute(wildcardKey: string, routeInfo: RouteInfo): void {

0 commit comments

Comments
 (0)