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; + }); + }); });