@nestjs/websockets 12.1.2 → 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;
@@ -15,9 +15,9 @@ export class WsException extends Error {
15
15
  this.message = this.error.message;
16
16
  }
17
17
  else if (this.constructor) {
18
- this.message = this.constructor.name
19
- .match(/[A-Z][a-z]+|[0-9]+/g)
20
- .join(' ');
18
+ this.message =
19
+ this.constructor.name.match(/[A-Z][a-z]+|[0-9]+/g)?.join(' ') ??
20
+ 'Error';
21
21
  }
22
22
  }
23
23
  getError() {
@@ -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
@@ -10,9 +10,11 @@ export class BaseWsExceptionFilter {
10
10
  static logger = new Logger('WsExceptionsHandler');
11
11
  constructor(options = {}) {
12
12
  this.options = options;
13
- this.options.includeCause = this.options.includeCause ?? true;
14
- this.options.causeFactory =
15
- this.options.causeFactory ?? ((pattern, data) => ({ pattern, data }));
13
+ this.options = {
14
+ ...this.options,
15
+ includeCause: this.options.includeCause ?? true,
16
+ causeFactory: this.options.causeFactory ?? ((pattern, data) => ({ pattern, data })),
17
+ };
16
18
  }
17
19
  catch(exception, host) {
18
20
  const client = host.switchToWs().getClient();
@@ -39,7 +41,7 @@ export class BaseWsExceptionFilter {
39
41
  if (this.options?.includeCause && cause) {
40
42
  payload.cause = this.options.causeFactory(cause.pattern, cause.data);
41
43
  }
42
- this.emitMessage(client, 'exception', payload);
44
+ this.emitErrorPayload(client, payload);
43
45
  }
44
46
  handleUnknownError(exception, client, data) {
45
47
  const status = 'error';
@@ -50,11 +52,12 @@ export class BaseWsExceptionFilter {
50
52
  if (this.options?.includeCause && data) {
51
53
  payload.cause = this.options.causeFactory(data.pattern, data.data);
52
54
  }
53
- this.emitMessage(client, 'exception', payload);
55
+ // Logged first, so that a reply that cannot be sent never hides it
54
56
  if (!(exception instanceof IntrinsicException)) {
55
57
  const logger = BaseWsExceptionFilter.logger;
56
58
  logger.error(exception);
57
59
  }
60
+ this.emitErrorPayload(client, payload);
58
61
  }
59
62
  isExceptionObject(err) {
60
63
  return isObject(err) && !!err.message;
@@ -80,6 +83,27 @@ export class BaseWsExceptionFilter {
80
83
  client.emit(event, payload);
81
84
  }
82
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
+ }
83
107
  /**
84
108
  * Determines whether the given client is a native WebSocket (e.g. from the
85
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.2",
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.2",
30
- "@nestjs/core": "12.1.2"
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": "8ab60aa1b2927fbe0e23135ffc65d610c623236a"
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,7 @@ export declare class WebSocketsController {
21
21
  private readonly appOptions;
22
22
  private readonly logger;
23
23
  private readonly metadataExplorer;
24
- private readonly exceptionFiltersCache;
24
+ private readonly pendingDisconnects;
25
25
  constructor(socketServerProvider: SocketServerProvider, config: ApplicationConfig, contextCreator: WsContextCreator, container: NestContainer, injector: Injector, exceptionFiltersContext: ExceptionFiltersContext, graphInspector: GraphInspector, appOptions?: NestApplicationContextOptions);
26
26
  connectGatewayToServer(instanceWrapper: InstanceWrapper<NestGateway>, moduleKey: string): void;
27
27
  connectGatewayToServer(instance: NestGateway, metatype: Type<unknown> | Function, moduleKey: string, instanceWrapperId: string): void;
@@ -37,6 +37,16 @@ export declare class WebSocketsController {
37
37
  inspectEntrypointDefinitions(instance: NestGateway, port: number, messageHandlers: MessageMappingProperties[], instanceWrapperId: string): void;
38
38
  createRequestScopedHandler(instanceWrapper: InstanceWrapper<NestGateway>, moduleRef: Module, moduleKey: string, methodName: string): (...args: unknown[]) => Promise<void>;
39
39
  createRequestScopedEventHandler(instanceWrapper: InstanceWrapper<NestGateway>, moduleRef: Module, moduleKey: string, methodName: 'handleConnection' | 'handleDisconnect', server: object): (...args: unknown[]) => Promise<unknown>;
40
+ private logResolutionError;
41
+ private areGlobalScopedEnhancersDurable;
42
+ /**
43
+ * Runs a lifecycle hook of a default-scoped gateway and passes the errors it
44
+ * throws or rejects with to the gateway's exception filters, as for message
45
+ * handlers. The hook still runs synchronously.
46
+ */
47
+ createStaticEventHandler(instance: NestGateway, moduleKey: string, methodName: 'handleConnection' | 'handleDisconnect'): ((...args: unknown[]) => unknown) | undefined;
48
+ private handleLifecycleHookError;
49
+ private getGlobalScopedEnhancers;
40
50
  getContextId<T extends Record<any, unknown> = any>(request: T, isTreeDurable: boolean): ContextId;
41
51
  private cleanupRequestScopedContext;
42
52
  private assignServerToProperties;
@@ -1,9 +1,10 @@
1
1
  import { from as fromPromise, isObservable, of, } from 'rxjs';
2
2
  import { distinctUntilChanged, mergeAll } from 'rxjs/operators';
3
- import { GATEWAY_OPTIONS, PORT_METADATA } from './constants.js';
3
+ import { GATEWAY_OPTIONS, MESSAGE_METADATA, PORT_METADATA, } from './constants.js';
4
4
  import { InvalidSocketPortException } from './errors/invalid-socket-port.exception.js';
5
5
  import { GatewayMetadataExplorer, } from './gateway-metadata-explorer.js';
6
6
  import { compareElementAt } from './utils/compare-element.util.js';
7
+ import { isFunction } from '@nestjs/common/internal';
7
8
  import { Logger } from '@nestjs/common';
8
9
  import { ContextIdFactory, MetadataScanner, } from '@nestjs/core';
9
10
  import { ExecutionContextHost, REQUEST_CONTEXT_ID, STATIC_CONTEXT, } from '@nestjs/core/internal';
@@ -20,7 +21,7 @@ export class WebSocketsController {
20
21
  timestamp: true,
21
22
  });
22
23
  metadataExplorer = new GatewayMetadataExplorer(new MetadataScanner());
23
- exceptionFiltersCache = new WeakMap();
24
+ pendingDisconnects = new WeakMap();
24
25
  constructor(socketServerProvider, config, contextCreator, container, injector, exceptionFiltersContext, graphInspector, appOptions = {}) {
25
26
  this.socketServerProvider = socketServerProvider;
26
27
  this.config = config;
@@ -54,6 +55,7 @@ export class WebSocketsController {
54
55
  id: targetInstanceWrapperId,
55
56
  isDependencyTreeStatic: () => true,
56
57
  isDependencyTreeDurable: () => false,
58
+ getEnhancersMetadata: () => [],
57
59
  };
58
60
  const gatewayMetatype = metatype ?? instance.constructor;
59
61
  const options = Reflect.getMetadata(GATEWAY_OPTIONS, gatewayMetatype) || {};
@@ -72,16 +74,18 @@ export class WebSocketsController {
72
74
  id: instanceWrapperId,
73
75
  isDependencyTreeStatic: () => true,
74
76
  isDependencyTreeDurable: () => false,
77
+ getEnhancersMetadata: () => [],
75
78
  };
76
79
  const { instance } = instanceWrapper;
77
80
  const nativeMessageHandlers = this.metadataExplorer.explore(instance);
78
81
  const isStatic = instanceWrapper.isDependencyTreeStatic();
82
+ const hasGlobalScopedEnhancers = this.getGlobalScopedEnhancers().length > 0;
79
83
  const moduleRef = this.container.getModuleByKey(moduleKey);
80
84
  const messageHandlers = nativeMessageHandlers.map(({ callback, isAckHandledManually, message, methodName }) => ({
81
85
  message,
82
86
  methodName,
83
- callback: isStatic
84
- ? this.contextCreator.create(instance, callback, moduleKey, methodName, STATIC_CONTEXT)
87
+ callback: isStatic && !hasGlobalScopedEnhancers
88
+ ? this.contextCreator.create(instance, callback, moduleKey, methodName, STATIC_CONTEXT, instanceWrapper.id)
85
89
  : this.createRequestScopedHandler(instanceWrapper, moduleRef, moduleKey, methodName),
86
90
  isAckHandledManually,
87
91
  }));
@@ -92,9 +96,9 @@ export class WebSocketsController {
92
96
  const observableServer = this.socketServerProvider.scanForSocketServer(options, port);
93
97
  this.assignServerToProperties(instance, observableServer.server);
94
98
  this.subscribeEvents(instanceWrapper, messageHandlers, observableServer, isStatic
95
- ? instance.handleConnection?.bind(instance)
96
- : this.createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, 'handleConnection', observableServer.server), isStatic
97
- ? instance.handleDisconnect?.bind(instance)
99
+ ? this.createStaticEventHandler(instance, moduleKey, 'handleConnection')
100
+ : this.createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, 'handleConnection', observableServer.server), isStatic && !hasGlobalScopedEnhancers
101
+ ? this.createStaticEventHandler(instance, moduleKey, 'handleDisconnect')
98
102
  : this.createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, 'handleDisconnect', observableServer.server));
99
103
  }
100
104
  subscribeEvents(instanceWrapper, subscribersMap, observableServer, connectionHandler, disconnectHandler) {
@@ -193,21 +197,36 @@ export class WebSocketsController {
193
197
  createRequestScopedHandler(instanceWrapper, moduleRef, moduleKey, methodName) {
194
198
  const { instance } = instanceWrapper;
195
199
  const collection = moduleRef.providers;
196
- const isTreeDurable = instanceWrapper.isDependencyTreeDurable();
200
+ const isStatic = instanceWrapper.isDependencyTreeStatic();
201
+ const isTreeDurable = isStatic
202
+ ? this.areGlobalScopedEnhancersDurable()
203
+ : instanceWrapper.isDependencyTreeDurable();
204
+ const scopedEnhancers = [
205
+ ...(isStatic ? instanceWrapper.getEnhancersMetadata() || [] : []),
206
+ ...this.getGlobalScopedEnhancers(),
207
+ ];
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;
197
212
  return async (...args) => {
198
213
  const [client] = args;
199
214
  try {
200
215
  const contextId = this.getContextId(client, isTreeDurable);
201
- const contextInstance = await this.injector.loadPerContext(instance, moduleRef, collection, contextId);
216
+ const contextInstance = isStatic
217
+ ? instance
218
+ : await this.injector.loadPerContext(instance, moduleRef, collection, contextId);
219
+ // Resolve global enhancers without adding them to the gateway's dependency
220
+ // tree, so a singleton gateway keeps its state and lifecycle hooks.
221
+ await this.injector.loadEnhancersPerContext(instanceWrapper, contextId, instanceWrapper, scopedEnhancers);
222
+ await this.injector.waitForDeferredLoads(contextId);
202
223
  return this.contextCreator.create(contextInstance, contextInstance[methodName], moduleKey, methodName, contextId, instanceWrapper.id)(...args);
203
224
  }
204
225
  catch (err) {
205
- let exceptionFilter = this.exceptionFiltersCache.get(instance[methodName]);
206
- if (!exceptionFilter) {
207
- exceptionFilter = this.exceptionFiltersContext.create(instance, instance[methodName], moduleKey);
208
- this.exceptionFiltersCache.set(instance[methodName], exceptionFilter);
209
- }
210
- const host = new ExecutionContextHost(args);
226
+ exceptionFilter ??= this.exceptionFiltersContext.create(instance, instance[methodName], moduleKey);
227
+ // The context failed to resolve before `WsProxy` could append the
228
+ // pattern, so add it here the same way.
229
+ const host = new ExecutionContextHost([...args, pattern]);
211
230
  host.setType('ws');
212
231
  exceptionFilter.handle(err, host);
213
232
  }
@@ -216,30 +235,55 @@ export class WebSocketsController {
216
235
  createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, methodName, server) {
217
236
  const { instance } = instanceWrapper;
218
237
  const collection = moduleRef.providers;
219
- const isTreeDurable = instanceWrapper.isDependencyTreeDurable();
238
+ const isStatic = instanceWrapper.isDependencyTreeStatic();
239
+ const isTreeDurable = isStatic
240
+ ? this.areGlobalScopedEnhancersDurable()
241
+ : instanceWrapper.isDependencyTreeDurable();
220
242
  const targetCallback = instance[methodName];
221
243
  return async (...args) => {
222
244
  const [client] = args;
223
245
  let contextId;
246
+ let isResolved = false;
224
247
  try {
225
248
  contextId = this.getContextId(client, isTreeDurable);
226
- const contextInstance = await this.injector.loadPerContext(instance, moduleRef, collection, contextId);
227
- this.assignServerToProperties(contextInstance, server);
249
+ if (methodName === 'handleDisconnect') {
250
+ this.pendingDisconnects.set(contextId, (this.pendingDisconnects.get(contextId) ?? 0) + 1);
251
+ }
252
+ const contextInstance = isStatic
253
+ ? instance
254
+ : await this.injector.loadPerContext(instance, moduleRef, collection, contextId);
255
+ isResolved = true;
256
+ if (!isStatic) {
257
+ this.assignServerToProperties(contextInstance, server);
258
+ }
228
259
  const scopedMethod = contextInstance[methodName];
229
260
  return await scopedMethod?.apply(contextInstance, args);
230
261
  }
231
262
  catch (err) {
232
263
  if (!targetCallback) {
233
- throw err;
264
+ // The gateway failed to resolve and has no hook whose exception
265
+ // filters could handle the error. Nothing awaits this handler, so
266
+ // rethrowing would end as an unhandled rejection.
267
+ this.logResolutionError(instance, methodName, err);
268
+ return;
234
269
  }
235
- let exceptionFilter = this.exceptionFiltersCache.get(targetCallback);
236
- if (!exceptionFilter) {
237
- exceptionFilter = this.exceptionFiltersContext.create(instance, targetCallback, moduleKey);
238
- this.exceptionFiltersCache.set(targetCallback, exceptionFilter);
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
+ }
239
281
  }
240
- const host = new ExecutionContextHost(args);
241
- host.setType('ws');
242
- exceptionFilter.handle(err, host);
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);
243
287
  }
244
288
  finally {
245
289
  if (methodName === 'handleDisconnect' && contextId) {
@@ -248,6 +292,65 @@ export class WebSocketsController {
248
292
  }
249
293
  };
250
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
+ }
298
+ // A default-scoped gateway is resolved per connection only for its global
299
+ // scoped enhancers, so their trees decide whether the context is durable
300
+ // (mirrors InstanceWrapper#isDependencyTreeDurable).
301
+ areGlobalScopedEnhancersDurable() {
302
+ const scopedEnhancers = this.getGlobalScopedEnhancers().filter(wrapper => !wrapper.isDependencyTreeStatic());
303
+ return (scopedEnhancers.length > 0 &&
304
+ scopedEnhancers.every(wrapper => wrapper.isDependencyTreeDurable()));
305
+ }
306
+ /**
307
+ * Runs a lifecycle hook of a default-scoped gateway and passes the errors it
308
+ * throws or rejects with to the gateway's exception filters, as for message
309
+ * handlers. The hook still runs synchronously.
310
+ */
311
+ createStaticEventHandler(instance, moduleKey, methodName) {
312
+ const hook = instance[methodName];
313
+ if (!hook) {
314
+ return undefined;
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;
319
+ return (...args) => {
320
+ const handleError = (err) => {
321
+ exceptionFilter ??= this.exceptionFiltersContext.create(instance, hook, moduleKey);
322
+ this.handleLifecycleHookError(exceptionFilter, methodName, args, err);
323
+ };
324
+ try {
325
+ const result = hook.apply(instance, args);
326
+ return isFunction(result?.then)
327
+ ? result.catch(handleError)
328
+ : result;
329
+ }
330
+ catch (err) {
331
+ handleError(err);
332
+ }
333
+ };
334
+ }
335
+ handleLifecycleHookError(exceptionFilter, methodName, args, err) {
336
+ const [client, disconnectReason] = args;
337
+ // Client first, data second and the pattern last, as for message handlers.
338
+ // With the raw hook arguments, the filter would send the last one (the
339
+ // socket itself with socket.io, the upgrade request with ws) back to the
340
+ // client as the pattern. The disconnect reason is safe to pass as data,
341
+ // as nothing is sent to a client that has already disconnected.
342
+ const data = methodName === 'handleDisconnect' ? disconnectReason : undefined;
343
+ const host = new ExecutionContextHost([client, data, methodName]);
344
+ host.setType('ws');
345
+ exceptionFilter.handle(err, host);
346
+ }
347
+ getGlobalScopedEnhancers() {
348
+ return [
349
+ ...this.config.getGlobalRequestGuards(),
350
+ ...this.config.getGlobalRequestPipes(),
351
+ ...this.config.getGlobalRequestInterceptors(),
352
+ ];
353
+ }
251
354
  getContextId(request, isTreeDurable) {
252
355
  const contextId = ContextIdFactory.getByRequest(request);
253
356
  if (!request[REQUEST_CONTEXT_ID]) {
@@ -264,8 +367,18 @@ export class WebSocketsController {
264
367
  }
265
368
  return contextId;
266
369
  }
267
- cleanupRequestScopedContext(_instanceWrapper, _contextId, request) {
268
- Reflect.deleteProperty(request, REQUEST_CONTEXT_ID);
370
+ cleanupRequestScopedContext(_instanceWrapper, contextId, request) {
371
+ const remaining = (this.pendingDisconnects.get(contextId) ?? 1) - 1;
372
+ if (remaining > 0) {
373
+ this.pendingDisconnects.set(contextId, remaining);
374
+ return;
375
+ }
376
+ this.pendingDisconnects.delete(contextId);
377
+ // Other gateways can still be executing their asynchronous disconnect hooks.
378
+ // A finished cycle must also never remove a newer context on the same client.
379
+ if (request[REQUEST_CONTEXT_ID] === contextId) {
380
+ Reflect.deleteProperty(request, REQUEST_CONTEXT_ID);
381
+ }
269
382
  }
270
383
  assignServerToProperties(instance, server) {
271
384
  for (const propertyKey of this.metadataExplorer.scanForServerHooks(instance)) {