UNPKG

@loopeco/socketio

Version:

A enhanced LoopBack's WebSocket server based on socket.io

275 lines (243 loc) 8.19 kB
import { Application, Binding, BindingFilter, BindingScope, config, Constructor, ContextView, CoreBindings, CoreTags, createBindingFromClass, inject, MetadataInspector, } from '@loopback/core'; import {HttpServer, HttpServerOptions} from '@loopback/http-server'; import cors from 'cors'; import debugFactory from 'debug'; import {cloneDeep} from 'lodash'; import {Namespace, Server, ServerOptions, Socket} from 'socket.io'; import {BaseMiddlewareRegistry} from '@loopback/express'; import { getSocketIoMetadata, SOCKET_IO_CONNECT_METADATA, SOCKET_IO_METADATA, SOCKET_IO_SUBSCRIBE_METADATA, SocketIoMetadata, } from './decorators'; import {SocketIoBindings, SocketIoTags} from './keys'; import {SocketIoControllerFactory} from './socketio-controller-factory'; import {toSocketIoMiddleware} from './middleware'; import {getConnectionContext, SocketIoConnectionContext} from './types'; const debug = debugFactory('loopeco:socketio:server'); export type SyncGetter<T> = () => T; export type SockIoNativeMiddleware = ( socket: Socket, // eslint-disable-next-line @typescript-eslint/no-explicit-any fn: (err?: any) => void, ) => void; export const getNamespaceKeyForName = (name: string) => `socketio.namespace.${name}`; /** * A binding filter to match socket.io controllers * @param binding - Binding object */ export const socketIoControllers: BindingFilter = binding => { // It has to be tagged with `controller` if (!binding.tagNames.includes(CoreTags.CONTROLLER)) return false; // It can be explicitly tagged with `socket.io` if (binding.tagNames.includes(SocketIoTags.SOCKETIO)) return true; // Now inspect socket.io decorations if (binding.valueConstructor) { const cls = binding.valueConstructor; const classMeta = MetadataInspector.getClassMetadata(SOCKET_IO_METADATA, cls); if (classMeta != null) { debug('SocketIo metadata found at class %s', cls.name); return true; } const subscribeMeta = MetadataInspector.getAllMethodMetadata(SOCKET_IO_SUBSCRIBE_METADATA, cls.prototype); if (subscribeMeta != null) { debug('SocketIo subscribe metadata found at methods of %s', cls.name); return true; } const connectMeta = MetadataInspector.getAllMethodMetadata(SOCKET_IO_CONNECT_METADATA, cls.prototype); if (connectMeta != null) { debug('SocketIo connect metadata found at methods of %s', cls.name); return true; } } return false; }; // Server config expected from application export interface SocketIoServerOptions { httpServerOptions?: HttpServerResolvedOptions; socketIoOptions?: ServerOptions; } /** * A socketio server */ export class SocketIoServer extends BaseMiddlewareRegistry { public readonly config: HttpServerResolvedOptions; private controllers: ContextView; private ownedHttpServer: HttpServer; private attachedHttpServer: SyncGetter<HttpServer | undefined>; private readonly io: Server; constructor( @inject(CoreBindings.APPLICATION_INSTANCE) public app: Application, @config({fromBinding: CoreBindings.APPLICATION_INSTANCE}) protected options: SocketIoServerOptions = {}, ) { super(app); if (!options.socketIoOptions) { this.io = new Server(); } else { this.io = new Server(options.socketIoOptions); } app.bind(SocketIoBindings.IO).to(this.io); this.controllers = this.createView(socketIoControllers); this.attachedHttpServer = () => undefined; } get httpServer() { return this.attachedHttpServer() ?? this.ownedHttpServer; } get listening(): boolean { return this.httpServer ? this.httpServer.listening : false; } get url() { return this.httpServer?.url; } /** * Register a sock.io middleware function * @param fn */ use(fn: SockIoNativeMiddleware): Server { return this.io.use(fn); } /** * Register a socketio controller * @param controllerClass * @param meta */ route(controllerClass: Constructor<object>, meta?: SocketIoMetadata | string | RegExp): Server | Namespace { if (meta instanceof RegExp || typeof meta === 'string') { meta = {namespace: meta} as SocketIoMetadata; } if (meta == null) { meta = getSocketIoMetadata(controllerClass) as SocketIoMetadata; } const nsp = meta?.namespace ? this.io.of(meta.namespace) : this.io; if (meta?.name) { this.app.bind(getNamespaceKeyForName(meta.name)).to(nsp); } nsp.use(toSocketIoMiddleware(this)); nsp.on('connection', socket => this.createSocketHandler(controllerClass)(socket)); return nsp; } /** * Register a socket.io controller * @param controllerClass */ controller(controllerClass: Constructor<unknown>): Binding<unknown> { debug('Adding controller %s', controllerClass.name); const binding = createBindingFromClass(controllerClass, { namespace: SocketIoBindings.CONTROLLERS_NAMESPACE, defaultScope: BindingScope.TRANSIENT, }).tag(SocketIoTags.SOCKETIO, CoreTags.CONTROLLER); this.add(binding); debug('Controller binding: %j', binding); return binding; } /** * Discover all socket.io controllers and register routes */ discoverAndRegister() { const bindings = this.controllers.bindings; for (const binding of bindings) { if (binding.valueConstructor) { debug('Controller binding found: %s %s', binding.key, binding.valueConstructor.name); this.route(binding.valueConstructor as Constructor<object>); } } } attach(httpServer: HttpServer | SyncGetter<HttpServer>) { this.attachedHttpServer = typeof httpServer === 'function' ? httpServer : () => httpServer; } /** * Start the socketio server */ async start() { let httpServer = this.attachedHttpServer(); if (!httpServer) { const requestListener = this.getSync(SocketIoBindings.REQUEST_LISTENER); const serverOptions = resolveHttpServerConfig(this.options.httpServerOptions); httpServer = this.ownedHttpServer = new HttpServer(requestListener, serverOptions); await httpServer.start(); } this.io.attach(httpServer.server, this.options.socketIoOptions); } /** * Stop the socketio server */ async stop() { const closePromise = new Promise<void>((resolve, _reject) => { this.io.close(() => { resolve(); }); }); await closePromise; if (this.ownedHttpServer) await this.ownedHttpServer.stop(); } /** * Retrieve the middleware context from the socket * @param socket - Socket object */ getConnectionContext(socket: Socket): SocketIoConnectionContext | undefined { return getConnectionContext(socket); } /** * Create socket handler from the controller class * @param controllerClass */ private createSocketHandler(controllerClass: Constructor<object>): (socket: Socket) => void { return async socket => { debug('SocketIo connected: id=%s namespace=%s', socket.id, socket.nsp.name); try { await new SocketIoControllerFactory(this, controllerClass, socket).create(); } catch (err) { debug('SocketIo error: error creating controller instance con connection', err); } }; } } /** * Valid configuration for the HttpServer constructor. */ export interface HttpServerResolvedOptions { host?: string; port: number; path?: string; basePath?: string; cors: cors.CorsOptions; } const DEFAULT_CONFIG: HttpServerResolvedOptions & HttpServerOptions = { port: 3000, cors: { origin: '*', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', preflightContinue: false, optionsSuccessStatus: 204, maxAge: 86400, credentials: true, }, }; function resolveHttpServerConfig(applicationConfig?: HttpServerResolvedOptions): HttpServerResolvedOptions { const result: HttpServerResolvedOptions = Object.assign(cloneDeep(DEFAULT_CONFIG), applicationConfig); // Can't check falsiness, 0 is a valid port. if (result.port == null) { result.port = 3000; } if (result.host == null) { // Set it to '' so that the http server will listen on all interfaces result.host = undefined; } return result; }