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
-
-
-
-
-
-
-
-
-
+
+
+
+ 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();
+ }
+}