@nestjs/websockets 12.1.2 → 12.1.3

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.
@@ -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() {
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestjs/websockets",
3
- "version": "12.1.2",
3
+ "version": "12.1.3",
4
4
  "description": "Nest - modern, fast, powerful node.js web framework (@websockets)",
5
5
  "author": "Kamil Mysliwiec",
6
6
  "license": "MIT",
@@ -26,8 +26,8 @@
26
26
  "tslib": "2.8.1"
27
27
  },
28
28
  "devDependencies": {
29
- "@nestjs/common": "12.1.2",
30
- "@nestjs/core": "12.1.2"
29
+ "@nestjs/common": "12.1.3",
30
+ "@nestjs/core": "12.1.3"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@nestjs/common": "^12.0.0",
@@ -41,5 +41,5 @@
41
41
  "optional": true
42
42
  }
43
43
  },
44
- "gitHead": "8ab60aa1b2927fbe0e23135ffc65d610c623236a"
44
+ "gitHead": "4514623279f6087808e3c8f08fbf9f37d3c17482"
45
45
  }
@@ -22,6 +22,7 @@ export declare class WebSocketsController {
22
22
  private readonly logger;
23
23
  private readonly metadataExplorer;
24
24
  private readonly exceptionFiltersCache;
25
+ private readonly pendingDisconnects;
25
26
  constructor(socketServerProvider: SocketServerProvider, config: ApplicationConfig, contextCreator: WsContextCreator, container: NestContainer, injector: Injector, exceptionFiltersContext: ExceptionFiltersContext, graphInspector: GraphInspector, appOptions?: NestApplicationContextOptions);
26
27
  connectGatewayToServer(instanceWrapper: InstanceWrapper<NestGateway>, moduleKey: string): void;
27
28
  connectGatewayToServer(instance: NestGateway, metatype: Type<unknown> | Function, moduleKey: string, instanceWrapperId: string): void;
@@ -37,6 +38,15 @@ export declare class WebSocketsController {
37
38
  inspectEntrypointDefinitions(instance: NestGateway, port: number, messageHandlers: MessageMappingProperties[], instanceWrapperId: string): void;
38
39
  createRequestScopedHandler(instanceWrapper: InstanceWrapper<NestGateway>, moduleRef: Module, moduleKey: string, methodName: string): (...args: unknown[]) => Promise<void>;
39
40
  createRequestScopedEventHandler(instanceWrapper: InstanceWrapper<NestGateway>, moduleRef: Module, moduleKey: string, methodName: 'handleConnection' | 'handleDisconnect', server: object): (...args: unknown[]) => Promise<unknown>;
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';
@@ -21,6 +22,7 @@ export class WebSocketsController {
21
22
  });
22
23
  metadataExplorer = new GatewayMetadataExplorer(new MetadataScanner());
23
24
  exceptionFiltersCache = new WeakMap();
25
+ pendingDisconnects = new WeakMap();
24
26
  constructor(socketServerProvider, config, contextCreator, container, injector, exceptionFiltersContext, graphInspector, appOptions = {}) {
25
27
  this.socketServerProvider = socketServerProvider;
26
28
  this.config = config;
@@ -54,6 +56,7 @@ export class WebSocketsController {
54
56
  id: targetInstanceWrapperId,
55
57
  isDependencyTreeStatic: () => true,
56
58
  isDependencyTreeDurable: () => false,
59
+ getEnhancersMetadata: () => [],
57
60
  };
58
61
  const gatewayMetatype = metatype ?? instance.constructor;
59
62
  const options = Reflect.getMetadata(GATEWAY_OPTIONS, gatewayMetatype) || {};
@@ -72,15 +75,17 @@ export class WebSocketsController {
72
75
  id: instanceWrapperId,
73
76
  isDependencyTreeStatic: () => true,
74
77
  isDependencyTreeDurable: () => false,
78
+ getEnhancersMetadata: () => [],
75
79
  };
76
80
  const { instance } = instanceWrapper;
77
81
  const nativeMessageHandlers = this.metadataExplorer.explore(instance);
78
82
  const isStatic = instanceWrapper.isDependencyTreeStatic();
83
+ const hasGlobalScopedEnhancers = this.getGlobalScopedEnhancers().length > 0;
79
84
  const moduleRef = this.container.getModuleByKey(moduleKey);
80
85
  const messageHandlers = nativeMessageHandlers.map(({ callback, isAckHandledManually, message, methodName }) => ({
81
86
  message,
82
87
  methodName,
83
- callback: isStatic
88
+ callback: isStatic && !hasGlobalScopedEnhancers
84
89
  ? this.contextCreator.create(instance, callback, moduleKey, methodName, STATIC_CONTEXT)
85
90
  : this.createRequestScopedHandler(instanceWrapper, moduleRef, moduleKey, methodName),
86
91
  isAckHandledManually,
@@ -92,9 +97,9 @@ export class WebSocketsController {
92
97
  const observableServer = this.socketServerProvider.scanForSocketServer(options, port);
93
98
  this.assignServerToProperties(instance, observableServer.server);
94
99
  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)
100
+ ? this.createStaticEventHandler(instance, moduleKey, 'handleConnection')
101
+ : this.createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, 'handleConnection', observableServer.server), isStatic && !hasGlobalScopedEnhancers
102
+ ? this.createStaticEventHandler(instance, moduleKey, 'handleDisconnect')
98
103
  : this.createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, 'handleDisconnect', observableServer.server));
99
104
  }
100
105
  subscribeEvents(instanceWrapper, subscribersMap, observableServer, connectionHandler, disconnectHandler) {
@@ -193,12 +198,25 @@ export class WebSocketsController {
193
198
  createRequestScopedHandler(instanceWrapper, moduleRef, moduleKey, methodName) {
194
199
  const { instance } = instanceWrapper;
195
200
  const collection = moduleRef.providers;
196
- const isTreeDurable = instanceWrapper.isDependencyTreeDurable();
201
+ const isStatic = instanceWrapper.isDependencyTreeStatic();
202
+ const isTreeDurable = isStatic
203
+ ? this.areGlobalScopedEnhancersDurable()
204
+ : instanceWrapper.isDependencyTreeDurable();
205
+ const scopedEnhancers = [
206
+ ...(isStatic ? instanceWrapper.getEnhancersMetadata() || [] : []),
207
+ ...this.getGlobalScopedEnhancers(),
208
+ ];
209
+ const pattern = Reflect.getMetadata(MESSAGE_METADATA, instance[methodName]) ?? 'unknown';
197
210
  return async (...args) => {
198
211
  const [client] = args;
199
212
  try {
200
213
  const contextId = this.getContextId(client, isTreeDurable);
201
- const contextInstance = await this.injector.loadPerContext(instance, moduleRef, collection, contextId);
214
+ const contextInstance = isStatic
215
+ ? instance
216
+ : await this.injector.loadPerContext(instance, moduleRef, collection, contextId);
217
+ // Resolve global enhancers without adding them to the gateway's dependency
218
+ // tree, so a singleton gateway keeps its state and lifecycle hooks.
219
+ await this.injector.loadEnhancersPerContext(instanceWrapper, contextId, instanceWrapper, scopedEnhancers);
202
220
  return this.contextCreator.create(contextInstance, contextInstance[methodName], moduleKey, methodName, contextId, instanceWrapper.id)(...args);
203
221
  }
204
222
  catch (err) {
@@ -207,7 +225,9 @@ export class WebSocketsController {
207
225
  exceptionFilter = this.exceptionFiltersContext.create(instance, instance[methodName], moduleKey);
208
226
  this.exceptionFiltersCache.set(instance[methodName], exceptionFilter);
209
227
  }
210
- const host = new ExecutionContextHost(args);
228
+ // The context failed to resolve before `WsProxy` could append the
229
+ // pattern, so add it here the same way.
230
+ const host = new ExecutionContextHost([...args, pattern]);
211
231
  host.setType('ws');
212
232
  exceptionFilter.handle(err, host);
213
233
  }
@@ -216,30 +236,37 @@ export class WebSocketsController {
216
236
  createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, methodName, server) {
217
237
  const { instance } = instanceWrapper;
218
238
  const collection = moduleRef.providers;
219
- const isTreeDurable = instanceWrapper.isDependencyTreeDurable();
239
+ const isStatic = instanceWrapper.isDependencyTreeStatic();
240
+ const isTreeDurable = isStatic
241
+ ? this.areGlobalScopedEnhancersDurable()
242
+ : instanceWrapper.isDependencyTreeDurable();
220
243
  const targetCallback = instance[methodName];
221
244
  return async (...args) => {
222
245
  const [client] = args;
223
246
  let contextId;
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
+ if (!isStatic) {
256
+ this.assignServerToProperties(contextInstance, server);
257
+ }
228
258
  const scopedMethod = contextInstance[methodName];
229
259
  return await scopedMethod?.apply(contextInstance, args);
230
260
  }
231
261
  catch (err) {
232
262
  if (!targetCallback) {
233
- throw err;
234
- }
235
- let exceptionFilter = this.exceptionFiltersCache.get(targetCallback);
236
- if (!exceptionFilter) {
237
- exceptionFilter = this.exceptionFiltersContext.create(instance, targetCallback, moduleKey);
238
- this.exceptionFiltersCache.set(targetCallback, exceptionFilter);
263
+ // The gateway failed to resolve and has no hook whose exception
264
+ // filters could handle the error. Nothing awaits this handler, so
265
+ // 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
+ return;
239
268
  }
240
- const host = new ExecutionContextHost(args);
241
- host.setType('ws');
242
- exceptionFilter.handle(err, host);
269
+ this.handleLifecycleHookError(instance, moduleKey, methodName, args, err);
243
270
  }
244
271
  finally {
245
272
  if (methodName === 'handleDisconnect' && contextId) {
@@ -248,6 +275,63 @@ export class WebSocketsController {
248
275
  }
249
276
  };
250
277
  }
278
+ // A default-scoped gateway is resolved per connection only for its global
279
+ // scoped enhancers, so their trees decide whether the context is durable
280
+ // (mirrors InstanceWrapper#isDependencyTreeDurable).
281
+ areGlobalScopedEnhancersDurable() {
282
+ const scopedEnhancers = this.getGlobalScopedEnhancers().filter(wrapper => !wrapper.isDependencyTreeStatic());
283
+ return (scopedEnhancers.length > 0 &&
284
+ scopedEnhancers.every(wrapper => wrapper.isDependencyTreeDurable()));
285
+ }
286
+ /**
287
+ * Runs a lifecycle hook of a default-scoped gateway and passes the errors it
288
+ * throws or rejects with to the gateway's exception filters, as for message
289
+ * handlers. The hook still runs synchronously.
290
+ */
291
+ createStaticEventHandler(instance, moduleKey, methodName) {
292
+ const hook = instance[methodName];
293
+ if (!hook) {
294
+ return undefined;
295
+ }
296
+ return (...args) => {
297
+ const handleError = (err) => this.handleLifecycleHookError(instance, moduleKey, methodName, args, err);
298
+ try {
299
+ const result = hook.apply(instance, args);
300
+ return isFunction(result?.then)
301
+ ? result.catch(handleError)
302
+ : result;
303
+ }
304
+ catch (err) {
305
+ handleError(err);
306
+ }
307
+ };
308
+ }
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
+ }
317
+ const [client, disconnectReason] = args;
318
+ // Client first, data second and the pattern last, as for message handlers.
319
+ // With the raw hook arguments, the filter would send the last one (the
320
+ // socket itself with socket.io, the upgrade request with ws) back to the
321
+ // client as the pattern. The disconnect reason is safe to pass as data,
322
+ // as nothing is sent to a client that has already disconnected.
323
+ const data = methodName === 'handleDisconnect' ? disconnectReason : undefined;
324
+ const host = new ExecutionContextHost([client, data, methodName]);
325
+ host.setType('ws');
326
+ exceptionFilter.handle(err, host);
327
+ }
328
+ getGlobalScopedEnhancers() {
329
+ return [
330
+ ...this.config.getGlobalRequestGuards(),
331
+ ...this.config.getGlobalRequestPipes(),
332
+ ...this.config.getGlobalRequestInterceptors(),
333
+ ];
334
+ }
251
335
  getContextId(request, isTreeDurable) {
252
336
  const contextId = ContextIdFactory.getByRequest(request);
253
337
  if (!request[REQUEST_CONTEXT_ID]) {
@@ -264,8 +348,18 @@ export class WebSocketsController {
264
348
  }
265
349
  return contextId;
266
350
  }
267
- cleanupRequestScopedContext(_instanceWrapper, _contextId, request) {
268
- Reflect.deleteProperty(request, REQUEST_CONTEXT_ID);
351
+ cleanupRequestScopedContext(_instanceWrapper, contextId, request) {
352
+ const remaining = (this.pendingDisconnects.get(contextId) ?? 1) - 1;
353
+ if (remaining > 0) {
354
+ this.pendingDisconnects.set(contextId, remaining);
355
+ return;
356
+ }
357
+ this.pendingDisconnects.delete(contextId);
358
+ // Other gateways can still be executing their asynchronous disconnect hooks.
359
+ // A finished cycle must also never remove a newer context on the same client.
360
+ if (request[REQUEST_CONTEXT_ID] === contextId) {
361
+ Reflect.deleteProperty(request, REQUEST_CONTEXT_ID);
362
+ }
269
363
  }
270
364
  assignServerToProperties(instance, server) {
271
365
  for (const propertyKey of this.metadataExplorer.scanForServerHooks(instance)) {