Browse Source

feat: Add websocket module (WIP)

Owen Diffey 1 year ago
parent
commit
e4f2bdc83c

+ 182 - 0
backend/package-lock.json

@@ -29,9 +29,11 @@
 				"@microsoft/tsdoc": "^0.14.2",
 				"@types/chai": "^4.3.5",
 				"@types/config": "^3.3.0",
+				"@types/express": "^4.17.17",
 				"@types/mocha": "^10.0.1",
 				"@types/sinon": "^10.0.14",
 				"@types/sinon-chai": "^3.2.9",
+				"@types/ws": "^8.5.4",
 				"@typescript-eslint/eslint-plugin": "^5.59.5",
 				"@typescript-eslint/parser": "^5.59.5",
 				"chai": "^4.3.7",
@@ -384,6 +386,16 @@
 			"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
 			"dev": true
 		},
+		"node_modules/@types/body-parser": {
+			"version": "1.19.2",
+			"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
+			"integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==",
+			"dev": true,
+			"dependencies": {
+				"@types/connect": "*",
+				"@types/node": "*"
+			}
+		},
 		"node_modules/@types/chai": {
 			"version": "4.3.5",
 			"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz",
@@ -396,6 +408,39 @@
 			"integrity": "sha512-9kZSbl3/X3TVNowLCu5HFQdQmD+4287Om55avknEYkuo6R2dDrsp/EXEHUFvfYeG7m1eJ0WYGj+cbcUIhARJAQ==",
 			"dev": true
 		},
+		"node_modules/@types/connect": {
+			"version": "3.4.35",
+			"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
+			"integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==",
+			"dev": true,
+			"dependencies": {
+				"@types/node": "*"
+			}
+		},
+		"node_modules/@types/express": {
+			"version": "4.17.17",
+			"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz",
+			"integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==",
+			"dev": true,
+			"dependencies": {
+				"@types/body-parser": "*",
+				"@types/express-serve-static-core": "^4.17.33",
+				"@types/qs": "*",
+				"@types/serve-static": "*"
+			}
+		},
+		"node_modules/@types/express-serve-static-core": {
+			"version": "4.17.35",
+			"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz",
+			"integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==",
+			"dev": true,
+			"dependencies": {
+				"@types/node": "*",
+				"@types/qs": "*",
+				"@types/range-parser": "*",
+				"@types/send": "*"
+			}
+		},
 		"node_modules/@types/json-schema": {
 			"version": "7.0.11",
 			"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
@@ -408,6 +453,12 @@
 			"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
 			"dev": true
 		},
+		"node_modules/@types/mime": {
+			"version": "1.3.2",
+			"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
+			"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
+			"dev": true
+		},
 		"node_modules/@types/mocha": {
 			"version": "10.0.1",
 			"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz",
@@ -419,12 +470,44 @@
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz",
 			"integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q=="
 		},
+		"node_modules/@types/qs": {
+			"version": "6.9.7",
+			"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
+			"integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==",
+			"dev": true
+		},
+		"node_modules/@types/range-parser": {
+			"version": "1.2.4",
+			"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz",
+			"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
+			"dev": true
+		},
 		"node_modules/@types/semver": {
 			"version": "7.5.0",
 			"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
 			"integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
 			"dev": true
 		},
+		"node_modules/@types/send": {
+			"version": "0.17.1",
+			"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz",
+			"integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==",
+			"dev": true,
+			"dependencies": {
+				"@types/mime": "^1",
+				"@types/node": "*"
+			}
+		},
+		"node_modules/@types/serve-static": {
+			"version": "1.15.1",
+			"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.1.tgz",
+			"integrity": "sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==",
+			"dev": true,
+			"dependencies": {
+				"@types/mime": "*",
+				"@types/node": "*"
+			}
+		},
 		"node_modules/@types/sinon": {
 			"version": "10.0.14",
 			"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.14.tgz",
@@ -476,6 +559,15 @@
 				"@types/webidl-conversions": "*"
 			}
 		},
+		"node_modules/@types/ws": {
+			"version": "8.5.4",
+			"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz",
+			"integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==",
+			"dev": true,
+			"dependencies": {
+				"@types/node": "*"
+			}
+		},
 		"node_modules/@typescript-eslint/eslint-plugin": {
 			"version": "5.59.5",
 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz",
@@ -5514,6 +5606,16 @@
 			"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
 			"dev": true
 		},
+		"@types/body-parser": {
+			"version": "1.19.2",
+			"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
+			"integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==",
+			"dev": true,
+			"requires": {
+				"@types/connect": "*",
+				"@types/node": "*"
+			}
+		},
 		"@types/chai": {
 			"version": "4.3.5",
 			"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz",
@@ -5526,6 +5628,39 @@
 			"integrity": "sha512-9kZSbl3/X3TVNowLCu5HFQdQmD+4287Om55avknEYkuo6R2dDrsp/EXEHUFvfYeG7m1eJ0WYGj+cbcUIhARJAQ==",
 			"dev": true
 		},
