@nestjs/websockets
Version:
Nest - modern, fast, powerful node.js web framework (@websockets)
377 lines (376 loc) • 18.4 kB
JavaScript
import { from as fromPromise, isObservable, of, } from 'rxjs';
import { distinctUntilChanged, mergeAll } from 'rxjs/operators';
import { GATEWAY_OPTIONS, MESSAGE_METADATA, PORT_METADATA, } from './constants.js';
import { InvalidSocketPortException } from './errors/invalid-socket-port.exception.js';
import { GatewayMetadataExplorer, } from './gateway-metadata-explorer.js';
import { compareElementAt } from './utils/compare-element.util.js';
import { isFunction } from '@nestjs/common/internal';
import { Logger } from '@nestjs/common';
import { ContextIdFactory, MetadataScanner, } from '@nestjs/core';
import { ExecutionContextHost, REQUEST_CONTEXT_ID, STATIC_CONTEXT, } from '@nestjs/core/internal';
export class WebSocketsController {
socketServerProvider;
config;
contextCreator;
container;
injector;
exceptionFiltersContext;
graphInspector;
appOptions;
logger = new Logger(WebSocketsController.name, {
timestamp: true,
});
metadataExplorer = new GatewayMetadataExplorer(new MetadataScanner());
exceptionFiltersCache = new WeakMap();
pendingDisconnects = new WeakMap();
constructor(socketServerProvider, config, contextCreator, container, injector, exceptionFiltersContext, graphInspector, appOptions = {}) {
this.socketServerProvider = socketServerProvider;
this.config = config;
this.contextCreator = contextCreator;
this.container = container;
this.injector = injector;
this.exceptionFiltersContext = exceptionFiltersContext;
this.graphInspector = graphInspector;
this.appOptions = appOptions;
}
connectGatewayToServer(instanceOrWrapper, metatypeOrModuleKey, moduleKey, instanceWrapperId) {
const isInstanceWrapper = typeof metatypeOrModuleKey === 'string' &&
'instance' in instanceOrWrapper;
const instance = isInstanceWrapper
? instanceOrWrapper.instance
: instanceOrWrapper;
const metatype = isInstanceWrapper
? instanceOrWrapper.metatype
: metatypeOrModuleKey;
const targetModuleKey = isInstanceWrapper
? metatypeOrModuleKey
: moduleKey;
const targetInstanceWrapperId = isInstanceWrapper
? instanceOrWrapper.id
: instanceWrapperId;
const instanceWrapper = isInstanceWrapper
? instanceOrWrapper
: {
instance,
metatype,
id: targetInstanceWrapperId,
isDependencyTreeStatic: () => true,
isDependencyTreeDurable: () => false,
getEnhancersMetadata: () => [],
};
const gatewayMetatype = metatype ?? instance.constructor;
const options = Reflect.getMetadata(GATEWAY_OPTIONS, gatewayMetatype) || {};
const port = Reflect.getMetadata(PORT_METADATA, gatewayMetatype) || 0;
if (!Number.isInteger(port)) {
throw new InvalidSocketPortException(port, gatewayMetatype);
}
this.subscribeToServerEvents(instanceWrapper, options, port, targetModuleKey, targetInstanceWrapperId);
}
subscribeToServerEvents(instanceOrWrapper, options, port, moduleKey, instanceWrapperId) {
const instanceWrapper = 'instance' in instanceOrWrapper
? instanceOrWrapper
: {
instance: instanceOrWrapper,
metatype: instanceOrWrapper.constructor,
id: instanceWrapperId,
isDependencyTreeStatic: () => true,
isDependencyTreeDurable: () => false,
getEnhancersMetadata: () => [],
};
const { instance } = instanceWrapper;
const nativeMessageHandlers = this.metadataExplorer.explore(instance);
const isStatic = instanceWrapper.isDependencyTreeStatic();
const hasGlobalScopedEnhancers = this.getGlobalScopedEnhancers().length > 0;
const moduleRef = this.container.getModuleByKey(moduleKey);
const messageHandlers = nativeMessageHandlers.map(({ callback, isAckHandledManually, message, methodName }) => ({
message,
methodName,
callback: isStatic && !hasGlobalScopedEnhancers
? this.contextCreator.create(instance, callback, moduleKey, methodName, STATIC_CONTEXT)
: this.createRequestScopedHandler(instanceWrapper, moduleRef, moduleKey, methodName),
isAckHandledManually,
}));
this.inspectEntrypointDefinitions(instance, port, messageHandlers, instanceWrapperId);
if (this.appOptions.preview) {
return;
}
const observableServer = this.socketServerProvider.scanForSocketServer(options, port);
this.assignServerToProperties(instance, observableServer.server);
this.subscribeEvents(instanceWrapper, messageHandlers, observableServer, isStatic
? this.createStaticEventHandler(instance, moduleKey, 'handleConnection')
: this.createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, 'handleConnection', observableServer.server), isStatic && !hasGlobalScopedEnhancers
? this.createStaticEventHandler(instance, moduleKey, 'handleDisconnect')
: this.createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, 'handleDisconnect', observableServer.server));
}
subscribeEvents(instanceWrapper, subscribersMap, observableServer, connectionHandler, disconnectHandler) {
const { instance } = instanceWrapper;
const { init, disconnect, connection, server } = observableServer;
const adapter = this.config.getIoAdapter();
this.subscribeInitEvent(instance, init);
this.subscribeConnectionEvent(connectionHandler, connection);
this.subscribeDisconnectEvent(disconnectHandler, disconnect);
const handler = this.getConnectionHandler(this, instance, subscribersMap, disconnect, connection);
adapter.bindClientConnect(server, handler);
this.printSubscriptionLogs(instance, subscribersMap);
}
getConnectionHandler(context, instance, subscribersMap, disconnect, connection) {
const adapter = this.config.getIoAdapter();
return (...args) => {
const [client] = args;
connection.next(args);
context.subscribeMessages(subscribersMap, client, instance);
const disconnectHook = adapter.bindClientDisconnect;
disconnectHook &&
disconnectHook.call(adapter, client, (reason) => disconnect.next({ client, reason }));
};
}
subscribeInitEvent(instance, event) {
if (instance.afterInit) {
event.subscribe(instance.afterInit.bind(instance));
}
}
subscribeConnectionEvent(handlerOrGateway, event) {
const handler = typeof handlerOrGateway === 'function'
? handlerOrGateway
: handlerOrGateway?.handleConnection?.bind(handlerOrGateway);
if (handler) {
event
.pipe(distinctUntilChanged((prev, curr) => compareElementAt(prev, curr, 0)))
.subscribe((args) => handler(...args));
}
}
subscribeDisconnectEvent(handlerOrGateway, event) {
const handler = typeof handlerOrGateway === 'function'
? handlerOrGateway
: handlerOrGateway?.handleDisconnect?.bind(handlerOrGateway);
if (handler) {
event
.pipe(distinctUntilChanged((prev, curr) => {
const prevClient = prev?.client || prev;
const currClient = curr?.client || curr;
return prevClient === currClient;
}))
.subscribe((data) => {
if (data && typeof data === 'object' && 'client' in data) {
handler(data.client, data.reason);
}
else {
// Backward compatibility: if it's just the client
handler(data);
}
});
}
}
subscribeMessages(subscribersMap, client, instance) {
const adapter = this.config.getIoAdapter();
const handlers = subscribersMap.map(({ callback, message, isAckHandledManually }) => ({
message,
callback: callback.bind(instance, client),
isAckHandledManually,
}));
adapter.bindMessageHandlers(client, handlers, data => fromPromise(this.pickResult(data)).pipe(mergeAll()));
}
async pickResult(deferredResult) {
const result = await deferredResult;
if (isObservable(result)) {
return result;
}
if (result instanceof Promise) {
return fromPromise(result);
}
return of(result);
}
inspectEntrypointDefinitions(instance, port, messageHandlers, instanceWrapperId) {
messageHandlers.forEach(handler => {
this.graphInspector.insertEntrypointDefinition({
type: 'websocket',
methodName: handler.methodName,
className: instance.constructor?.name,
classNodeId: instanceWrapperId,
metadata: {
port,
key: handler.message,
message: handler.message,
},
}, instanceWrapperId);
});
}
createRequestScopedHandler(instanceWrapper, moduleRef, moduleKey, methodName) {
const { instance } = instanceWrapper;
const collection = moduleRef.providers;
const isStatic = instanceWrapper.isDependencyTreeStatic();
const isTreeDurable = isStatic
? this.areGlobalScopedEnhancersDurable()
: instanceWrapper.isDependencyTreeDurable();
const scopedEnhancers = [
...(isStatic ? instanceWrapper.getEnhancersMetadata() || [] : []),
...this.getGlobalScopedEnhancers(),
];
const pattern = Reflect.getMetadata(MESSAGE_METADATA, instance[methodName]) ?? 'unknown';
return async (...args) => {
const [client] = args;
try {
const contextId = this.getContextId(client, isTreeDurable);
const contextInstance = isStatic
? instance
: await this.injector.loadPerContext(instance, moduleRef, collection, contextId);
// Resolve global enhancers without adding them to the gateway's dependency
// tree, so a singleton gateway keeps its state and lifecycle hooks.
await this.injector.loadEnhancersPerContext(instanceWrapper, contextId, instanceWrapper, scopedEnhancers);
return this.contextCreator.create(contextInstance, contextInstance[methodName], moduleKey, methodName, contextId, instanceWrapper.id)(...args);
}
catch (err) {
let exceptionFilter = this.exceptionFiltersCache.get(instance[methodName]);
if (!exceptionFilter) {
exceptionFilter = this.exceptionFiltersContext.create(instance, instance[methodName], moduleKey);
this.exceptionFiltersCache.set(instance[methodName], exceptionFilter);
}
// The context failed to resolve before `WsProxy` could append the
// pattern, so add it here the same way.
const host = new ExecutionContextHost([...args, pattern]);
host.setType('ws');
exceptionFilter.handle(err, host);
}
};
}
createRequestScopedEventHandler(instanceWrapper, moduleRef, moduleKey, methodName, server) {
const { instance } = instanceWrapper;
const collection = moduleRef.providers;
const isStatic = instanceWrapper.isDependencyTreeStatic();
const isTreeDurable = isStatic
? this.areGlobalScopedEnhancersDurable()
: instanceWrapper.isDependencyTreeDurable();
const targetCallback = instance[methodName];
return async (...args) => {
const [client] = args;
let contextId;
try {
contextId = this.getContextId(client, isTreeDurable);
if (methodName === 'handleDisconnect') {
this.pendingDisconnects.set(contextId, (this.pendingDisconnects.get(contextId) ?? 0) + 1);
}
const contextInstance = isStatic
? instance
: await this.injector.loadPerContext(instance, moduleRef, collection, contextId);
if (!isStatic) {
this.assignServerToProperties(contextInstance, server);
}
const scopedMethod = contextInstance[methodName];
return await scopedMethod?.apply(contextInstance, args);
}
catch (err) {
if (!targetCallback) {
// The gateway failed to resolve and has no hook whose exception
// filters could handle the error. Nothing awaits this handler, so
// rethrowing would end as an unhandled rejection.
this.logger.error(`Failed to resolve ${instance.constructor.name} on client ${methodName === 'handleConnection' ? 'connection' : 'disconnection'}`, err?.stack);
return;
}
this.handleLifecycleHookError(instance, moduleKey, methodName, args, err);
}
finally {
if (methodName === 'handleDisconnect' && contextId) {
this.cleanupRequestScopedContext(instanceWrapper, contextId, client);
}
}
};
}
// A default-scoped gateway is resolved per connection only for its global
// scoped enhancers, so their trees decide whether the context is durable
// (mirrors InstanceWrapper#isDependencyTreeDurable).
areGlobalScopedEnhancersDurable() {
const scopedEnhancers = this.getGlobalScopedEnhancers().filter(wrapper => !wrapper.isDependencyTreeStatic());
return (scopedEnhancers.length > 0 &&
scopedEnhancers.every(wrapper => wrapper.isDependencyTreeDurable()));
}
/**
* Runs a lifecycle hook of a default-scoped gateway and passes the errors it
* throws or rejects with to the gateway's exception filters, as for message
* handlers. The hook still runs synchronously.
*/
createStaticEventHandler(instance, moduleKey, methodName) {
const hook = instance[methodName];
if (!hook) {
return undefined;
}
return (...args) => {
const handleError = (err) => this.handleLifecycleHookError(instance, moduleKey, methodName, args, err);
try {
const result = hook.apply(instance, args);
return isFunction(result?.then)
? result.catch(handleError)
: result;
}
catch (err) {
handleError(err);
}
};
}
handleLifecycleHookError(instance, moduleKey, methodName, args, err) {
// Only called for gateways that implement the hook
const targetCallback = instance[methodName];
let exceptionFilter = this.exceptionFiltersCache.get(targetCallback);
if (!exceptionFilter) {
exceptionFilter = this.exceptionFiltersContext.create(instance, targetCallback, moduleKey);
this.exceptionFiltersCache.set(targetCallback, exceptionFilter);
}
const [client, disconnectReason] = args;
// Client first, data second and the pattern last, as for message handlers.
// With the raw hook arguments, the filter would send the last one (the
// socket itself with socket.io, the upgrade request with ws) back to the
// client as the pattern. The disconnect reason is safe to pass as data,
// as nothing is sent to a client that has already disconnected.
const data = methodName === 'handleDisconnect' ? disconnectReason : undefined;
const host = new ExecutionContextHost([client, data, methodName]);
host.setType('ws');
exceptionFilter.handle(err, host);
}
getGlobalScopedEnhancers() {
return [
...this.config.getGlobalRequestGuards(),
...this.config.getGlobalRequestPipes(),
...this.config.getGlobalRequestInterceptors(),
];
}
getContextId(request, isTreeDurable) {
const contextId = ContextIdFactory.getByRequest(request);
if (!request[REQUEST_CONTEXT_ID]) {
Object.defineProperty(request, REQUEST_CONTEXT_ID, {
value: contextId,
enumerable: false,
writable: false,
configurable: true,
});
const requestProviderValue = isTreeDurable
? contextId.payload
: Object.assign(request, contextId.payload);
this.container.registerRequestProvider(requestProviderValue, contextId);
}
return contextId;
}
cleanupRequestScopedContext(_instanceWrapper, contextId, request) {
const remaining = (this.pendingDisconnects.get(contextId) ?? 1) - 1;
if (remaining > 0) {
this.pendingDisconnects.set(contextId, remaining);
return;
}
this.pendingDisconnects.delete(contextId);
// Other gateways can still be executing their asynchronous disconnect hooks.
// A finished cycle must also never remove a newer context on the same client.
if (request[REQUEST_CONTEXT_ID] === contextId) {
Reflect.deleteProperty(request, REQUEST_CONTEXT_ID);
}
}
assignServerToProperties(instance, server) {
for (const propertyKey of this.metadataExplorer.scanForServerHooks(instance)) {
Reflect.set(instance, propertyKey, server);
}
}
printSubscriptionLogs(instance, subscribersMap) {
const gatewayClassName = instance?.constructor?.name;
if (!gatewayClassName) {
return;
}
subscribersMap.forEach(({ message }) => this.logger.log(`${gatewayClassName} subscribed to the "${message}" message`));
}
}