@nestjs/websockets 12.1.3 → 12.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/constants.d.ts +1 -0
- package/constants.js +2 -0
- package/exceptions/base-ws-exception-filter.d.ts +7 -0
- package/exceptions/base-ws-exception-filter.js +24 -2
- package/factories/ws-params-factory.js +17 -2
- package/interfaces/hooks/on-gateway-connection.interface.d.ts +12 -0
- package/internal.d.ts +1 -0
- package/internal.js +1 -0
- package/package.json +5 -4
- package/utils/merge-map-bounded.util.d.ts +12 -0
- package/utils/merge-map-bounded.util.js +62 -0
- package/web-sockets-controller.d.ts +1 -1
- package/web-sockets-controller.js +37 -18
package/constants.d.ts
CHANGED
package/constants.js
CHANGED
|
@@ -11,3 +11,5 @@ export const CONNECTION_EVENT = 'connection';
|
|
|
11
11
|
export const DISCONNECT_EVENT = 'disconnect';
|
|
12
12
|
export const CLOSE_EVENT = 'close';
|
|
13
13
|
export const ERROR_EVENT = 'error';
|
|
14
|
+
// How many messages of a client an adapter handles at the same time
|
|
15
|
+
export const DEFAULT_MAX_CONCURRENT_MESSAGES = 1000;
|
|
@@ -62,6 +62,13 @@ export declare class BaseWsExceptionFilter<TError = any> implements WsExceptionF
|
|
|
62
62
|
emit?: Function;
|
|
63
63
|
send?: Function;
|
|
64
64
|
}>(client: TClient, event: string, payload: unknown): void;
|
|
65
|
+
/**
|
|
66
|
+
* Sends the error payload, and sends it again without the "cause" when that
|
|
67
|
+
* fails. The cause echoes the client's message, which `JSON.parse` accepts
|
|
68
|
+
* at any depth, while `JSON.stringify` and the Socket.IO encoder overflow
|
|
69
|
+
* the stack on it.
|
|
70
|
+
*/
|
|
71
|
+
private emitErrorPayload;
|
|
65
72
|
/**
|
|
66
73
|
* Determines whether the given client is a native WebSocket (e.g. from the
|
|
67
74
|
* `ws` package) as opposed to a Socket.IO socket. Native WebSocket objects
|
|
@@ -41,7 +41,7 @@ export class BaseWsExceptionFilter {
|
|
|
41
41
|
if (this.options?.includeCause && cause) {
|
|
42
42
|
payload.cause = this.options.causeFactory(cause.pattern, cause.data);
|
|
43
43
|
}
|
|
44
|
-
this.
|
|
44
|
+
this.emitErrorPayload(client, payload);
|
|
45
45
|
}
|
|
46
46
|
handleUnknownError(exception, client, data) {
|
|
47
47
|
const status = 'error';
|
|
@@ -52,11 +52,12 @@ export class BaseWsExceptionFilter {
|
|
|
52
52
|
if (this.options?.includeCause && data) {
|
|
53
53
|
payload.cause = this.options.causeFactory(data.pattern, data.data);
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
// Logged first, so that a reply that cannot be sent never hides it
|
|
56
56
|
if (!(exception instanceof IntrinsicException)) {
|
|
57
57
|
const logger = BaseWsExceptionFilter.logger;
|
|
58
58
|
logger.error(exception);
|
|
59
59
|
}
|
|
60
|
+
this.emitErrorPayload(client, payload);
|
|
60
61
|
}
|
|
61
62
|
isExceptionObject(err) {
|
|
62
63
|
return isObject(err) && !!err.message;
|
|
@@ -82,6 +83,27 @@ export class BaseWsExceptionFilter {
|
|
|
82
83
|
client.emit(event, payload);
|
|
83
84
|
}
|
|
84
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Sends the error payload, and sends it again without the "cause" when that
|
|
88
|
+
* fails. The cause echoes the client's message, which `JSON.parse` accepts
|
|
89
|
+
* at any depth, while `JSON.stringify` and the Socket.IO encoder overflow
|
|
90
|
+
* the stack on it.
|
|
91
|
+
*/
|
|
92
|
+
emitErrorPayload(client, payload) {
|
|
93
|
+
try {
|
|
94
|
+
this.emitMessage(client, 'exception', payload);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
// Without a cause, the payload holds nothing the client sent
|
|
98
|
+
if (!('cause' in payload)) {
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
this.emitMessage(client, 'exception', {
|
|
102
|
+
status: payload.status,
|
|
103
|
+
message: payload.message,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
85
107
|
/**
|
|
86
108
|
* Determines whether the given client is a native WebSocket (e.g. from the
|
|
87
109
|
* `ws` package) as opposed to a Socket.IO socket. Native WebSocket objects
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { WsParamtype } from '../enums/ws-paramtype.enum.js';
|
|
2
|
-
import { isFunction } from '@nestjs/common/internal';
|
|
2
|
+
import { isFunction, isNil, isObject } from '@nestjs/common/internal';
|
|
3
|
+
/**
|
|
4
|
+
* A key picks a property of an object payload. Of an array, a Buffer or a
|
|
5
|
+
* primitive the client sent instead, it picks only an own property (an
|
|
6
|
+
* element, a length), not a prototype member: `Buffer#parent` exposes memory
|
|
7
|
+
* shared with other requests, and a built-in function such as
|
|
8
|
+
* `Array.prototype.values` passes for a DTO without required properties.
|
|
9
|
+
* Objects keep resolving inherited properties, as a custom message parser may
|
|
10
|
+
* build class instances.
|
|
11
|
+
*/
|
|
12
|
+
const getPayloadProperty = (payload, key) => (isObject(payload) &&
|
|
13
|
+
!Array.isArray(payload) &&
|
|
14
|
+
!ArrayBuffer.isView(payload)) ||
|
|
15
|
+
(!isNil(payload) && Object.hasOwn(Object(payload), key))
|
|
16
|
+
? payload[key]
|
|
17
|
+
: undefined;
|
|
3
18
|
export class WsParamsFactory {
|
|
4
19
|
exchangeKeyForValue(type, data, args) {
|
|
5
20
|
if (!args) {
|
|
@@ -9,7 +24,7 @@ export class WsParamsFactory {
|
|
|
9
24
|
case WsParamtype.SOCKET:
|
|
10
25
|
return args[0];
|
|
11
26
|
case WsParamtype.PAYLOAD:
|
|
12
|
-
return data ? args[1]
|
|
27
|
+
return data ? getPayloadProperty(args[1], data) : args[1];
|
|
13
28
|
case WsParamtype.ACK: {
|
|
14
29
|
return args.find(arg => isFunction(arg));
|
|
15
30
|
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* Interface defining the method called for each client that connects to the
|
|
3
|
+
* gateway.
|
|
4
|
+
*
|
|
5
|
+
* The hook doesn't hold back the client's messages: they are handled as they
|
|
6
|
+
* arrive, even while an async `handleConnection()` is still pending. Use
|
|
7
|
+
* guards to check each message, or connection-level middleware to reject a
|
|
8
|
+
* client before any of its messages is handled: socket.io's `server.use()` in
|
|
9
|
+
* `afterInit()`, or, with `WsAdapter`, a `verifyClient` function in the
|
|
10
|
+
* `@WebSocketGateway()` options.
|
|
11
|
+
*
|
|
12
|
+
* @see [Lifecycle hooks](https://docs.nestjs.com/websockets/gateways#lifecycle-hooks)
|
|
13
|
+
*
|
|
2
14
|
* @publicApi
|
|
3
15
|
*/
|
|
4
16
|
export interface OnGatewayConnection<T = any> {
|
package/internal.d.ts
CHANGED
package/internal.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestjs/websockets",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.2.0",
|
|
4
4
|
"description": "Nest - modern, fast, powerful node.js web framework (@websockets)",
|
|
5
5
|
"author": "Kamil Mysliwiec",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"main": "./index.js",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./index.js",
|
|
11
|
+
"./package.json": "./package.json",
|
|
11
12
|
"./internal": "./internal.js",
|
|
12
13
|
"./*.js": "./*.js",
|
|
13
14
|
"./*": "./*.js"
|
|
@@ -26,8 +27,8 @@
|
|
|
26
27
|
"tslib": "2.8.1"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@nestjs/common": "12.
|
|
30
|
-
"@nestjs/core": "12.
|
|
30
|
+
"@nestjs/common": "12.2.0",
|
|
31
|
+
"@nestjs/core": "12.2.0"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
33
34
|
"@nestjs/common": "^12.0.0",
|
|
@@ -41,5 +42,5 @@
|
|
|
41
42
|
"optional": true
|
|
42
43
|
}
|
|
43
44
|
},
|
|
44
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "af37111368de7fd2dfab0bdec2f530c7845510c2"
|
|
45
46
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ObservableInput, type OperatorFunction } from 'rxjs';
|
|
2
|
+
/**
|
|
3
|
+
* `mergeMap(project, concurrent)` for the messages of a client, which decides
|
|
4
|
+
* how many arrive before the first one has been handled. `mergeMap()` removes
|
|
5
|
+
* each completed inner subscription from an array of all the active ones, and
|
|
6
|
+
* `shift()`s each waiting value off an array of all the waiting ones, so a
|
|
7
|
+
* burst costs time quadratic in its size. Here at most `concurrent` inner
|
|
8
|
+
* subscriptions are active, and the values that wait for their turn are kept
|
|
9
|
+
* in a linked list. `onBacklog` is told when values start and stop waiting,
|
|
10
|
+
* e.g. to stop reading from the client in the meantime.
|
|
11
|
+
*/
|
|
12
|
+
export declare function mergeMapBounded<T, R>(project: (value: T) => ObservableInput<R>, concurrent: number, onBacklog?: (isBacklogged: boolean) => void): OperatorFunction<T, R>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { defer, Observable, } from 'rxjs';
|
|
2
|
+
/**
|
|
3
|
+
* `mergeMap(project, concurrent)` for the messages of a client, which decides
|
|
4
|
+
* how many arrive before the first one has been handled. `mergeMap()` removes
|
|
5
|
+
* each completed inner subscription from an array of all the active ones, and
|
|
6
|
+
* `shift()`s each waiting value off an array of all the waiting ones, so a
|
|
7
|
+
* burst costs time quadratic in its size. Here at most `concurrent` inner
|
|
8
|
+
* subscriptions are active, and the values that wait for their turn are kept
|
|
9
|
+
* in a linked list. `onBacklog` is told when values start and stop waiting,
|
|
10
|
+
* e.g. to stop reading from the client in the meantime.
|
|
11
|
+
*/
|
|
12
|
+
export function mergeMapBounded(project, concurrent, onBacklog) {
|
|
13
|
+
return source => new Observable(subscriber => {
|
|
14
|
+
let head;
|
|
15
|
+
let tail;
|
|
16
|
+
let active = 0;
|
|
17
|
+
let isDraining = false;
|
|
18
|
+
let isSourceComplete = false;
|
|
19
|
+
let isBacklogged = false;
|
|
20
|
+
// A loop, not recursion: an inner observable may complete synchronously,
|
|
21
|
+
// and how many values wait is up to the client.
|
|
22
|
+
const drain = () => {
|
|
23
|
+
if (isDraining) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
isDraining = true;
|
|
27
|
+
while (head && active < concurrent && !subscriber.closed) {
|
|
28
|
+
const { value } = head;
|
|
29
|
+
head = head.next;
|
|
30
|
+
active++;
|
|
31
|
+
subscriber.add(defer(() => project(value)).subscribe({
|
|
32
|
+
next: result => subscriber.next(result),
|
|
33
|
+
error: err => subscriber.error(err),
|
|
34
|
+
complete: () => {
|
|
35
|
+
active--;
|
|
36
|
+
drain();
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
isDraining = false;
|
|
41
|
+
if (onBacklog && isBacklogged !== !!head) {
|
|
42
|
+
isBacklogged = !!head;
|
|
43
|
+
onBacklog(isBacklogged);
|
|
44
|
+
}
|
|
45
|
+
if (isSourceComplete && !head && active === 0) {
|
|
46
|
+
subscriber.complete();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
subscriber.add(source.subscribe({
|
|
50
|
+
next: value => {
|
|
51
|
+
const waiting = { value };
|
|
52
|
+
tail = head ? (tail.next = waiting) : (head = waiting);
|
|
53
|
+
drain();
|
|
54
|
+
},
|
|
55
|
+
error: err => subscriber.error(err),
|
|
56
|
+
complete: () => {
|
|
57
|
+
isSourceComplete = true;
|
|
58
|
+
drain();
|
|
59
|
+
},
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -21,7 +21,6 @@ export declare class WebSocketsController {
|
|
|
21
21
|
private readonly appOptions;
|
|
22
22
|
private readonly logger;
|
|
23
23
|
private readonly metadataExplorer;
|
|
24
|
-
private readonly exceptionFiltersCache;
|
|
25
24
|
private readonly pendingDisconnects;
|
|
26
25
|
constructor(socketServerProvider: SocketServerProvider, config: ApplicationConfig, contextCreator: WsContextCreator, container: NestContainer, injector: Injector, exceptionFiltersContext: ExceptionFiltersContext, graphInspector: GraphInspector, appOptions?: NestApplicationContextOptions);
|
|
27
26
|
connectGatewayToServer(instanceWrapper: InstanceWrapper<NestGateway>, moduleKey: string): void;
|
|
@@ -38,6 +37,7 @@ export declare class WebSocketsController {
|
|
|
38
37
|
inspectEntrypointDefinitions(instance: NestGateway, port: number, messageHandlers: MessageMappingProperties[], instanceWrapperId: string): void;
|
|
39
38
|
createRequestScopedHandler(instanceWrapper: InstanceWrapper<NestGateway>, moduleRef: Module, moduleKey: string, methodName: string): (...args: unknown[]) => Promise<void>;
|
|
40
39
|
createRequestScopedEventHandler(instanceWrapper: InstanceWrapper<NestGateway>, moduleRef: Module, moduleKey: string, methodName: 'handleConnection' | 'handleDisconnect', server: object): (...args: unknown[]) => Promise<unknown>;
|
|
40
|
+
private logResolutionError;
|
|
41
41
|
private areGlobalScopedEnhancersDurable;
|
|
42
42
|
/**
|
|
43
43
|
* Runs a lifecycle hook of a default-scoped gateway and passes the errors it
|
|
@@ -21,7 +21,6 @@ export class WebSocketsController {
|
|
|
21
21
|
timestamp: true,
|
|
22
22
|
});
|
|
23
23
|
metadataExplorer = new GatewayMetadataExplorer(new MetadataScanner());
|
|
24
|
-
exceptionFiltersCache = new WeakMap();
|
|
25
24
|
pendingDisconnects = new WeakMap();
|
|
26
25
|
constructor(socketServerProvider, config, contextCreator, container, injector, exceptionFiltersContext, graphInspector, appOptions = {}) {
|
|
27
26
|
this.socketServerProvider = socketServerProvider;
|
|
@@ -86,7 +85,7 @@ export class WebSocketsController {
|
|
|
86
85
|
message,
|
|
87
86
|
methodName,
|
|
88
87
|
callback: isStatic && !hasGlobalScopedEnhancers
|
|
89
|
-
? this.contextCreator.create(instance, callback, moduleKey, methodName, STATIC_CONTEXT)
|
|
88
|
+
? this.contextCreator.create(instance, callback, moduleKey, methodName, STATIC_CONTEXT, instanceWrapper.id)
|
|
90
89
|
: this.createRequestScopedHandler(instanceWrapper, moduleRef, moduleKey, methodName),
|
|
91
90
|
isAckHandledManually,
|
|
92
91
|
}));
|
|
@@ -207,6 +206,9 @@ export class WebSocketsController {
|
|
|
207
206
|
...this.getGlobalScopedEnhancers(),
|
|
208
207
|
];
|
|
209
208
|
const pattern = Reflect.getMetadata(MESSAGE_METADATA, instance[methodName]) ?? 'unknown';
|
|
209
|
+
// Kept per gateway, not per handler function: gateways that inherit the
|
|
210
|
+
// handler from a base class can declare different filters
|
|
211
|
+
let exceptionFilter;
|
|
210
212
|
return async (...args) => {
|
|
211
213
|
const [client] = args;
|
|
212
214
|
try {
|
|
@@ -217,14 +219,11 @@ export class WebSocketsController {
|
|
|
217
219
|
// Resolve global enhancers without adding them to the gateway's dependency
|
|
218
220
|
// tree, so a singleton gateway keeps its state and lifecycle hooks.
|
|
219
221
|
await this.injector.loadEnhancersPerContext(instanceWrapper, contextId, instanceWrapper, scopedEnhancers);
|
|
222
|
+
await this.injector.waitForDeferredLoads(contextId);
|
|
220
223
|
return this.contextCreator.create(contextInstance, contextInstance[methodName], moduleKey, methodName, contextId, instanceWrapper.id)(...args);
|
|
221
224
|
}
|
|
222
225
|
catch (err) {
|
|
223
|
-
|
|
224
|
-
if (!exceptionFilter) {
|
|
225
|
-
exceptionFilter = this.exceptionFiltersContext.create(instance, instance[methodName], moduleKey);
|
|
226
|
-
this.exceptionFiltersCache.set(instance[methodName], exceptionFilter);
|
|
227
|
-
}
|
|
226
|
+
exceptionFilter ??= this.exceptionFiltersContext.create(instance, instance[methodName], moduleKey);
|
|
228
227
|
// The context failed to resolve before `WsProxy` could append the
|
|
229
228
|
// pattern, so add it here the same way.
|
|
230
229
|
const host = new ExecutionContextHost([...args, pattern]);
|
|
@@ -244,6 +243,7 @@ export class WebSocketsController {
|
|
|
244
243
|
return async (...args) => {
|
|
245
244
|
const [client] = args;
|
|
246
245
|
let contextId;
|
|
246
|
+
let isResolved = false;
|
|
247
247
|
try {
|
|
248
248
|
contextId = this.getContextId(client, isTreeDurable);
|
|
249
249
|
if (methodName === 'handleDisconnect') {
|
|
@@ -252,6 +252,7 @@ export class WebSocketsController {
|
|
|
252
252
|
const contextInstance = isStatic
|
|
253
253
|
? instance
|
|
254
254
|
: await this.injector.loadPerContext(instance, moduleRef, collection, contextId);
|
|
255
|
+
isResolved = true;
|
|
255
256
|
if (!isStatic) {
|
|
256
257
|
this.assignServerToProperties(contextInstance, server);
|
|
257
258
|
}
|
|
@@ -263,10 +264,26 @@ export class WebSocketsController {
|
|
|
263
264
|
// The gateway failed to resolve and has no hook whose exception
|
|
264
265
|
// filters could handle the error. Nothing awaits this handler, so
|
|
265
266
|
// rethrowing would end as an unhandled rejection.
|
|
266
|
-
this.
|
|
267
|
+
this.logResolutionError(instance, methodName, err);
|
|
267
268
|
return;
|
|
268
269
|
}
|
|
269
|
-
|
|
270
|
+
if (!isResolved && contextId) {
|
|
271
|
+
// The gateway's enhancers are created in the context of the
|
|
272
|
+
// connection along with it, so when it failed to resolve, its
|
|
273
|
+
// filters have to be created here.
|
|
274
|
+
try {
|
|
275
|
+
await this.injector.loadEnhancersPerContext(instanceWrapper, contextId, instanceWrapper);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
this.logResolutionError(instance, methodName, err);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Built for each connection: a request-scoped filter is only
|
|
283
|
+
// constructed in the context of the connection, and the static one
|
|
284
|
+
// holds an empty placeholder
|
|
285
|
+
const exceptionFilter = this.exceptionFiltersContext.create(instance, targetCallback, moduleKey, contextId, instanceWrapper.id);
|
|
286
|
+
this.handleLifecycleHookError(exceptionFilter, methodName, args, err);
|
|
270
287
|
}
|
|
271
288
|
finally {
|
|
272
289
|
if (methodName === 'handleDisconnect' && contextId) {
|
|
@@ -275,6 +292,9 @@ export class WebSocketsController {
|
|
|
275
292
|
}
|
|
276
293
|
};
|
|
277
294
|
}
|
|
295
|
+
logResolutionError(instance, methodName, err) {
|
|
296
|
+
this.logger.error(`Failed to resolve ${instance.constructor.name} on client ${methodName === 'handleConnection' ? 'connection' : 'disconnection'}`, err?.stack);
|
|
297
|
+
}
|
|
278
298
|
// A default-scoped gateway is resolved per connection only for its global
|
|
279
299
|
// scoped enhancers, so their trees decide whether the context is durable
|
|
280
300
|
// (mirrors InstanceWrapper#isDependencyTreeDurable).
|
|
@@ -293,8 +313,14 @@ export class WebSocketsController {
|
|
|
293
313
|
if (!hook) {
|
|
294
314
|
return undefined;
|
|
295
315
|
}
|
|
316
|
+
// Kept per gateway, not per hook function: gateways that inherit the
|
|
317
|
+
// hook from a base class can declare different filters
|
|
318
|
+
let exceptionFilter;
|
|
296
319
|
return (...args) => {
|
|
297
|
-
const handleError = (err) =>
|
|
320
|
+
const handleError = (err) => {
|
|
321
|
+
exceptionFilter ??= this.exceptionFiltersContext.create(instance, hook, moduleKey);
|
|
322
|
+
this.handleLifecycleHookError(exceptionFilter, methodName, args, err);
|
|
323
|
+
};
|
|
298
324
|
try {
|
|
299
325
|
const result = hook.apply(instance, args);
|
|
300
326
|
return isFunction(result?.then)
|
|
@@ -306,14 +332,7 @@ export class WebSocketsController {
|
|
|
306
332
|
}
|
|
307
333
|
};
|
|
308
334
|
}
|
|
309
|
-
handleLifecycleHookError(
|
|
310
|
-
// Only called for gateways that implement the hook
|
|
311
|
-
const targetCallback = instance[methodName];
|
|
312
|
-
let exceptionFilter = this.exceptionFiltersCache.get(targetCallback);
|
|
313
|
-
if (!exceptionFilter) {
|
|
314
|
-
exceptionFilter = this.exceptionFiltersContext.create(instance, targetCallback, moduleKey);
|
|
315
|
-
this.exceptionFiltersCache.set(targetCallback, exceptionFilter);
|
|
316
|
-
}
|
|
335
|
+
handleLifecycleHookError(exceptionFilter, methodName, args, err) {
|
|
317
336
|
const [client, disconnectReason] = args;
|
|
318
337
|
// Client first, data second and the pattern last, as for message handlers.
|
|
319
338
|
// With the raw hook arguments, the filter would send the last one (the
|