@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 +1 -0
- package/constants.js +2 -0
- package/errors/ws-exception.js +3 -3
- package/exceptions/base-ws-exception-filter.d.ts +7 -0
- package/exceptions/base-ws-exception-filter.js +29 -5
- 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 +11 -1
- package/web-sockets-controller.js +141 -28
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;
|
package/errors/ws-exception.js
CHANGED
|
@@ -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 =
|
|
19
|
-
.match(/[A-Z][a-z]+|[0-9]+/g)
|
|
20
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
this.options.
|
|
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.
|
|
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
|
-
|
|
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]
|
|
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,7 @@ export declare class WebSocketsController {
|
|
|
21
21
|
private readonly appOptions;
|
|
22
22
|
private readonly logger;
|
|
23
23
|
private readonly metadataExplorer;
|
|
24
|
-
private readonly
|
|
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
|
-
|
|
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
|
-
?
|
|
96
|
-
: this.createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, 'handleConnection', observableServer.server), isStatic
|
|
97
|
-
?
|
|
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
|
|
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 =
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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,
|
|
268
|
-
|
|
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)) {
|