@loopeco/socketio
Version:
A enhanced LoopBack's WebSocket server based on socket.io
275 lines (243 loc) • 8.19 kB
text/typescript
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(
(CoreBindings.APPLICATION_INSTANCE) public app: Application,
({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;
}