From a71de2c57ee89cd157e657b07c73a7495299fb1c Mon Sep 17 00:00:00 2001 From: Himanshu Gupta Date: Tue, 10 Feb 2026 09:00:20 +0530 Subject: [PATCH] fix(websockets): deliver exception errors to native ws clients BaseWsExceptionFilter used client.emit() to send exception payloads, which only works with socket.io. For native WebSocket clients (ws library), emit() is just EventEmitter.emit() and does not send data over the wire, causing exceptions to be silently swallowed. Add sendExceptionToClient() and isNativeWebSocket() methods to BaseWsExceptionFilter to detect the client type and use client.send() for native WebSocket clients. Update WsExceptionsHandler guard to allow clients with send() through to the exception filter. Closes #9056 --- integration/websockets/e2e/ws-gateway.spec.ts | 36 ++++++ .../exceptions/base-ws-exception-filter.ts | 50 ++++++-- .../exceptions/ws-exceptions-handler.ts | 5 +- .../exceptions/ws-exceptions-handler.spec.ts | 114 ++++++++++++++++++ 4 files changed, 195 insertions(+), 10 deletions(-) diff --git a/integration/websockets/e2e/ws-gateway.spec.ts b/integration/websockets/e2e/ws-gateway.spec.ts index 5a498ea9151..71966386a59 100644 --- a/integration/websockets/e2e/ws-gateway.spec.ts +++ b/integration/websockets/e2e/ws-gateway.spec.ts @@ -5,6 +5,7 @@ import { expect } from 'chai'; import * as WebSocket from 'ws'; import { ApplicationGateway } from '../src/app.gateway'; import { CoreGateway } from '../src/core.gateway'; +import { ErrorGateway } from '../src/error.gateway'; import { ExamplePathGateway } from '../src/example-path.gateway'; import { ServerGateway } from '../src/server.gateway'; import { WsPathGateway } from '../src/ws-path.gateway'; @@ -273,6 +274,41 @@ describe('WebSocketGateway (WsAdapter)', () => { ); }); + it(`should handle WsException and send error to client`, async () => { + app = await createNestApp(ErrorGateway); + await app.listen(3000); + + ws = new WebSocket('ws://localhost:8080'); + await new Promise(resolve => ws.on('open', resolve)); + + ws.send( + JSON.stringify({ + event: 'push', + data: { + test: 'test', + }, + }), + ); + + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timeout: no error message received')), + 5000, + ); + ws.on('message', data => { + clearTimeout(timeout); + const parsed = JSON.parse(data.toString()); + expect(parsed.event).to.be.eql('exception'); + expect(parsed.data).to.deep.include({ + status: 'error', + message: 'test', + }); + ws.close(); + resolve(); + }); + }); + }); + afterEach(async function () { await app.close(); }); diff --git a/packages/websockets/exceptions/base-ws-exception-filter.ts b/packages/websockets/exceptions/base-ws-exception-filter.ts index be46ec05da6..2547e939eb9 100644 --- a/packages/websockets/exceptions/base-ws-exception-filter.ts +++ b/packages/websockets/exceptions/base-ws-exception-filter.ts @@ -64,7 +64,7 @@ export class BaseWsExceptionFilter< }); } - public handleError( + public handleError( client: TClient, exception: TError, cause: ErrorPayload['cause'], @@ -77,7 +77,7 @@ export class BaseWsExceptionFilter< const result = exception.getError(); if (isObject(result)) { - return client.emit('exception', result); + return this.sendExceptionToClient(client, 'exception', result); } const payload: ErrorPayload = { @@ -89,14 +89,12 @@ export class BaseWsExceptionFilter< payload.cause = this.options.causeFactory!(cause.pattern, cause.data); } - client.emit('exception', payload); + this.sendExceptionToClient(client, 'exception', payload); } - public handleUnknownError( - exception: TError, - client: TClient, - data: ErrorPayload['cause'], - ) { + public handleUnknownError< + TClient extends { emit?: Function; send?: Function }, + >(exception: TError, client: TClient, data: ErrorPayload['cause']) { const status = 'error'; const payload: ErrorPayload = { status, @@ -107,7 +105,7 @@ export class BaseWsExceptionFilter< payload.cause = this.options.causeFactory!(data.pattern, data.data); } - client.emit('exception', payload); + this.sendExceptionToClient(client, 'exception', payload); if (!(exception instanceof IntrinsicException)) { const logger = BaseWsExceptionFilter.logger; @@ -115,6 +113,40 @@ export class BaseWsExceptionFilter< } } + /** + * Sends the exception payload to the client using the appropriate transport. + * For native WebSocket clients (e.g., `ws` library), uses `client.send()`. + * For socket.io clients, uses `client.emit()`. + * + * Override this method if you use a custom WebSocket adapter with a + * different sending mechanism. + */ + protected sendExceptionToClient( + client: any, + event: string, + payload: any, + ): void { + if (this.isNativeWebSocket(client)) { + if (client.readyState === 1) { + client.send(JSON.stringify({ event, data: payload })); + } + } else if (typeof client.emit === 'function') { + client.emit(event, payload); + } + } + + /** + * Determines whether the client is a native WebSocket instance (e.g., from + * the `ws` library) rather than a socket.io socket. + */ + protected isNativeWebSocket(client: any): boolean { + return ( + typeof client.send === 'function' && + typeof client.readyState === 'number' && + !client.nsp + ); + } + public isExceptionObject(err: any): err is Error { return isObject(err) && !!(err as Error).message; } diff --git a/packages/websockets/exceptions/ws-exceptions-handler.ts b/packages/websockets/exceptions/ws-exceptions-handler.ts index 3efbbbfad58..90891d75308 100644 --- a/packages/websockets/exceptions/ws-exceptions-handler.ts +++ b/packages/websockets/exceptions/ws-exceptions-handler.ts @@ -14,7 +14,10 @@ export class WsExceptionsHandler extends BaseWsExceptionFilter { public handle(exception: Error | WsException, host: ArgumentsHost) { const client = host.switchToWs().getClient(); - if (this.invokeCustomFilters(exception, host) || !client.emit) { + if ( + this.invokeCustomFilters(exception, host) || + (!client.emit && !client.send) + ) { return; } super.catch(exception, host); diff --git a/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts b/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts index c1b54ba4a2c..82c440c072e 100644 --- a/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts +++ b/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts @@ -1,6 +1,7 @@ import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; import { expect } from 'chai'; import * as sinon from 'sinon'; +import { BaseWsExceptionFilter } from '../../exceptions/base-ws-exception-filter'; import { WsException } from '../../errors/ws-exception'; import { WsExceptionsHandler } from '../../exceptions/ws-exceptions-handler'; @@ -170,4 +171,117 @@ describe('WsExceptionsHandler', () => { }); }); }); + + describe('when client is a native WebSocket (ws library)', () => { + let sendStub: sinon.SinonStub; + let wsClient: { + send: sinon.SinonStub; + readyState: number; + }; + let wsExecutionContextHost: ExecutionContextHost; + + beforeEach(() => { + sendStub = sinon.stub(); + wsClient = { + send: sendStub, + readyState: 1, + }; + wsExecutionContextHost = new ExecutionContextHost([ + wsClient, + data, + pattern, + ]); + }); + + it('should send JSON error via client.send when exception is unknown', () => { + handler.handle(new Error(), wsExecutionContextHost); + expect(sendStub.calledOnce).to.be.true; + const sent = JSON.parse(sendStub.firstCall.args[0]); + expect(sent).to.deep.equal({ + event: 'exception', + data: { + status: 'error', + message: 'Internal server error', + cause: { + pattern, + data, + }, + }, + }); + }); + + it('should send JSON error via client.send when WsException has string message', () => { + const message = 'Unauthorized'; + handler.handle(new WsException(message), wsExecutionContextHost); + expect(sendStub.calledOnce).to.be.true; + const sent = JSON.parse(sendStub.firstCall.args[0]); + expect(sent).to.deep.equal({ + event: 'exception', + data: { + message, + status: 'error', + cause: { + pattern, + data, + }, + }, + }); + }); + + it('should send JSON error via client.send when WsException has object message', () => { + const message = { custom: 'Unauthorized' }; + handler.handle(new WsException(message), wsExecutionContextHost); + expect(sendStub.calledOnce).to.be.true; + const sent = JSON.parse(sendStub.firstCall.args[0]); + expect(sent).to.deep.equal({ + event: 'exception', + data: message, + }); + }); + + it('should not send when readyState is not OPEN', () => { + wsClient.readyState = 3; + handler.handle(new WsException('test'), wsExecutionContextHost); + expect(sendStub.notCalled).to.be.true; + }); + }); + + describe('when client has neither emit nor send', () => { + it('should bail out without throwing', () => { + const bareClient = {}; + const bareCtx = new ExecutionContextHost([bareClient, data, pattern]); + expect(() => handler.handle(new WsException('test'), bareCtx)).to.not + .throw; + }); + }); +}); + +describe('BaseWsExceptionFilter', () => { + describe('isNativeWebSocket', () => { + let filter: BaseWsExceptionFilter; + + beforeEach(() => { + filter = new BaseWsExceptionFilter(); + }); + + it('should return true for a raw ws client', () => { + const wsClient = { send: () => {}, readyState: 1 }; + expect((filter as any).isNativeWebSocket(wsClient)).to.be.true; + }); + + it('should return false for a socket.io client (has nsp)', () => { + const ioClient = { + send: () => {}, + readyState: 1, + emit: () => {}, + nsp: {}, + }; + expect((filter as any).isNativeWebSocket(ioClient)).to.be.false; + }); + + it('should return false for a client without send', () => { + const client = { emit: () => {} }; + expect((filter as any).isNativeWebSocket(client)).to.be.false; + }); + }); });