From efe43519110bd72626965593c832277a667368d6 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 15 Feb 2026 01:50:11 -0800 Subject: [PATCH 1/4] fix(websockets): send WsException errors to native WebSocket clients The BaseWsExceptionFilter used client.emit() which only works with Socket.IO clients. Native WebSocket clients (via @nestjs/platform-ws) don't have an emit method, so exceptions were silently swallowed. Added an emitMessage() method that detects the client type and uses client.send() with JSON-serialized { event, data } payloads for native WS clients, matching the message format the WS adapter already uses. Also removed the !client.emit guard in WsExceptionsHandler that prevented exception handling from reaching native WS clients at all. Closes #9056 --- .../websockets/e2e/ws-error-gateway.spec.ts | 61 ++++++++++++++ .../websockets/src/ws-error.gateway.ts | 14 ++++ .../exceptions/base-ws-exception-filter.ts | 37 ++++++--- .../exceptions/ws-exceptions-handler.ts | 2 +- .../exceptions/ws-exceptions-handler.spec.ts | 83 +++++++++++++++++++ 5 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 integration/websockets/e2e/ws-error-gateway.spec.ts create mode 100644 integration/websockets/src/ws-error.gateway.ts diff --git a/integration/websockets/e2e/ws-error-gateway.spec.ts b/integration/websockets/e2e/ws-error-gateway.spec.ts new file mode 100644 index 00000000000..2b63949f03d --- /dev/null +++ b/integration/websockets/e2e/ws-error-gateway.spec.ts @@ -0,0 +1,61 @@ +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 { WsErrorGateway } from '../src/ws-error.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('WebSocketGateway (WsAdapter) - Error Handling', () => { + let ws: WebSocket, app: INestApplication; + + it('should send WsException error to client via native WebSocket', async () => { + app = await createNestApp(WsErrorGateway); + await app.listen(3000); + + ws = new WebSocket('ws://localhost:8085'); + await new Promise(resolve => ws.on('open', resolve)); + + ws.send( + JSON.stringify({ + event: 'push', + data: { + test: 'test', + }, + }), + ); + + await new Promise(resolve => + ws.on('message', data => { + const response = JSON.parse(data.toString()); + expect(response).to.deep.equal({ + event: 'exception', + data: { + status: 'error', + message: 'test', + cause: { + pattern: 'push', + data: { + test: 'test', + }, + }, + }, + }); + ws.close(); + resolve(); + }), + ); + }); + + afterEach(async function () { + await app.close(); + }); +}); diff --git a/integration/websockets/src/ws-error.gateway.ts b/integration/websockets/src/ws-error.gateway.ts new file mode 100644 index 00000000000..f72c69f5b52 --- /dev/null +++ b/integration/websockets/src/ws-error.gateway.ts @@ -0,0 +1,14 @@ +import { + SubscribeMessage, + WebSocketGateway, + WsException, +} from '@nestjs/websockets'; +import { throwError } from 'rxjs'; + +@WebSocketGateway(8085) +export class WsErrorGateway { + @SubscribeMessage('push') + onPush() { + return throwError(() => new WsException('test')); + } +} diff --git a/packages/websockets/exceptions/base-ws-exception-filter.ts b/packages/websockets/exceptions/base-ws-exception-filter.ts index d006286fc63..c97c217b0b1 100644 --- a/packages/websockets/exceptions/base-ws-exception-filter.ts +++ b/packages/websockets/exceptions/base-ws-exception-filter.ts @@ -5,7 +5,7 @@ import { type WsExceptionFilter, } from '@nestjs/common'; import { WsException } from '../errors/ws-exception.js'; -import { isObject } from '@nestjs/common/internal'; +import { isFunction, isObject } from '@nestjs/common/internal'; import { MESSAGES } from '@nestjs/core/internal'; export interface ErrorPayload { @@ -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.emitMessage(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.emitMessage(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.emitMessage(client, 'exception', payload); if (!(exception instanceof IntrinsicException)) { const logger = BaseWsExceptionFilter.logger; @@ -118,4 +116,23 @@ export class BaseWsExceptionFilter< public isExceptionObject(err: any): err is Error { return isObject(err) && !!(err as Error).message; } + + /** + * Sends an error message to the client. Supports both Socket.IO clients + * (which use `emit`) and native WebSocket clients (which use `send`). + */ + protected emitMessage< + TClient extends { emit?: Function; send?: Function }, + >(client: TClient, event: string, payload: unknown): void { + if (isFunction(client.emit)) { + client.emit(event, payload); + } else if (isFunction(client.send)) { + client.send( + JSON.stringify({ + event, + data: payload, + }), + ); + } + } } diff --git a/packages/websockets/exceptions/ws-exceptions-handler.ts b/packages/websockets/exceptions/ws-exceptions-handler.ts index 4d71bdfe586..e6e4acab543 100644 --- a/packages/websockets/exceptions/ws-exceptions-handler.ts +++ b/packages/websockets/exceptions/ws-exceptions-handler.ts @@ -16,7 +16,7 @@ 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) { 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 3df991bafaa..8f9287e1475 100644 --- a/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts +++ b/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts @@ -98,6 +98,89 @@ describe('WsExceptionsHandler', () => { }); }); + describe('when client uses "send" instead of "emit" (native WebSocket)', () => { + let sendStub: sinon.SinonStub; + let wsClient: { send: sinon.SinonStub }; + let wsExecutionContextHost: ExecutionContextHost; + + beforeEach(() => { + handler = new WsExceptionsHandler(); + sendStub = sinon.stub(); + wsClient = { send: sendStub }; + wsExecutionContextHost = new ExecutionContextHost([ + wsClient, + data, + pattern, + ]); + }); + + it('should send JSON-stringified error via "send" when exception is unknown', () => { + handler.handle(new Error(), wsExecutionContextHost); + expect(sendStub.calledOnce).to.be.true; + const sent = JSON.parse(sendStub.getCall(0).args[0]); + expect(sent).to.deep.equal({ + event: 'exception', + data: { + status: 'error', + message: 'Internal server error', + cause: { + pattern, + data, + }, + }, + }); + }); + + it('should send JSON-stringified error via "send" for WsException with object', () => { + const message = { custom: 'Unauthorized' }; + handler.handle(new WsException(message), wsExecutionContextHost); + expect(sendStub.calledOnce).to.be.true; + const sent = JSON.parse(sendStub.getCall(0).args[0]); + expect(sent).to.deep.equal({ + event: 'exception', + data: message, + }); + }); + + it('should send JSON-stringified error via "send" for WsException with string', () => { + const message = 'Unauthorized'; + handler.handle(new WsException(message), wsExecutionContextHost); + expect(sendStub.calledOnce).to.be.true; + const sent = JSON.parse(sendStub.getCall(0).args[0]); + expect(sent).to.deep.equal({ + event: 'exception', + data: { + message, + status: 'error', + cause: { + pattern, + data, + }, + }, + }); + }); + + describe('when "includeCause" is set to false', () => { + beforeEach(() => { + handler = new WsExceptionsHandler({ includeCause: false }); + }); + + it('should send error without cause via "send"', () => { + const message = 'Unauthorized'; + handler.handle(new WsException(message), wsExecutionContextHost); + expect(sendStub.calledOnce).to.be.true; + const sent = JSON.parse(sendStub.getCall(0).args[0]); + expect(sent).to.deep.equal({ + event: 'exception', + data: { + message, + status: 'error', + }, + }); + }); + }); + }); + describe('when "invokeCustomFilters" returns true', () => { beforeEach(() => { vi.spyOn(handler, 'invokeCustomFilters').mockReturnValue(true); From bc6b0880fe530fc0bc757beb74397b9ddd2acdc9 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 15 Feb 2026 16:22:37 -0800 Subject: [PATCH 2/4] fix(websockets): detect native ws clients before calling emit The emitMessage() method checked for client.emit first, but native WebSocket clients from the ws package inherit from EventEmitter and also have an emit method that only dispatches events locally. This caused WsException errors to be emitted as local events instead of being sent over the wire, resulting in the e2e test timing out. Added an isNativeWebSocket() check that looks for a numeric readyState property (per the WebSocket spec) to reliably distinguish native WS clients from Socket.IO sockets. Also fixed prettier formatting on the emitMessage generic signature, and updated test mocks to include readyState on the native WS client stub. --- .../exceptions/base-ws-exception-filter.ts | 33 +++++++++++++++---- .../exceptions/ws-exceptions-handler.spec.ts | 4 +-- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/websockets/exceptions/base-ws-exception-filter.ts b/packages/websockets/exceptions/base-ws-exception-filter.ts index c97c217b0b1..cac1f78b61c 100644 --- a/packages/websockets/exceptions/base-ws-exception-filter.ts +++ b/packages/websockets/exceptions/base-ws-exception-filter.ts @@ -5,7 +5,7 @@ import { type WsExceptionFilter, } from '@nestjs/common'; import { WsException } from '../errors/ws-exception.js'; -import { isFunction, isObject } from '@nestjs/common/internal'; +import { isFunction, isNumber, isObject } from '@nestjs/common/internal'; import { MESSAGES } from '@nestjs/core/internal'; export interface ErrorPayload { @@ -120,19 +120,38 @@ export class BaseWsExceptionFilter< /** * Sends an error message to the client. Supports both Socket.IO clients * (which use `emit`) and native WebSocket clients (which use `send`). + * + * Native WebSocket clients (e.g. from the `ws` package) inherit from + * EventEmitter and therefore also have an `emit` method, but that method + * only dispatches events locally. To distinguish native WebSocket clients + * from Socket.IO clients, we check for a numeric `readyState` property + * (part of the WebSocket specification) before falling back to `emit`. */ - protected emitMessage< - TClient extends { emit?: Function; send?: Function }, - >(client: TClient, event: string, payload: unknown): void { - if (isFunction(client.emit)) { - client.emit(event, payload); - } else if (isFunction(client.send)) { + protected emitMessage( + client: TClient, + event: string, + payload: unknown, + ): void { + if (this.isNativeWebSocket(client)) { client.send( JSON.stringify({ event, data: payload, }), ); + } else if (isFunction(client.emit)) { + client.emit(event, payload); } } + + /** + * Determines whether the given client is a native WebSocket (e.g. from the + * `ws` package) as opposed to a Socket.IO socket. Native WebSocket objects + * expose a numeric `readyState` property per the WebSocket specification. + */ + private isNativeWebSocket( + client: Record, + ): client is { send: Function; readyState: number } { + return isNumber(client.readyState) && isFunction(client.send); + } } diff --git a/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts b/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts index 8f9287e1475..ef2d7a3cb29 100644 --- a/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts +++ b/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts @@ -100,13 +100,13 @@ describe('WsExceptionsHandler', () => { describe('when client uses "send" instead of "emit" (native WebSocket)', () => { let sendStub: sinon.SinonStub; - let wsClient: { send: sinon.SinonStub }; + let wsClient: { send: sinon.SinonStub; readyState: number }; let wsExecutionContextHost: ExecutionContextHost; beforeEach(() => { handler = new WsExceptionsHandler(); sendStub = sinon.stub(); - wsClient = { send: sendStub }; + wsClient = { send: sendStub, readyState: 1 }; wsExecutionContextHost = new ExecutionContextHost([ wsClient, data, From 8ef44c2239c905a80fdc90dbb55d2d7242e65754 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Thu, 19 Feb 2026 22:29:37 -0800 Subject: [PATCH 3/4] fix(websockets): use vitest in ws-exceptions-handler tests Replace sinon.stub() and Chai-style assertions with Vitest's vi.fn() and expect() to fix CI failures in the test environment. --- .../exceptions/ws-exceptions-handler.spec.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts b/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts index ef2d7a3cb29..337c379ff74 100644 --- a/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts +++ b/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts @@ -99,13 +99,13 @@ describe('WsExceptionsHandler', () => { }); describe('when client uses "send" instead of "emit" (native WebSocket)', () => { - let sendStub: sinon.SinonStub; - let wsClient: { send: sinon.SinonStub; readyState: number }; + let sendStub: ReturnType; + let wsClient: { send: ReturnType; readyState: number }; let wsExecutionContextHost: ExecutionContextHost; beforeEach(() => { handler = new WsExceptionsHandler(); - sendStub = sinon.stub(); + sendStub = vi.fn(); wsClient = { send: sendStub, readyState: 1 }; wsExecutionContextHost = new ExecutionContextHost([ wsClient, @@ -116,9 +116,9 @@ describe('WsExceptionsHandler', () => { it('should send JSON-stringified error via "send" when exception is unknown', () => { handler.handle(new Error(), wsExecutionContextHost); - expect(sendStub.calledOnce).to.be.true; - const sent = JSON.parse(sendStub.getCall(0).args[0]); - expect(sent).to.deep.equal({ + expect(sendStub).toHaveBeenCalledTimes(1); + const sent = JSON.parse(sendStub.mock.calls[0][0]); + expect(sent).toEqual({ event: 'exception', data: { status: 'error', @@ -134,9 +134,9 @@ describe('WsExceptionsHandler', () => { it('should send JSON-stringified error via "send" for WsException with object', () => { const message = { custom: 'Unauthorized' }; handler.handle(new WsException(message), wsExecutionContextHost); - expect(sendStub.calledOnce).to.be.true; - const sent = JSON.parse(sendStub.getCall(0).args[0]); - expect(sent).to.deep.equal({ + expect(sendStub).toHaveBeenCalledTimes(1); + const sent = JSON.parse(sendStub.mock.calls[0][0]); + expect(sent).toEqual({ event: 'exception', data: message, }); @@ -145,9 +145,9 @@ describe('WsExceptionsHandler', () => { it('should send JSON-stringified error via "send" for WsException with string', () => { const message = 'Unauthorized'; handler.handle(new WsException(message), wsExecutionContextHost); - expect(sendStub.calledOnce).to.be.true; - const sent = JSON.parse(sendStub.getCall(0).args[0]); - expect(sent).to.deep.equal({ + expect(sendStub).toHaveBeenCalledTimes(1); + const sent = JSON.parse(sendStub.mock.calls[0][0]); + expect(sent).toEqual({ event: 'exception', data: { message, @@ -168,9 +168,9 @@ describe('WsExceptionsHandler', () => { it('should send error without cause via "send"', () => { const message = 'Unauthorized'; handler.handle(new WsException(message), wsExecutionContextHost); - expect(sendStub.calledOnce).to.be.true; - const sent = JSON.parse(sendStub.getCall(0).args[0]); - expect(sent).to.deep.equal({ + expect(sendStub).toHaveBeenCalledTimes(1); + const sent = JSON.parse(sendStub.mock.calls[0][0]); + expect(sent).toEqual({ event: 'exception', data: { message, From 4ee5330086f5fbdec8095aed806eb1838b4effc7 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Thu, 19 Feb 2026 23:37:23 -0800 Subject: [PATCH 4/4] fix(websockets): migrate integration test from chai to vitest The ws-error-gateway integration test was still using chai assertions and CJS-style imports which are incompatible with the v12.0.0 branch that has migrated to Vitest and ESM. Replaced chai's expect with Vitest globals, changed to default WebSocket import, added .js extension to local import, and removed a stray console.log from the unit test. --- integration/websockets/e2e/ws-error-gateway.spec.ts | 7 +++---- .../test/exceptions/ws-exceptions-handler.spec.ts | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/integration/websockets/e2e/ws-error-gateway.spec.ts b/integration/websockets/e2e/ws-error-gateway.spec.ts index 2b63949f03d..293af95c504 100644 --- a/integration/websockets/e2e/ws-error-gateway.spec.ts +++ b/integration/websockets/e2e/ws-error-gateway.spec.ts @@ -1,9 +1,8 @@ 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 { WsErrorGateway } from '../src/ws-error.gateway'; +import WebSocket from 'ws'; +import { WsErrorGateway } from '../src/ws-error.gateway.js'; async function createNestApp(...gateways: any[]): Promise { const testingModule = await Test.createTestingModule({ @@ -36,7 +35,7 @@ describe('WebSocketGateway (WsAdapter) - Error Handling', () => { await new Promise(resolve => ws.on('message', data => { const response = JSON.parse(data.toString()); - expect(response).to.deep.equal({ + expect(response).toEqual({ event: 'exception', data: { status: 'error', diff --git a/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts b/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts index 337c379ff74..48909d796fb 100644 --- a/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts +++ b/packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts @@ -50,7 +50,6 @@ describe('WsExceptionsHandler', () => { const message = 'Unauthorized'; handler.handle(new WsException(message), executionContextHost); - console.log(emitStub.mock.calls[0]); expect(emitStub).toHaveBeenCalledWith('exception', { message, status: 'error',