+		"@types/connect": {
+			"version": "3.4.35",
+			"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
+			"integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==",
+			"dev": true,
+			"requires": {
+				"@types/node": "*"
+			}
+		},
+		"@types/express": {
+			"version": "4.17.17",
+			"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz",
+			"integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==",
+			"dev": true,
+			"requires": {
+				"@types/body-parser": "*",
+				"@types/express-serve-static-core": "^4.17.33",
+				"@types/qs": "*",
+				"@types/serve-static": "*"
+			}
+		},
+		"@types/express-serve-static-core": {
+			"version": "4.17.35",
+			"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz",
+			"integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==",
+			"dev": true,
+			"requires": {
+				"@types/node": "*",
+				"@types/qs": "*",
+				"@types/range-parser": "*",
+				"@types/send": "*"
+			}
+		},
 		"@types/json-schema": {
 			"version": "7.0.11",
 			"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
@@ -5538,6 +5673,12 @@
 			"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
 			"dev": true
 		},
+		"@types/mime": {
+			"version": "1.3.2",
+			"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
+			"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
+			"dev": true
+		},
 		"@types/mocha": {
 			"version": "10.0.1",
 			"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz",
@@ -5549,12 +5690,44 @@
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz",
 			"integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q=="
 		},
+		"@types/qs": {
+			"version": "6.9.7",
+			"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
+			"integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==",
+			"dev": true
+		},
+		"@types/range-parser": {
+			"version": "1.2.4",
+			"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz",
+			"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
+			"dev": true
+		},
 		"@types/semver": {
 			"version": "7.5.0",
 			"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
 			"integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
 			"dev": true
 		},
+		"@types/send": {
+			"version": "0.17.1",
+			"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz",
+			"integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==",
+			"dev": true,
+			"requires": {
+				"@types/mime": "^1",
+				"@types/node": "*"
+			}
+		},
+		"@types/serve-static": {
+			"version": "1.15.1",
+			"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.1.tgz",
+			"integrity": "sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==",
+			"dev": true,
+			"requires": {
+				"@types/mime": "*",
+				"@types/node": "*"
+			}
+		},
 		"@types/sinon": {
 			"version": "10.0.14",
 			"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.14.tgz",
@@ -5606,6 +5779,15 @@
 				"@types/webidl-conversions": "*"
 			}
 		},
+		"@types/ws": {
+			"version": "8.5.4",
+			"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz",
+			"integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==",
+			"dev": true,
+			"requires": {
+				"@types/node": "*"
+			}
+		},
 		"@typescript-eslint/eslint-plugin": {
 			"version": "5.59.5",
 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz",

+ 2 - 0
backend/package.json

@@ -37,9 +37,11 @@
 		"@microsoft/tsdoc": "^0.14.2",
 		"@types/chai": "^4.3.5",
 		"@types/config": "^3.3.0",
+		"@types/express": "^4.17.17",
 		"@types/mocha": "^10.0.1",
 		"@types/sinon": "^10.0.14",
 		"@types/sinon-chai": "^3.2.9",
+		"@types/ws": "^8.5.4",
 		"@typescript-eslint/eslint-plugin": "^5.59.5",
 		"@typescript-eslint/parser": "^5.59.5",
 		"chai": "^4.3.7",

+ 1 - 1
backend/src/JobQueue.ts

@@ -98,7 +98,7 @@ export default class JobQueue {
 				payload,
 				{ resolve, reject },
 				options
-			);
+			).catch(reject);
 		});
 	}
 

+ 4 - 2
backend/src/ModuleManager.ts

