From 59487410b1af5dc63904f5e1ad4648a2d3c16f38 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 18 Nov 2020 23:31:43 +0100 Subject: [PATCH] feat: Add WebSocket functionality to server. --- .eslintrc.js | 2 - package-lock.json | 55 +++++++++++-------- package.json | 2 + src/server/WebSocketHandler.ts | 9 +++ src/server/WebSocketServerFactory.ts | 37 +++++++++++++ test/.eslintrc.js | 5 ++ .../server/WebSocketServerFactory.test.ts | 54 ++++++++++++++++++ 7 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 src/server/WebSocketHandler.ts create mode 100644 src/server/WebSocketServerFactory.ts create mode 100644 test/unit/server/WebSocketServerFactory.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index bc154780b..48581dfb1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -40,8 +40,6 @@ module.exports = { 'dot-location': ['error', 'property'], 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], 'max-len': ['error', { code: 120, ignoreUrls: true }], - 'mocha/no-exports': 'off', // we are not using Mocha - 'mocha/no-skipped-tests': 'off', // we are not using Mocha 'new-cap': 'off', // used for RDF constants 'no-param-reassign': 'off', // necessary in constructor overloading 'no-underscore-dangle': 'off', // conflicts with external libraries diff --git a/package-lock.json b/package-lock.json index 428b1f777..929ed323b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1396,6 +1396,14 @@ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz", "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==" }, + "@types/ws": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.0.tgz", + "integrity": "sha512-Y29uQ3Uy+58bZrFLhX36hcI3Np37nqWE7ky5tjiDoy1GDZnIwVxS0CgF+s+1bXMzjKBFy+fqaRfb708iNzdinw==", + "requires": { + "@types/node": "*" + } + }, "@types/yargs": { "version": "15.0.9", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.9.tgz", @@ -1492,6 +1500,18 @@ "requires": { "@typescript-eslint/types": "4.6.0", "@typescript-eslint/visitor-keys": "4.6.0" + }, + "dependencies": { + "@typescript-eslint/visitor-keys": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.6.0.tgz", + "integrity": "sha512-38Aa9Ztl0XyFPVzmutHXqDMCu15Xx8yKvUo38Gu3GhsuckCh3StPI5t2WIO9LHEsOH7MLmlGfKUisU8eW1Sjhg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.6.0", + "eslint-visitor-keys": "^2.0.0" + } + } } }, "@typescript-eslint/types": { @@ -1516,6 +1536,16 @@ "tsutils": "^3.17.1" }, "dependencies": { + "@typescript-eslint/visitor-keys": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.6.0.tgz", + "integrity": "sha512-38Aa9Ztl0XyFPVzmutHXqDMCu15Xx8yKvUo38Gu3GhsuckCh3StPI5t2WIO9LHEsOH7MLmlGfKUisU8eW1Sjhg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.6.0", + "eslint-visitor-keys": "^2.0.0" + } + }, "debug": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", @@ -1533,24 +1563,6 @@ } } }, - "@typescript-eslint/visitor-keys": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.0.1.tgz", - "integrity": "sha512-yBSqd6FjnTzbg5RUy9J+9kJEyQjTI34JdGMJz+9ttlJzLCnGkBikxw+N5n2VDcc3CesbIEJ0MnZc5uRYnrEnCw==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.0.1", - "eslint-visitor-keys": "^2.0.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", - "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", - "dev": true - } - } - }, "JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", @@ -9644,10 +9656,9 @@ } }, "ws": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz", - "integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==", - "dev": true + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.0.tgz", + "integrity": "sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==" }, "xdg-basedir": { "version": "4.0.0", diff --git a/package.json b/package.json index f3b2ee77e..f233d9fbc 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@types/sparqljs": "^3.1.0", "@types/streamify-array": "^1.0.0", "@types/uuid": "^8.3.0", + "@types/ws": "^7.4.0", "@types/yargs": "^15.0.5", "arrayify-stream": "^1.0.0", "async-lock": "^1.2.4", @@ -101,6 +102,7 @@ "uuid": "^8.3.0", "winston": "^3.3.3", "winston-transport": "^4.4.0", + "ws": "^7.4.0", "yargs": "^16.0.0" }, "devDependencies": { diff --git a/src/server/WebSocketHandler.ts b/src/server/WebSocketHandler.ts new file mode 100644 index 000000000..32ab66cc8 --- /dev/null +++ b/src/server/WebSocketHandler.ts @@ -0,0 +1,9 @@ +import type WebSocket from 'ws'; +import { AsyncHandler } from '../util/AsyncHandler'; +import type { HttpRequest } from './HttpRequest'; + +/** + * A WebSocketHandler handles the communication with multiple WebSockets + */ +export abstract class WebSocketHandler + extends AsyncHandler<{ webSocket: WebSocket; upgradeRequest: HttpRequest }> {} diff --git a/src/server/WebSocketServerFactory.ts b/src/server/WebSocketServerFactory.ts new file mode 100644 index 000000000..a154ff4cc --- /dev/null +++ b/src/server/WebSocketServerFactory.ts @@ -0,0 +1,37 @@ +import type { Server } from 'http'; +import type { Socket } from 'net'; +import type WebSocket from 'ws'; +import { Server as WebSocketServer } from 'ws'; +import type { HttpRequest } from './HttpRequest'; +import type { HttpServerFactory } from './HttpServerFactory'; +import type { WebSocketHandler } from './WebSocketHandler'; + +/** + * Factory that adds WebSocket functionality to an existing server + */ +export class WebSocketServerFactory implements HttpServerFactory { + private readonly baseServerFactory: HttpServerFactory; + private readonly webSocketHandler: WebSocketHandler; + + public constructor(baseServerFactory: HttpServerFactory, webSocketHandler: WebSocketHandler) { + this.baseServerFactory = baseServerFactory; + this.webSocketHandler = webSocketHandler; + } + + public startServer(port: number): Server { + // Create WebSocket server + const webSocketServer = new WebSocketServer({ noServer: true }); + webSocketServer.on('connection', async(webSocket: WebSocket, upgradeRequest: HttpRequest): Promise => { + await this.webSocketHandler.handleSafe({ webSocket, upgradeRequest }); + }); + + // Create base HTTP server + const httpServer = this.baseServerFactory.startServer(port); + httpServer.on('upgrade', (upgradeRequest: HttpRequest, socket: Socket, head: Buffer): void => { + webSocketServer.handleUpgrade(upgradeRequest, socket, head, (webSocket: WebSocket): void => { + webSocketServer.emit('connection', webSocket, upgradeRequest); + }); + }); + return httpServer; + } +} diff --git a/test/.eslintrc.js b/test/.eslintrc.js index 9886c3797..c81eb2d3b 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -11,6 +11,11 @@ module.exports = { 'unicorn/no-useless-undefined': 'off', 'no-process-env': 'off', + // We are not using Mocha + 'mocha/no-exports': 'off', + 'mocha/no-skipped-tests': 'off', + 'mocha/no-synchronous-tests': 'off', + // Need these 2 to run tests for throwing non-Error objects '@typescript-eslint/no-throw-literal': 'off', 'no-throw-literal': 'off', diff --git a/test/unit/server/WebSocketServerFactory.test.ts b/test/unit/server/WebSocketServerFactory.test.ts new file mode 100644 index 000000000..bbb2c3a88 --- /dev/null +++ b/test/unit/server/WebSocketServerFactory.test.ts @@ -0,0 +1,54 @@ +import type { Server } from 'http'; +import request from 'supertest'; +import WebSocket from 'ws'; +import { ExpressHttpServerFactory } from '../../../src/server/ExpressHttpServerFactory'; +import { HttpHandler } from '../../../src/server/HttpHandler'; +import type { HttpRequest } from '../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../src/server/HttpResponse'; +import { WebSocketHandler } from '../../../src/server/WebSocketHandler'; +import { WebSocketServerFactory } from '../../../src/server/WebSocketServerFactory'; + +class SimpleHttpHandler extends HttpHandler { + public async handle(input: { request: HttpRequest; response: HttpResponse }): Promise { + input.response.end('SimpleHttpHandler'); + } +} + +class SimpleWebSocketHandler extends WebSocketHandler { + public host: any; + + public async handle(input: { webSocket: WebSocket; upgradeRequest: HttpRequest }): Promise { + input.webSocket.send('SimpleWebSocketHandler'); + input.webSocket.close(); + this.host = input.upgradeRequest.headers.host; + } +} + +describe('SimpleWebSocketHandler', (): void => { + let webSocketHandler: SimpleWebSocketHandler; + let server: Server; + + beforeAll(async(): Promise => { + const httpHandler = new SimpleHttpHandler(); + webSocketHandler = new SimpleWebSocketHandler(); + const httpServerFactory = new ExpressHttpServerFactory(httpHandler); + const webSocketServerFactory = new WebSocketServerFactory(httpServerFactory, webSocketHandler); + server = webSocketServerFactory.startServer(5555); + }); + + afterAll(async(): Promise => { + server.close(); + }); + + it('has a functioning HTTP interface.', async(): Promise => { + const result = await request(server).get('/').expect('SimpleHttpHandler'); + expect(result).toBeDefined(); + }); + + it('has a functioning WebSockets interface.', async(): Promise => { + const client = new WebSocket('ws://localhost:5555'); + const text = await new Promise((resolve): any => client.on('message', resolve)); + expect(text).toBe('SimpleWebSocketHandler'); + expect(webSocketHandler.host).toBe('localhost:5555'); + }); +});