From e9371344f6974566374e2d08a0012c0f0aaf739d Mon Sep 17 00:00:00 2001 From: lifefloating Date: Fri, 1 Aug 2025 17:26:54 +0800 Subject: [PATCH 1/2] feat(ws): Support for dynamic path params in websocket, add tests --- .../websockets/e2e/wildcard-param.spec.ts | 267 ++++++++++++++++++ .../websockets/src/wildcard-param.gateway.ts | 82 ++++++ packages/platform-ws/adapters/ws-adapter.ts | 95 ++++++- packages/platform-ws/package.json | 1 + packages/websockets/decorators/index.ts | 1 + .../decorators/ws-param.decorator.ts | 60 ++++ .../websockets/enums/ws-paramtype.enum.ts | 1 + .../websockets/factories/ws-params-factory.ts | 15 + .../test/factories/ws-params-factory.spec.ts | 92 ++++++ 9 files changed, 611 insertions(+), 3 deletions(-) create mode 100644 integration/websockets/e2e/wildcard-param.spec.ts create mode 100644 integration/websockets/src/wildcard-param.gateway.ts create mode 100644 packages/websockets/decorators/ws-param.decorator.ts diff --git a/integration/websockets/e2e/wildcard-param.spec.ts b/integration/websockets/e2e/wildcard-param.spec.ts new file mode 100644 index 00000000000..a8921f7d22b --- /dev/null +++ b/integration/websockets/e2e/wildcard-param.spec.ts @@ -0,0 +1,267 @@ +import { INestApplication } from '@nestjs/common'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { Test } from '@nestjs/testing'; +import { expect } from 'chai'; +import * as WebSocket from 'ws'; +import { + WildcardParamGateway, + MultipleParamsGateway, +} from '../src/wildcard-param.gateway'; + +async function createNestApp(...gateways: any[]): Promise { + const testingModule = await Test.createTestingModule({ + providers: gateways, + }).compile(); + const app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app) as any); + return app; +} + +describe('WebSocket Wildcard URL Parameters', () => { + let app: INestApplication; + let ws: WebSocket; + + afterEach(async () => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + if (app) { + await app.close(); + } + }); + + describe('Single Parameter Gateway', () => { + beforeEach(async () => { + app = await createNestApp(WildcardParamGateway); + await app.listen(3000); + }); + + it('should extract roomId parameter from URL path', async () => { + const roomId = 'test-room-123'; + ws = new WebSocket(`ws://localhost:3000/chat/${roomId}/socket`); + + await new Promise(resolve => ws.on('open', resolve)); + + const testMessage = { message: 'Hello World' }; + ws.send( + JSON.stringify({ + event: 'join', + data: testMessage, + }), + ); + + await new Promise(resolve => { + ws.on('message', data => { + const response = JSON.parse(data.toString()); + expect(response.event).to.equal('joined'); + expect(response.data.roomId).to.equal(roomId); + expect(response.data.message).to.equal(testMessage.message); + expect(response.data.timestamp).to.be.a('string'); + resolve(); + }); + }); + }); + + it('should handle different roomId values', async () => { + const roomId = 'room-with-dashes-and-numbers-456'; + ws = new WebSocket(`ws://localhost:3000/chat/${roomId}/socket`); + + await new Promise(resolve => ws.on('open', resolve)); + + ws.send( + JSON.stringify({ + event: 'join', + data: { message: 'Different room test' }, + }), + ); + + await new Promise(resolve => { + ws.on('message', data => { + const response = JSON.parse(data.toString()); + expect(response.data.roomId).to.equal(roomId); + resolve(); + }); + }); + }); + + it('should return all parameters when using @WsParam() without argument', async () => { + const roomId = 'all-params-test'; + ws = new WebSocket(`ws://localhost:3000/chat/${roomId}/socket`); + + await new Promise(resolve => ws.on('open', resolve)); + + const testData = { info: 'test data' }; + ws.send( + JSON.stringify({ + event: 'getAllParams', + data: testData, + }), + ); + + await new Promise(resolve => { + ws.on('message', data => { + const response = JSON.parse(data.toString()); + expect(response.event).to.equal('allParams'); + expect(response.data.params).to.be.an('object'); + expect(response.data.params.roomId).to.equal(roomId); + expect(response.data.receivedData).to.deep.equal(testData); + resolve(); + }); + }); + }); + + it('should handle URL encoded parameters', async () => { + const roomId = 'room%20with%20spaces'; + ws = new WebSocket(`ws://localhost:3000/chat/${roomId}/socket`); + + await new Promise(resolve => ws.on('open', resolve)); + + ws.send( + JSON.stringify({ + event: 'join', + data: { message: 'Encoded test' }, + }), + ); + + await new Promise(resolve => { + ws.on('message', data => { + const response = JSON.parse(data.toString()); + expect(response.data.roomId).to.equal('room with spaces'); + resolve(); + }); + }); + }); + }); + + describe('Multiple Parameters Gateway', () => { + beforeEach(async () => { + app = await createNestApp(MultipleParamsGateway); + await app.listen(3000); + }); + + it('should extract multiple parameters from complex URL path', async () => { + const gameId = 'game-123'; + const roomId = 'room-456'; + const playerId = 'player-789'; + + ws = new WebSocket( + `ws://localhost:3000/game/${gameId}/room/${roomId}/player/${playerId}/socket`, + ); + + await new Promise(resolve => ws.on('open', resolve)); + + const moveData = { x: 10, y: 20, action: 'attack' }; + ws.send( + JSON.stringify({ + event: 'move', + data: moveData, + }), + ); + + await new Promise(resolve => { + ws.on('message', data => { + const response = JSON.parse(data.toString()); + expect(response.event).to.equal('moveProcessed'); + expect(response.data.gameId).to.equal(gameId); + expect(response.data.roomId).to.equal(roomId); + expect(response.data.playerId).to.equal(playerId); + expect(response.data.move).to.deep.equal(moveData); + expect(response.data.timestamp).to.be.a('string'); + resolve(); + }); + }); + }); + + it('should get all parameters as object in multiple params scenario', async () => { + const gameId = 'test-game'; + const roomId = 'test-room'; + const playerId = 'test-player'; + + ws = new WebSocket( + `ws://localhost:3000/game/${gameId}/room/${roomId}/player/${playerId}/socket`, + ); + + await new Promise(resolve => ws.on('open', resolve)); + + ws.send( + JSON.stringify({ + event: 'status', + data: {}, + }), + ); + + await new Promise(resolve => { + ws.on('message', data => { + const response = JSON.parse(data.toString()); + expect(response.event).to.equal('statusUpdate'); + expect(response.data.gameId).to.equal(gameId); + expect(response.data.roomId).to.equal(roomId); + expect(response.data.playerId).to.equal(playerId); + expect(response.data.status).to.equal('active'); + resolve(); + }); + }); + }); + + it('should handle numeric-like parameters as strings', async () => { + const gameId = '12345'; + const roomId = '67890'; + const playerId = '99999'; + + ws = new WebSocket( + `ws://localhost:3000/game/${gameId}/room/${roomId}/player/${playerId}/socket`, + ); + + await new Promise(resolve => ws.on('open', resolve)); + + ws.send( + JSON.stringify({ + event: 'move', + data: { test: 'numeric params' }, + }), + ); + + await new Promise(resolve => { + ws.on('message', data => { + const response = JSON.parse(data.toString()); + expect(response.data.gameId).to.equal('12345'); + expect(response.data.roomId).to.equal('67890'); + expect(response.data.playerId).to.equal('99999'); + expect(typeof response.data.gameId).to.equal('string'); + expect(typeof response.data.roomId).to.equal('string'); + expect(typeof response.data.playerId).to.equal('string'); + resolve(); + }); + }); + }); + }); + + describe('Error Handling', () => { + beforeEach(async () => { + app = await createNestApp(WildcardParamGateway); + await app.listen(3000); + }); + + it('should fail to connect to non-matching static path', async () => { + const promise = new Promise((resolve, reject) => { + ws = new WebSocket('ws://localhost:3000/invalid-path'); + ws.on('open', () => reject(new Error('Should not connect'))); + ws.on('error', () => resolve('Expected error')); + setTimeout(() => resolve('Timeout as expected'), 1000); + }); + + await promise; + }); + + it('should fail to connect to path missing required parameters', async () => { + const promise = new Promise((resolve, reject) => { + ws = new WebSocket('ws://localhost:3000/chat/socket'); // Missing roomId + ws.on('open', () => reject(new Error('Should not connect'))); + ws.on('error', () => resolve('Expected error')); + setTimeout(() => resolve('Timeout as expected'), 1000); + }); + + await promise; + }); + }); +}); diff --git a/integration/websockets/src/wildcard-param.gateway.ts b/integration/websockets/src/wildcard-param.gateway.ts new file mode 100644 index 00000000000..84e228bb87a --- /dev/null +++ b/integration/websockets/src/wildcard-param.gateway.ts @@ -0,0 +1,82 @@ +import { + ConnectedSocket, + MessageBody, + SubscribeMessage, + WebSocketGateway, +} from '@nestjs/websockets'; +import { WsParam } from '@nestjs/websockets'; + +@WebSocketGateway({ + path: '/chat/:roomId/socket', +}) +export class WildcardParamGateway { + @SubscribeMessage('join') + handleJoin( + @ConnectedSocket() client: any, + @MessageBody() data: any, + @WsParam('roomId') roomId: string, + ) { + return { + event: 'joined', + data: { + roomId, + message: data.message, + timestamp: new Date().toISOString(), + }, + }; + } + + @SubscribeMessage('getAllParams') + handleGetAllParams( + @ConnectedSocket() client: any, + @MessageBody() data: any, + @WsParam() params: Record, + ) { + return { + event: 'allParams', + data: { + params, + receivedData: data, + }, + }; + } +} + +@WebSocketGateway({ + path: '/game/:gameId/room/:roomId/player/:playerId/socket', +}) +export class MultipleParamsGateway { + @SubscribeMessage('move') + handleMove( + @ConnectedSocket() client: any, + @MessageBody() moveData: any, + @WsParam('gameId') gameId: string, + @WsParam('roomId') roomId: string, + @WsParam('playerId') playerId: string, + ) { + return { + event: 'moveProcessed', + data: { + gameId, + roomId, + playerId, + move: moveData, + timestamp: new Date().toISOString(), + }, + }; + } + + @SubscribeMessage('status') + handleStatus( + @ConnectedSocket() client: any, + @WsParam() allParams: Record, + ) { + return { + event: 'statusUpdate', + data: { + ...allParams, + status: 'active', + }, + }; + } +} diff --git a/packages/platform-ws/adapters/ws-adapter.ts b/packages/platform-ws/adapters/ws-adapter.ts index 6292468ad89..9e1f3110bbc 100644 --- a/packages/platform-ws/adapters/ws-adapter.ts +++ b/packages/platform-ws/adapters/ws-adapter.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/websockets/constants'; import { MessageMappingProperties } from '@nestjs/websockets/gateway-metadata-explorer'; import * as http from 'http'; +import { pathToRegexp, Key } from 'path-to-regexp'; import { EMPTY, fromEvent, Observable } from 'rxjs'; import { filter, first, mergeMap, share, takeUntil } from 'rxjs/operators'; @@ -31,6 +32,23 @@ type WsAdapterOptions = { messageParser?: WsMessageParser; }; +/** + * Extended WebSocket server type with dynamic path matching support + */ +interface WsServerWithPath { + path: string; + pathRegexp?: RegExp; + pathKeys?: Key[]; + isStaticPath?: boolean; + handleUpgrade: ( + request: any, + socket: any, + head: any, + callback: (ws: unknown) => void, + ) => void; + emit: (event: string, ...args: any[]) => void; +} + const UNDERLYING_HTTP_SERVER_PORT = 0; /** @@ -222,8 +240,49 @@ export class WsAdapter extends AbstractWsAdapter { let isRequestDelegated = false; for (const wsServer of wsServersCollection) { - if (pathname === wsServer.path) { + const wsServerWithPath = wsServer as WsServerWithPath; + let pathMatch = false; + const pathParams: Record = {}; + + if (wsServerWithPath.isStaticPath !== false) { + pathMatch = pathname === wsServerWithPath.path; + } else { + // Dynamic path matching using path-to-regexp + const match = wsServerWithPath.pathRegexp!.exec(pathname); + if (match) { + pathMatch = true; + + if ( + wsServerWithPath.pathKeys && + wsServerWithPath.pathKeys.length > 0 + ) { + wsServerWithPath.pathKeys.forEach((key, index) => { + const paramValue = match[index + 1]; + if (paramValue !== undefined) { + pathParams[key.name] = decodeURIComponent(paramValue); + } + }); + } + } + } + + if (pathMatch) { + // Inject + if (Object.keys(pathParams).length > 0) { + (request as any).params = pathParams; + + this.logger.debug( + `WebSocket connection matched dynamic path "${wsServerWithPath.path}" with params:`, + pathParams, + ); + } + wsServer.handleUpgrade(request, socket, head, (ws: unknown) => { + if (Object.keys(pathParams).length > 0) { + (ws as any)._pathParams = pathParams; + (ws as any).upgradeReq = request; + } + wsServer.emit('connection', ws, request); }); isRequestDelegated = true; @@ -246,9 +305,39 @@ export class WsAdapter extends AbstractWsAdapter { path: string, ) { const entries = this.wsServersRegistry.get(port) ?? []; - entries.push(wsServer); + const normalizedPath = normalizePath(path); + + // Prepare path matching + const wsServerWithPath = wsServer as unknown as WsServerWithPath; + wsServerWithPath.path = normalizedPath; + + const isDynamicPath = + normalizedPath.includes(':') || + normalizedPath.includes('*') || + normalizedPath.includes('('); + + if (isDynamicPath) { + try { + const pathRegexpResult = pathToRegexp(normalizedPath); + wsServerWithPath.pathRegexp = pathRegexpResult.regexp; + wsServerWithPath.pathKeys = pathRegexpResult.keys || []; + wsServerWithPath.isStaticPath = false; + + this.logger.log( + `Registered WebSocket server with dynamic path: ${normalizedPath} on port ${port}`, + ); + } catch (error) { + this.logger.error( + `Failed to compile dynamic path "${normalizedPath}": ${error.message}`, + error.stack, + ); + wsServerWithPath.isStaticPath = true; + } + } else { + wsServerWithPath.isStaticPath = true; + } - wsServer.path = normalizePath(path); + entries.push(wsServerWithPath); this.wsServersRegistry.set(port, entries); } } diff --git a/packages/platform-ws/package.json b/packages/platform-ws/package.json index 9d6db0c3520..cd48ffc4e25 100644 --- a/packages/platform-ws/package.json +++ b/packages/platform-ws/package.json @@ -18,6 +18,7 @@ "access": "public" }, "dependencies": { + "path-to-regexp": "8.2.0", "tslib": "2.8.1", "ws": "8.18.3" }, diff --git a/packages/websockets/decorators/index.ts b/packages/websockets/decorators/index.ts index 51d5d6da3aa..9bcd40437b4 100644 --- a/packages/websockets/decorators/index.ts +++ b/packages/websockets/decorators/index.ts @@ -3,3 +3,4 @@ export * from './gateway-server.decorator'; export * from './message-body.decorator'; export * from './socket-gateway.decorator'; export * from './subscribe-message.decorator'; +export * from './ws-param.decorator'; diff --git a/packages/websockets/decorators/ws-param.decorator.ts b/packages/websockets/decorators/ws-param.decorator.ts new file mode 100644 index 00000000000..6500544f258 --- /dev/null +++ b/packages/websockets/decorators/ws-param.decorator.ts @@ -0,0 +1,60 @@ +import { PipeTransform, Type } from '@nestjs/common'; +import { WsParamtype } from '../enums/ws-paramtype.enum'; +import { createPipesWsParamDecorator } from '../utils/param.utils'; + +/** + * WebSocket parameter decorator. Extracts path parameters from the WebSocket URL. + * + * Use this decorator to inject path parameter values from dynamic WebSocket URLs. + * Path parameters are defined in the WebSocket gateway path configuration using + * the same syntax as HTTP controllers (e.g., `:id`, `:userId`). + * + * @example + * ```typescript + * @WebSocketGateway({ path: '/chat/:roomId/socket' }) + * export class ChatGateway { + * @SubscribeMessage('message') + * handleMessage( + * @ConnectedSocket() client: WebSocket, + * @MessageBody() data: any, + * @WsParam('roomId') roomId: string, + * ) { + * // roomId is automatically extracted from the WebSocket URL + * console.log(`Message received in room: ${roomId}`); + * } + * } + * ``` + * + * @param property - The name of the path parameter to extract (optional) + * @param pipes - Optional transformation/validation pipes + * @returns ParameterDecorator + * + * @publicApi + */ +export function WsParam( + property?: string, + ...pipes: (Type | PipeTransform)[] +): ParameterDecorator; + +/** + * WebSocket parameter decorator without property name. + * Returns all path parameters as an object. + * + * @param pipes - Optional transformation/validation pipes + * @returns ParameterDecorator + * + * @publicApi + */ +export function WsParam( + ...pipes: (Type | PipeTransform)[] +): ParameterDecorator; + +/** + * Implementation of the WsParam decorator + */ +export function WsParam( + property?: string | (Type | PipeTransform), + ...pipes: (Type | PipeTransform)[] +): ParameterDecorator { + return createPipesWsParamDecorator(WsParamtype.PARAM)(property, ...pipes); +} diff --git a/packages/websockets/enums/ws-paramtype.enum.ts b/packages/websockets/enums/ws-paramtype.enum.ts index 51dd98abfff..e538dbadf27 100644 --- a/packages/websockets/enums/ws-paramtype.enum.ts +++ b/packages/websockets/enums/ws-paramtype.enum.ts @@ -3,4 +3,5 @@ import { RouteParamtypes } from '@nestjs/common/enums/route-paramtypes.enum'; export enum WsParamtype { SOCKET = RouteParamtypes.REQUEST, PAYLOAD = RouteParamtypes.BODY, + PARAM = RouteParamtypes.PARAM, } diff --git a/packages/websockets/factories/ws-params-factory.ts b/packages/websockets/factories/ws-params-factory.ts index 649226d8221..5708e7cff69 100644 --- a/packages/websockets/factories/ws-params-factory.ts +++ b/packages/websockets/factories/ws-params-factory.ts @@ -14,6 +14,21 @@ export class WsParamsFactory { return args[0]; case WsParamtype.PAYLOAD: return data ? args[1]?.[data] : args[1]; + case WsParamtype.PARAM: { + // Path parameters are extracted from the WebSocket handshake request + // and stored in the client object during connection establishment + const client = args[0] as any; + const pathParams = + client?._pathParams || + client?.upgradeReq?.params || + client?.request?.params; + + if (!pathParams) { + return data ? undefined : {}; + } + + return data && pathParams ? pathParams[data] : pathParams; + } default: return null; } diff --git a/packages/websockets/test/factories/ws-params-factory.spec.ts b/packages/websockets/test/factories/ws-params-factory.spec.ts index a0e95883bf5..a6b4081039b 100644 --- a/packages/websockets/test/factories/ws-params-factory.spec.ts +++ b/packages/websockets/test/factories/ws-params-factory.spec.ts @@ -32,6 +32,98 @@ describe('WsParamsFactory', () => { ).to.be.eql(client); }); }); + describe(`WsParamtype.PARAM`, () => { + it('should return all path parameters when no property is specified', () => { + const pathParams = { roomId: '123', userId: '456' }; + const clientWithParams = { _pathParams: pathParams }; + const argsWithParams = [clientWithParams, data]; + + expect( + factory.exchangeKeyForValue( + WsParamtype.PARAM, + null!, + argsWithParams, + ), + ).to.be.eql(pathParams); + }); + + it('should return specific path parameter when property is specified', () => { + const pathParams = { roomId: '123', userId: '456' }; + const clientWithParams = { _pathParams: pathParams }; + const argsWithParams = [clientWithParams, data]; + + expect( + factory.exchangeKeyForValue( + WsParamtype.PARAM, + 'roomId', + argsWithParams, + ), + ).to.be.eql('123'); + }); + + it('should return undefined for non-existent parameter', () => { + const pathParams = { roomId: '123' }; + const clientWithParams = { _pathParams: pathParams }; + const argsWithParams = [clientWithParams, data]; + + expect( + factory.exchangeKeyForValue( + WsParamtype.PARAM, + 'nonExistent', + argsWithParams, + ), + ).to.be.undefined; + }); + + it('should handle client without path parameters', () => { + const clientWithoutParams = {}; + const argsWithoutParams = [clientWithoutParams, data]; + + expect( + factory.exchangeKeyForValue( + WsParamtype.PARAM, + 'roomId', + argsWithoutParams, + ), + ).to.be.undefined; + + expect( + factory.exchangeKeyForValue( + WsParamtype.PARAM, + null!, + argsWithoutParams, + ), + ).to.be.eql({}); + }); + + it('should fallback to upgradeReq.params if _pathParams is not available', () => { + const pathParams = { roomId: '789' }; + const clientWithUpgradeReq = { upgradeReq: { params: pathParams } }; + const argsWithUpgradeReq = [clientWithUpgradeReq, data]; + + expect( + factory.exchangeKeyForValue( + WsParamtype.PARAM, + 'roomId', + argsWithUpgradeReq, + ), + ).to.be.eql('789'); + }); + + it('should fallback to request.params if _pathParams and upgradeReq.params are not available', () => { + const pathParams = { roomId: '999' }; + const clientWithRequest = { request: { params: pathParams } }; + const argsWithRequest = [clientWithRequest, data]; + + expect( + factory.exchangeKeyForValue( + WsParamtype.PARAM, + 'roomId', + argsWithRequest, + ), + ).to.be.eql('999'); + }); + }); }); describe('when key is not available', () => { it('should return null', () => { From e2c4ed3598202c0758a0ceff2f9c23258ea00dcf Mon Sep 17 00:00:00 2001 From: lifefloating Date: Mon, 4 Aug 2025 16:02:07 +0800 Subject: [PATCH 2/2] feat(ws): enhance optimized/dynamic path matching for websocket routes --- packages/platform-ws/adapters/ws-adapter.ts | 205 +++++++++++++++----- 1 file changed, 159 insertions(+), 46 deletions(-) diff --git a/packages/platform-ws/adapters/ws-adapter.ts b/packages/platform-ws/adapters/ws-adapter.ts index 9e1f3110bbc..3eff3f85a0d 100644 --- a/packages/platform-ws/adapters/ws-adapter.ts +++ b/packages/platform-ws/adapters/ws-adapter.ts @@ -49,6 +49,26 @@ interface WsServerWithPath { emit: (event: string, ...args: any[]) => void; } +/** + * Path matching result containing matched servers and extracted parameters + */ +interface PathMatchResult { + server: WsServerWithPath; + params: Record; +} + +/** + * Optimized path matcher for efficient WebSocket route resolution + */ +interface PathMatcher { + staticPaths: Map; + dynamicPaths: Array<{ + server: WsServerWithPath; + pathRegexp: RegExp; + pathKeys: Key[]; + }>; +} + const UNDERLYING_HTTP_SERVER_PORT = 0; /** @@ -64,6 +84,10 @@ export class WsAdapter extends AbstractWsAdapter { WsServerRegistryKey, WsServerRegistryEntry >(); + protected readonly pathMatchersCache = new Map< + WsServerRegistryKey, + PathMatcher + >(); protected messageParser: WsMessageParser = (data: WsData) => { return JSON.parse(data.toString()); }; @@ -217,6 +241,8 @@ export class WsAdapter extends AbstractWsAdapter { await Promise.all(closeEventSignals); this.httpServersRegistry.clear(); this.wsServersRegistry.clear(); + // Clear path matcher cache to prevent memory leaks + this.pathMatchersCache.clear(); } public setMessageParser(parser: WsMessageParser) { @@ -236,58 +262,32 @@ export class WsAdapter extends AbstractWsAdapter { try { const baseUrl = 'ws://' + request.headers.host + '/'; const pathname = new URL(request.url!, baseUrl).pathname; - const wsServersCollection = this.wsServersRegistry.get(port)!; + const pathMatcher = this.getOrCreatePathMatcher(port); + const matchResult = this.matchPath(pathname, pathMatcher); let isRequestDelegated = false; - for (const wsServer of wsServersCollection) { - const wsServerWithPath = wsServer as WsServerWithPath; - let pathMatch = false; - const pathParams: Record = {}; - - if (wsServerWithPath.isStaticPath !== false) { - pathMatch = pathname === wsServerWithPath.path; - } else { - // Dynamic path matching using path-to-regexp - const match = wsServerWithPath.pathRegexp!.exec(pathname); - if (match) { - pathMatch = true; - - if ( - wsServerWithPath.pathKeys && - wsServerWithPath.pathKeys.length > 0 - ) { - wsServerWithPath.pathKeys.forEach((key, index) => { - const paramValue = match[index + 1]; - if (paramValue !== undefined) { - pathParams[key.name] = decodeURIComponent(paramValue); - } - }); - } - } - } + if (matchResult) { + const { server, params } = matchResult; - if (pathMatch) { - // Inject - if (Object.keys(pathParams).length > 0) { - (request as any).params = pathParams; + // Inject path parameters if any + if (Object.keys(params).length > 0) { + (request as any).params = params; - this.logger.debug( - `WebSocket connection matched dynamic path "${wsServerWithPath.path}" with params:`, - pathParams, - ); - } + this.logger.debug( + `WebSocket connection matched dynamic path "${server.path}" with params:`, + params, + ); + } - wsServer.handleUpgrade(request, socket, head, (ws: unknown) => { - if (Object.keys(pathParams).length > 0) { - (ws as any)._pathParams = pathParams; - (ws as any).upgradeReq = request; - } + server.handleUpgrade(request, socket, head, (ws: unknown) => { + if (Object.keys(params).length > 0) { + (ws as any)._pathParams = params; + (ws as any).upgradeReq = request; + } - wsServer.emit('connection', ws, request); - }); - isRequestDelegated = true; - break; - } + server.emit('connection', ws, request); + }); + isRequestDelegated = true; } if (!isRequestDelegated) { socket.destroy(); @@ -299,6 +299,116 @@ export class WsAdapter extends AbstractWsAdapter { return httpServer; } + /** + * Get or create an optimized path matcher for the specified port + */ + protected getOrCreatePathMatcher(port: number): PathMatcher { + let pathMatcher = this.pathMatchersCache.get(port); + if (!pathMatcher) { + const wsServersCollection = this.wsServersRegistry.get(port) || []; + pathMatcher = this.createPathMatcher( + wsServersCollection as WsServerWithPath[], + ); + this.pathMatchersCache.set(port, pathMatcher); + } + return pathMatcher; + } + + /** + * Create an optimized path matcher from WebSocket servers + */ + protected createPathMatcher(servers: WsServerWithPath[]): PathMatcher { + const matcher: PathMatcher = { + staticPaths: new Map(), + dynamicPaths: [], + }; + + let staticCount = 0; + let dynamicCount = 0; + + for (const server of servers) { + if (server.isStaticPath !== false) { + // Static path - use Map for O(1) lookup + const existing = matcher.staticPaths.get(server.path) || []; + existing.push(server); + matcher.staticPaths.set(server.path, existing); + staticCount++; + } else { + // Dynamic path - store for sequential matching + matcher.dynamicPaths.push({ + server, + pathRegexp: server.pathRegexp!, + pathKeys: server.pathKeys || [], + }); + dynamicCount++; + } + } + + // Sort dynamic paths by complexity (simpler patterns first for better performance) + matcher.dynamicPaths.sort((a, b) => { + const aComplexity = + (a.pathKeys?.length || 0) + (a.server.path.split('/').length || 0); + const bComplexity = + (b.pathKeys?.length || 0) + (b.server.path.split('/').length || 0); + return aComplexity - bComplexity; + }); + + this.logger.log( + `Created optimized path matcher: ${staticCount} static paths, ${dynamicCount} dynamic paths`, + ); + + return matcher; + } + + /** + * Match a pathname against the optimized path matcher + * Returns the first matching server and extracted parameters + */ + protected matchPath( + pathname: string, + matcher: PathMatcher, + ): PathMatchResult | null { + // First try static paths (O(1) lookup) + const staticServers = matcher.staticPaths.get(pathname); + if (staticServers && staticServers.length > 0) { + return { + server: staticServers[0], // Return first matching static server + params: {}, + }; + } + + // Then try dynamic paths (ordered by complexity) + for (const { server, pathRegexp, pathKeys } of matcher.dynamicPaths) { + const match = pathRegexp.exec(pathname); + if (match) { + const params: Record = {}; + + // Extract path parameters + pathKeys.forEach((key, index) => { + const paramValue = match[index + 1]; + if (paramValue !== undefined) { + try { + params[key.name] = decodeURIComponent(paramValue); + } catch (error) { + // Fallback to raw value if decoding fails + params[key.name] = paramValue; + this.logger.warn( + `Failed to decode path parameter "${key.name}": ${paramValue}`, + ); + } + } + }); + + return { + server, + params, + }; + } + } + + return null; + } + protected addWsServerToRegistry = any>( wsServer: T, port: number, @@ -339,5 +449,8 @@ export class WsAdapter extends AbstractWsAdapter { entries.push(wsServerWithPath); this.wsServersRegistry.set(port, entries); + + // Invalidate path matcher cache for this port + this.pathMatchersCache.delete(port); } }