Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions integration/websockets/e2e/ws-gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void>((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();
});
Expand Down
50 changes: 41 additions & 9 deletions packages/websockets/exceptions/base-ws-exception-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class BaseWsExceptionFilter<
});
}

public handleError<TClient extends { emit: Function }>(
public handleError<TClient extends { emit?: Function; send?: Function }>(
client: TClient,
exception: TError,
cause: ErrorPayload['cause'],
Expand All @@ -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<unknown> = {
Expand All @@ -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<TClient extends { emit: Function }>(
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<unknown> = {
status,
Expand All @@ -107,14 +105,48 @@ 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;
logger.error(exception);
}
}

/**
* 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;
}
Expand Down
5 changes: 4 additions & 1 deletion packages/websockets/exceptions/ws-exceptions-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
114 changes: 114 additions & 0 deletions packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
});
});
});
Loading