@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 CHANGED
@@ -10,3 +10,4 @@ export declare const CONNECTION_EVENT = "connection";
10
10
  export declare const DISCONNECT_EVENT = "disconnect";
11
11
  export declare const CLOSE_EVENT = "close";
12
12
  export declare const ERROR_EVENT = "error";
13
+ export declare const DEFAULT_MAX_CONCURRENT_MESSAGES = 1000;
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.emitMessage(client, 'exception', payload);
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
- this.emitMessage(client, 'exception', payload);
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]?.[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
@@ -6,3 +6,4 @@
6
6
  * @module
7
7
  */
8
8
  export * from './constants.js';
9
+ export * from './utils/merge-map-bounded.util.js';
package/internal.js CHANGED
@@ -6,3 +6,4 @@
6
6
  * @module
7
7
  */
8
8
  export * from './constants.js';
9
+ export * from './utils/merge-map-bounded.util.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestjs/websockets",
3
- "version": "12.1.3",
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.1.3",
30
- "@nestjs/core": "12.1.3"
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": "4514623279f6087808e3c8f08fbf9f37d3c17482"
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
- let exceptionFilter = this.exceptionFiltersCache.get(instance[methodName]);
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.logger.error(`Failed to resolve ${instance.constructor.name} on client ${methodName === 'handleConnection' ? 'connection' : 'disconnection'}`, err?.stack);
267
+ this.logResolutionError(instance, methodName, err);
267
268
  return;
268
269
  }
269
- this.handleLifecycleHookError(instance, moduleKey, methodName, args, err);
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) => this.handleLifecycleHookError(instance, moduleKey, methodName, args, 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(instance, moduleKey, methodName, args, err) {
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