diff --git a/package-lock.json b/package-lock.json index 21ecd8c..b62c1de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "loopback-example-websocket-app", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/public/index.html b/public/index.html index 6b6992e..3cce29d 100644 --- a/public/index.html +++ b/public/index.html @@ -1,73 +1,39 @@ - - - - - @loopback/example-todo - - - - - - - - - - -
-

@loopback/example-todo

- -

OpenAPI spec: /openapi.json

-

API Explorer: /explorer

-

Chats: /chats.html

-
- - - - + + + + Socket.IO chat + + + + +
+ +
+ + + + diff --git a/src/application.ts b/src/application.ts index c0e9ce3..9a18860 100644 --- a/src/application.ts +++ b/src/application.ts @@ -1,76 +1,54 @@ -// Copyright IBM Corp. 2018,2020. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT +import {Application, ApplicationConfig} from '@loopback/core'; +import {HttpServer} from '@loopback/http-server'; +import * as express from 'express'; +import * as path from 'path'; +import {WebSocketController} from './controllers'; +import {WebSocketServer} from './websocket.server'; -import { BootMixin } from '@loopback/boot'; -import { ApplicationConfig } from '@loopback/core'; -import { RepositoryMixin } from '@loopback/repository'; -import { Request, Response } from '@loopback/rest'; -import { RestExplorerComponent } from '@loopback/rest-explorer'; -import { ServiceMixin } from '@loopback/service-proxy'; -import morgan from 'morgan'; -import path from 'path'; -import { MySequence } from './sequence'; -import { WebsocketApplication } from "./websockets/websocket.application"; -import { WebsocketControllerBooter } from "./websockets/websocket.booter"; +// tslint:disable:no-any -export { ApplicationConfig }; +export class WebSocketDemoApplication extends Application { + readonly httpServer: HttpServer; + readonly wsServer: WebSocketServer; -export class TodoListApplication extends BootMixin( - ServiceMixin(RepositoryMixin(WebsocketApplication)), -) { constructor(options: ApplicationConfig = {}) { super(options); - // Set up the custom sequence - this.sequence(MySequence); - - // Set up default home page - this.static('/', path.join(__dirname, '../public')); - - this.component(RestExplorerComponent); - - this.booters(WebsocketControllerBooter); - - this.projectRoot = __dirname; - // Customize @loopback/boot Booter Conventions here - this.bootOptions = { - controllers: { - // Customize ControllerBooter Conventions here - dirs: ['controllers'], - extensions: ['.controller.js'], - nested: true, - }, - websocketControllers: { - dirs: ['controllers'], - extensions: ['.controller.ws.js'], - nested: true, - }, - }; - - this.setupLogging(); + /** + * Create an Express app to serve the home page + */ + const expressApp = express(); + const root = path.resolve(__dirname, '../../public'); + expressApp.use('/', express.static(root)); + + // Create an http server backed by the Express app + this.httpServer = new HttpServer(expressApp, options.websocket); + + // Create ws server from the http server + const wsServer = new WebSocketServer(this.httpServer); + this.bind('servers.websocket.server1').to(wsServer); + wsServer.use((socket, next) => { + console.log('Global middleware - socket:', socket.id); + next(); + }); + // Add a route + const ns = wsServer.route(WebSocketController, /^\/chats\/\d+$/); + ns.use((socket, next) => { + console.log( + 'Middleware for namespace %s - socket: %s', + socket.nsp.name, + socket.id, + ); + next(); + }); + this.wsServer = wsServer; } - private setupLogging() { - // Register `morgan` express middleware - // Create a middleware factory wrapper for `morgan(format, options)` - const morganFactory = (config?: morgan.Options) => { - this.debug('Morgan configuration', config); - return morgan('combined', config); - }; + start() { + return this.wsServer.start(); + } - // Print out logs using `debug` - const defaultConfig: morgan.Options = { - stream: { - write: str => { - this._debug(str); - }, - }, - }; - this.expressMiddleware(morganFactory, defaultConfig, { - injectConfiguration: 'watch', - key: 'middleware.morgan', - }); + stop() { + return this.wsServer.stop(); } } diff --git a/src/controllers/index.ts b/src/controllers/index.ts index d88b8dc..63693a2 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,7 +1 @@ -// Copyright IBM Corp. 2018,2020. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -export * from './todo.controller'; -export * from './chat.controller.ws'; +export * from './websocket.controller'; diff --git a/src/controllers/websocket.controller.ts b/src/controllers/websocket.controller.ts new file mode 100644 index 0000000..faa9c4a --- /dev/null +++ b/src/controllers/websocket.controller.ts @@ -0,0 +1,52 @@ +import {Socket} from 'socket.io'; +import {ws} from '../decorators/websocket.decorator'; + +/** + * A demo controller for websocket + */ +@ws('/chats') +export class WebSocketController { + constructor( + @ws.socket() // Equivalent to `@inject('ws.socket')` + private socket: Socket, + ) {} + + /** + * The method is invoked when a client connects to the server + * @param socket + */ + @ws.connect() + connect(socket: Socket) { + console.log('Client connected: %s', this.socket.id); + socket.join('room 1'); + } + + /** + * Register a handler for 'chat message' events + * @param msg + */ + @ws.subscribe('chat message') + // @ws.emit('namespace' | 'requestor' | 'broadcast') + handleChatMessage(msg: unknown) { + console.log('Chat message: %s', msg); + this.socket.nsp.emit('chat message', `[${this.socket.id}] ${msg}`); + } + + /** + * Register a handler for all events + * @param msg + */ + @ws.subscribe(/.+/) + logMessage(...args: unknown[]) { + console.log('Message: %s', args); + } + + /** + * The method is invoked when a client disconnects from the server + * @param socket + */ + @ws.disconnect() + disconnect() { + console.log('Client disconnected: %s', this.socket.id); + } +} diff --git a/src/decorators/websocket.decorator.ts b/src/decorators/websocket.decorator.ts new file mode 100644 index 0000000..03e4b30 --- /dev/null +++ b/src/decorators/websocket.decorator.ts @@ -0,0 +1,77 @@ +import { + ClassDecoratorFactory, + Constructor, + MetadataAccessor, + MetadataInspector, + MethodDecoratorFactory, + inject, +} from '@loopback/context'; + +export interface WebSocketMetadata { + namespace?: string | RegExp; +} + +export const WEBSOCKET_METADATA = MetadataAccessor.create< + WebSocketMetadata, + ClassDecorator +>('websocket'); + +/** + * Decorate a websocket controller class to specify the namespace + * For example, + * ```ts + * @ws({namespace: '/chats'}) + * export class WebSocketController {} + * ``` + * @param spec A namespace or object + */ +export function ws(spec: WebSocketMetadata | string | RegExp = {}) { + if (typeof spec === 'string' || spec instanceof RegExp) { + spec = {namespace: spec}; + } + return ClassDecoratorFactory.createDecorator(WEBSOCKET_METADATA, spec); +} + +export function getWebSocketMetadata(controllerClass: Constructor) { + return MetadataInspector.getClassMetadata( + WEBSOCKET_METADATA, + controllerClass, + ); +} + +export namespace ws { + export function socket() { + return inject('ws.socket'); + } + + /** + * Decorate a method to subscribe to websocket events. + * For example, + * ```ts + * @ws.subscribe('chat message') + * async function onChat(msg: string) { + * } + * ``` + * @param messageTypes + */ + export function subscribe(...messageTypes: (string | RegExp)[]) { + return MethodDecoratorFactory.createDecorator( + 'websocket:subscribe', + messageTypes, + ); + } + + /** + * Decorate a controller method for `disconnect` + */ + export function disconnect() { + return subscribe('disconnect'); + } + + /** + * Decorate a controller method for `connect` + */ + export function connect() { + return MethodDecoratorFactory.createDecorator('websocket:connect', true); + } +} diff --git a/src/index.ts b/src/index.ts index afaf6cd..4499a66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,44 +1,16 @@ -// Copyright IBM Corp. 2018,2020. All Rights Reserved. -// Node module: @loopback/example-todo -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT +import {WebSocketDemoApplication} from './application'; +import {ApplicationConfig} from '@loopback/core'; -import { ApplicationConfig, TodoListApplication } from './application'; +export {WebSocketDemoApplication}; +export * from './websocket.server'; +export * from './decorators/websocket.decorator'; +export * from './websocket-controller-factory'; export async function main(options: ApplicationConfig = {}) { - const app = new TodoListApplication(options); - await app.boot(); + const app = new WebSocketDemoApplication(options); await app.start(); - const url = app.restServer.url; - console.log(`Server is running at ${url}`); - return app; -} + console.log('listening on %s', app.httpServer.url); -if (require.main === module) { - const port = process.env.PORT ?? 3000; - // Run the application - const config = { - rest: { - port, - host: process.env.HOST ?? 'localhost', - openApiSpec: { - // useful when used with OpenAPI-to-GraphQL to locate your application - setServersFromRequest: true, - }, - }, - websocket: { - port - } - }; - main(config).catch(err => { - console.error('Cannot start the application.', err); - process.exit(1); - }); + return app; } - -// re-exports for our benchmark, not needed for the tutorial itself -export * from '@loopback/rest'; -export * from './application'; -export * from './models'; -export * from './repositories'; diff --git a/src/websocket-controller-factory.ts b/src/websocket-controller-factory.ts new file mode 100644 index 0000000..4809050 --- /dev/null +++ b/src/websocket-controller-factory.ts @@ -0,0 +1,108 @@ +import { + BindingScope, + Constructor, + Context, + invokeMethod, + MetadataInspector, +} from '@loopback/context'; +import {Socket} from 'socket.io'; + +/* eslint-disable @typescript-eslint/no-misused-promises */ +export class WebSocketControllerFactory { + private controller: {[method: string]: Function}; + + constructor( + private ctx: Context, + private controllerClass: Constructor<{[method: string]: Function}>, + ) { + this.ctx + .bind('ws.controller') + .toClass(this.controllerClass) + .tag('websocket') + .inScope(BindingScope.CONTEXT); + } + + async create(socket: Socket) { + // Instantiate the controller instance + this.controller = await this.ctx.get<{[method: string]: Function}>( + 'ws.controller', + ); + await this.setup(socket); + return this.controller; + } + + async connect(socket: Socket) { + const connectMethods = + MetadataInspector.getAllMethodMetadata( + 'websocket:connect', + this.controllerClass.prototype, + ) || {}; + for (const m in connectMethods) { + await invokeMethod(this.controller, m, this.ctx, [socket]); + } + } + + registerSubscribeMethods(socket: Socket) { + const regexpEventHandlers = new Map< + RegExp[], + (...args: unknown[]) => Promise + >(); + const subscribeMethods = + MetadataInspector.getAllMethodMetadata<(string | RegExp)[]>( + 'websocket:subscribe', + this.controllerClass.prototype, + ) || {}; + for (const m in subscribeMethods) { + for (const t of subscribeMethods[m]) { + const regexps: RegExp[] = []; + if (typeof t === 'string') { + socket.on(t, async (...args: unknown[]) => { + await invokeMethod(this.controller, m, this.ctx, args); + }); + } else if (t instanceof RegExp) { + regexps.push(t); + } + if (regexps.length) { + // Build a map of regexp based message handlers + regexpEventHandlers.set(regexps, async (...args: unknown[]) => { + await invokeMethod(this.controller, m, this.ctx, args); + }); + } + } + } + return regexpEventHandlers; + } + + /** + * Set up the controller for the given socket + * @param socket + */ + async setup(socket: Socket) { + // Invoke connect handlers + await this.connect(socket); + + // Register event handlers + const regexpHandlers = this.registerSubscribeMethods(socket); + + // Register event handlers with regexp + if (regexpHandlers.size) { + // Use a socket middleware to match event names with regexp + socket.use(async (packet, next) => { + const eventName = packet[0]; + for (const e of regexpHandlers.entries()) { + if (e[0].some(re => !!eventName.match(re))) { + const handler = e[1]; + const args = [packet[1]]; + if (packet[2]) { + // TODO: Should we auto-ack? + // Ack callback + args.push(packet[2]); + } + await handler(args); + } + } + next(); + }); + } + } +} diff --git a/src/websocket.server.ts b/src/websocket.server.ts new file mode 100644 index 0000000..15c2fc5 --- /dev/null +++ b/src/websocket.server.ts @@ -0,0 +1,91 @@ +import {Constructor, Context} from '@loopback/context'; +import {HttpServer} from '@loopback/http-server'; +import {Server, ServerOptions, Socket} from 'socket.io'; +import {getWebSocketMetadata} from './decorators/websocket.decorator'; +import {WebSocketControllerFactory} from './websocket-controller-factory'; +import SocketIOServer = require('socket.io'); + +const debug = require('debug')('loopback:websocket'); + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type SockIOMiddleware = ( + socket: Socket, + fn: (err?: any) => void, +) => void; + +/** + * A websocket server + */ +export class WebSocketServer extends Context { + private io: Server; + + constructor( + public readonly httpServer: HttpServer, + private options: ServerOptions = {}, + ) { + super(); + this.io = SocketIOServer(options); + } + + /** + * Register a sock.io middleware function + * @param fn + */ + use(fn: SockIOMiddleware) { + return this.io.use(fn); + } + + /** + * Register a websocket controller + * @param ControllerClass + * @param namespace + */ + route(ControllerClass: Constructor, namespace?: string | RegExp) { + if (namespace == null) { + const meta = getWebSocketMetadata(ControllerClass); + namespace = meta && meta.namespace; + } + + const nsp = namespace ? this.io.of(namespace) : this.io; + /* eslint-disable @typescript-eslint/no-misused-promises */ + nsp.on('connection', async socket => { + debug( + 'Websocket connected: id=%s namespace=%s', + socket.id, + socket.nsp.name, + ); + // Create a request context + const reqCtx = new Context(this); + // Bind websocket + reqCtx.bind('ws.socket').to(socket); + // Instantiate the controller instance + await new WebSocketControllerFactory(reqCtx, ControllerClass).create( + socket, + ); + }); + return nsp; + } + + /** + * Start the websocket server + */ + async start() { + await this.httpServer.start(); + // FIXME: Access HttpServer.server + const server = (this.httpServer as any).server; + this.io.attach(server, this.options); + } + + /** + * Stop the websocket server + */ + async stop() { + const close = new Promise((resolve, reject) => { + this.io.close(() => { + resolve(); + }); + }); + await close; + await this.httpServer.stop(); + } +}