@@ -38,7 +38,8 @@ export default class ModuleManager {
 		const mapper = {
 			data: "DataModule",
 			events: "EventsModule",
-			stations: "StationModule"
+			stations: "StationModule",
+			websocket: "WebSocketModule"
 		};
 		const { default: Module }: { default: ModuleClass<Modules[T]> } =
 			await import(`./modules/${mapper[moduleName]}`);
@@ -54,7 +55,8 @@ export default class ModuleManager {
 		this.modules = {
 			data: await this.loadModule("data"),
 			events: await this.loadModule("events"),
-			stations: await this.loadModule("stations")
+			stations: await this.loadModule("stations"),
+			websocket: await this.loadModule("websocket")
 		};
 	}
 

+ 7 - 0
backend/src/WebSocket.ts

@@ -0,0 +1,7 @@
+import { WebSocket as WSWebSocket } from "ws";
+
+export default class WebSocket extends WSWebSocket {
+	public dispatch(name: string, ...args: any[]) {
+		this.send(JSON.stringify([name, ...args]));
+	}
+}

+ 147 - 0
backend/src/modules/WebSocketModule.ts

@@ -0,0 +1,147 @@
+import config from "config";
+import express from "express";
+import http, { Server, IncomingMessage } from "node:http";
+import { RawData, WebSocketServer } from "ws";
+import BaseModule from "../BaseModule";
+import { UniqueMethods } from "../types/Modules";
+import JobQueue from "../JobQueue";
+import WebSocket from "../WebSocket";
+
+export default class WebSocketModule extends BaseModule {
+	private httpServer?: Server;
+
+	private wsServer?: WebSocketServer;
+
+	private jobQueue: JobQueue;
+
+	/**
+	 * WebSocket Module
+	 */
+	public constructor() {
+		super("websocket");
+
+		this.jobQueue = JobQueue.getPrimaryInstance();
+	}
+
+	/**
+	 * startup - Startup websocket module
+	 */
+	public override async startup() {
+		await super.startup();
+
+		this.httpServer = http
+			.createServer(express())
+			.listen(config.get("port"));
+
+		this.wsServer = new WebSocketServer({
+			server: this.httpServer,
+			path: "/ws",
+			WebSocket
+		});
+
+		this.wsServer.on(
+			"connection",
+			(socket: WebSocket, request: IncomingMessage) =>
+				this.handleConnection(socket, request)
+		);
+
+		await super.started();
+	}
+
+	/**
+	 * handleConnection - Handle websocket connection
+	 */
+	private async handleConnection(
+		socket: WebSocket,
+		request: IncomingMessage
+	) {
+		if (this.jobQueue.getStatus().isPaused) {
+			socket.close();
+			return;
+		}
+
+		const readyData = {
+			config: {
+				cookie: config.get("cookie"),
+				sitename: config.get("sitename"),
+				recaptcha: {
+					enabled: config.get("apis.recaptcha.enabled"),
+					key: config.get("apis.recaptcha.key")
+				},
+				githubAuthentication: config.get("apis.github.enabled"),
+				messages: config.get("messages"),
+				christmas: config.get("christmas"),
+				footerLinks: config.get("footerLinks"),
+				shortcutOverrides: config.get("shortcutOverrides"),
+				registrationDisabled: config.get("registrationDisabled"),
+				mailEnabled: config.get("mail.enabled"),
+				discogsEnabled: config.get("apis.discogs.enabled"),
+				experimental: {
+					changable_listen_mode: config.get(
+						"experimental.changable_listen_mode"
+					),
+					media_session: config.get("experimental.media_session"),
+					disable_youtube_search: config.get(
+						"experimental.disable_youtube_search"
+					),
+					station_history: config.get("experimental.station_history"),
+					soundcloud: config.get("experimental.soundcloud"),
+					spotify: config.get("experimental.spotify")
+				}
+			},
+			user: { loggedIn: false }
+		};
+
+		socket.dispatch("ready", readyData);
+
+		socket.on("message", message => this.handleMessage(socket, message));
+	}
+
+	/**
+	 * handleMessage - Handle websocket message
+	 */
+	private async handleMessage(socket: WebSocket, message: RawData) {
+		if (this.jobQueue.getStatus().isPaused) {
+			socket.close();
+			return;
+		}
+
+		try {
+			const data = JSON.parse(message.toString());
+
+			if (!Array.isArray(data) || data.length < 1)
+				throw new Error("Invalid request");
+
+			const [moduleJob, payload, options] = data;
+			const [moduleName, jobName] = moduleJob.split(".");
+			const { CB_REF } = options ?? payload ?? {};
+
+			await this.jobQueue
+				.runJob(moduleName, jobName, payload)
+				.then(res => socket.dispatch("CB_REF", CB_REF, res));
+		} catch (error) {
+			const message = error?.message ?? error;
+
+			this.log({ type: "error", message });
+
+			socket.dispatch("ERROR", error?.message ?? error);
+		}
+	}
+
+	/**
+	 * shutdown - Shutdown websocket module
+	 */
+	public override async shutdown() {
+		await super.shutdown();
+
+		if (this.httpServer) this.httpServer.close();
+		if (this.wsServer) this.wsServer.close();
+	}
+}
+
+export type WebSocketModuleJobs = {
+	[Property in keyof UniqueMethods<WebSocketModule>]: {
+		payload: Parameters<UniqueMethods<WebSocketModule>[Property]>[1];
+		returns: Awaited<ReturnType<UniqueMethods<WebSocketModule>[Property]>>;
+	};
+};

+ 7 - 0
backend/src/types/Modules.ts

@@ -1,6 +1,9 @@
 import DataModule, { DataModuleJobs } from "../modules/DataModule";
 import EventsModule, { EventsModuleJobs } from "../modules/EventsModule";
 import StationModule, { StationModuleJobs } from "../modules/StationModule";
+import WebSocketModule, {
+	WebSocketModuleJobs
+} from "../modules/WebSocketModule";
 import BaseModule from "../BaseModule";
 
 export type Module = BaseModule;
@@ -19,12 +22,16 @@ export type Jobs = {
 	stations: {
 		[Property in keyof StationModuleJobs]: StationModuleJobs[Property];
 	};
+	websocket: {
+		[Property in keyof WebSocketModuleJobs]: WebSocketModuleJobs[Property];
+	};
 };
 
 export type Modules = {
 	data: DataModule & typeof BaseModule;
 	events: EventsModule & typeof BaseModule;
 	stations: StationModule & typeof BaseModule;
+	websocket: WebSocketModule & typeof BaseModule;
 };
 
 export type Methods<T> = {