Spaces:
Paused
Paused
Merge pull request #26 from AnujPanthri/server-comments
Browse files- server/src/helpers.ts +6 -0
- server/src/index.ts +12 -0
- server/src/socket/hapticLinkServer.ts +38 -0
- server/src/socket/room.ts +30 -3
- server/src/socket/routes.ts +4 -0
server/src/helpers.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
import * as crypto from "crypto";
|
| 2 |
import basex from "base-x";
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
export function generateSessionToken(length: number): string {
|
| 5 |
if (length < 1) throw new Error("invalid length. length must be greater than 0");
|
| 6 |
const base31Tokens = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
|
|
| 1 |
import * as crypto from "crypto";
|
| 2 |
import basex from "base-x";
|
| 3 |
|
| 4 |
+
/**
|
| 5 |
+
* Generates a random, cryptographically secure token using uppercase, readable characters.
|
| 6 |
+
* Characters that are used: ABCDEFGHJKMNPQRSTUVWXYZ23456789
|
| 7 |
+
* @param {number} length - length of the token
|
| 8 |
+
* @returns {string}
|
| 9 |
+
*/
|
| 10 |
export function generateSessionToken(length: number): string {
|
| 11 |
if (length < 1) throw new Error("invalid length. length must be greater than 0");
|
| 12 |
const base31Tokens = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
server/src/index.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import express, { Application, Request, Response } from "express";
|
| 2 |
import * as http from "http";
|
| 3 |
import * as WebSocket from "ws";
|
|
@@ -7,6 +12,7 @@ import WebSocketWrapper from "./socket/WebSocketAdapter";
|
|
| 7 |
import pino from "pino";
|
| 8 |
import path from "path";
|
| 9 |
|
|
|
|
| 10 |
(() => {
|
| 11 |
const args = process.argv;
|
| 12 |
if (args.includes("--silent")) {
|
|
@@ -16,6 +22,10 @@ import path from "path";
|
|
| 16 |
return main(true);
|
| 17 |
})();
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
function main(logging: boolean = true) {
|
| 20 |
const port: number = parseInt(process.env.PORT as string, 10) || 3000;
|
| 21 |
const logger = pino({
|
|
@@ -35,8 +45,10 @@ function main(logging: boolean = true) {
|
|
| 35 |
const server = http.createServer(app);
|
| 36 |
const wss = new WebSocket.Server({ server });
|
| 37 |
|
|
|
|
| 38 |
app.use(express.static(path.join(__dirname, "../../client/build/web")));
|
| 39 |
|
|
|
|
| 40 |
app.get("*", (_req: Request, res: Response) => {
|
| 41 |
res.sendFile(path.join(__dirname + "/../../client/build/web/index.html"));
|
| 42 |
});
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
Created by Anne Lefebvre and Anuj Panthri - 2023
|
| 3 |
+
Handles the HTTP file serving and the WebSocket connect for HapticTouch
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
import express, { Application, Request, Response } from "express";
|
| 7 |
import * as http from "http";
|
| 8 |
import * as WebSocket from "ws";
|
|
|
|
| 12 |
import pino from "pino";
|
| 13 |
import path from "path";
|
| 14 |
|
| 15 |
+
// If started with --silent, then logging will be disabled.
|
| 16 |
(() => {
|
| 17 |
const args = process.argv;
|
| 18 |
if (args.includes("--silent")) {
|
|
|
|
| 22 |
return main(true);
|
| 23 |
})();
|
| 24 |
|
| 25 |
+
/**
|
| 26 |
+
* Starts HTTP(S) and WS server, initializes HapticLinkServer and handles routing.
|
| 27 |
+
* @param {boolean} logging - Defaults true
|
| 28 |
+
*/
|
| 29 |
function main(logging: boolean = true) {
|
| 30 |
const port: number = parseInt(process.env.PORT as string, 10) || 3000;
|
| 31 |
const logger = pino({
|
|
|
|
| 45 |
const server = http.createServer(app);
|
| 46 |
const wss = new WebSocket.Server({ server });
|
| 47 |
|
| 48 |
+
// The entire build/web directory is statically served.
|
| 49 |
app.use(express.static(path.join(__dirname, "../../client/build/web")));
|
| 50 |
|
| 51 |
+
// Catch all. If we want to add pages later, then we should probably change this.
|
| 52 |
app.get("*", (_req: Request, res: Response) => {
|
| 53 |
res.sendFile(path.join(__dirname + "/../../client/build/web/index.html"));
|
| 54 |
});
|
server/src/socket/hapticLinkServer.ts
CHANGED
|
@@ -5,9 +5,15 @@ import { WebSocketInterface } from "./WebSocketAdapter";
|
|
| 5 |
|
| 6 |
type RouteHandler<T> = (context: Context<T>) => void;
|
| 7 |
|
|
|
|
| 8 |
export interface Route<T> {
|
|
|
|
| 9 |
name: string;
|
|
|
|
|
|
|
| 10 |
schema: ZodSchema<T>;
|
|
|
|
|
|
|
| 11 |
handler: RouteHandler<T>;
|
| 12 |
}
|
| 13 |
|
|
@@ -25,6 +31,7 @@ export class User {
|
|
| 25 |
}
|
| 26 |
}
|
| 27 |
|
|
|
|
| 28 |
export interface Context<T> {
|
| 29 |
ws: WebSocketInterface;
|
| 30 |
user: User;
|
|
@@ -33,8 +40,13 @@ export interface Context<T> {
|
|
| 33 |
}
|
| 34 |
|
| 35 |
export class HapticLinkServer {
|
|
|
|
| 36 |
routes: { [key: string]: Route<any> };
|
|
|
|
|
|
|
| 37 |
rooms: { [key: string]: Room };
|
|
|
|
|
|
|
| 38 |
users: Map<WebSocketInterface, User>;
|
| 39 |
|
| 40 |
constructor() {
|
|
@@ -43,6 +55,11 @@ export class HapticLinkServer {
|
|
| 43 |
this.rooms = {};
|
| 44 |
}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
removeUser(ws: WebSocketInterface): boolean {
|
| 47 |
const user = this.users.get(ws);
|
| 48 |
if (!user) return false;
|
|
@@ -52,6 +69,13 @@ export class HapticLinkServer {
|
|
| 52 |
return true;
|
| 53 |
}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
addRoute<T>(name: string, schema: ZodSchema<T>, handler: RouteHandler<T>): boolean {
|
| 56 |
if (name in this.routes) {
|
| 57 |
return false;
|
|
@@ -66,6 +90,14 @@ export class HapticLinkServer {
|
|
| 66 |
return true;
|
| 67 |
}
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
handleRoute(ws: WebSocketInterface, message: string) {
|
| 70 |
// Parse JSON
|
| 71 |
let payload: any;
|
|
@@ -75,19 +107,23 @@ export class HapticLinkServer {
|
|
| 75 |
return ws.send(JSON.stringify({ error: "message not in JSON format" }));
|
| 76 |
}
|
| 77 |
|
|
|
|
| 78 |
if (typeof payload != "object" || !Object.keys(payload).includes("route")) {
|
| 79 |
return ws.send(JSON.stringify({ error: "missing route" }));
|
| 80 |
}
|
| 81 |
|
|
|
|
| 82 |
if (!(payload.route in this.routes)) {
|
| 83 |
return ws.send(JSON.stringify({ error: "route not found" }));
|
| 84 |
}
|
| 85 |
|
| 86 |
const route = this.routes[payload.route];
|
|
|
|
| 87 |
delete payload.route;
|
| 88 |
|
| 89 |
let user: User;
|
| 90 |
|
|
|
|
| 91 |
if (this.users.has(ws)) {
|
| 92 |
user = this.users.get(ws)!;
|
| 93 |
} else {
|
|
@@ -96,6 +132,7 @@ export class HapticLinkServer {
|
|
| 96 |
user = newUser;
|
| 97 |
}
|
| 98 |
|
|
|
|
| 99 |
let context: Context<any> = {
|
| 100 |
ws,
|
| 101 |
payload,
|
|
@@ -107,6 +144,7 @@ export class HapticLinkServer {
|
|
| 107 |
return ws.send(JSON.stringify({ error: "invalid route" }));
|
| 108 |
}
|
| 109 |
|
|
|
|
| 110 |
if (route.schema.safeParse(payload).success) {
|
| 111 |
route.handler(context);
|
| 112 |
} else {
|
|
|
|
| 5 |
|
| 6 |
type RouteHandler<T> = (context: Context<T>) => void;
|
| 7 |
|
| 8 |
+
|
| 9 |
export interface Route<T> {
|
| 10 |
+
// Name of route
|
| 11 |
name: string;
|
| 12 |
+
|
| 13 |
+
// ZodSchema used to validate request body
|
| 14 |
schema: ZodSchema<T>;
|
| 15 |
+
|
| 16 |
+
// Handler Function. To be called after request body is validated
|
| 17 |
handler: RouteHandler<T>;
|
| 18 |
}
|
| 19 |
|
|
|
|
| 31 |
}
|
| 32 |
}
|
| 33 |
|
| 34 |
+
// Context holds all available information for any given request
|
| 35 |
export interface Context<T> {
|
| 36 |
ws: WebSocketInterface;
|
| 37 |
user: User;
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
export class HapticLinkServer {
|
| 43 |
+
// Router map
|
| 44 |
routes: { [key: string]: Route<any> };
|
| 45 |
+
|
| 46 |
+
// All rooms are stored in this map
|
| 47 |
rooms: { [key: string]: Room };
|
| 48 |
+
|
| 49 |
+
// All users are stored in this map and should be dropped when their connection does.
|
| 50 |
users: Map<WebSocketInterface, User>;
|
| 51 |
|
| 52 |
constructor() {
|
|
|
|
| 55 |
this.rooms = {};
|
| 56 |
}
|
| 57 |
|
| 58 |
+
/**
|
| 59 |
+
* Removes user from registered users
|
| 60 |
+
* @param {WebSocketInterface} ws
|
| 61 |
+
* @returns {boolean} true/false whether the user was successfully removed
|
| 62 |
+
*/
|
| 63 |
removeUser(ws: WebSocketInterface): boolean {
|
| 64 |
const user = this.users.get(ws);
|
| 65 |
if (!user) return false;
|
|
|
|
| 69 |
return true;
|
| 70 |
}
|
| 71 |
|
| 72 |
+
/**
|
| 73 |
+
* Registers a route with router
|
| 74 |
+
* @param {string} name The name of the route which be used by the client to identify the route.
|
| 75 |
+
* @param {ZodSchema<T>} schema A ZodSchema to match the body of the WS message
|
| 76 |
+
* @param {RouteHandler<T>} handler A handler function that should have a matching schema
|
| 77 |
+
* @returns
|
| 78 |
+
*/
|
| 79 |
addRoute<T>(name: string, schema: ZodSchema<T>, handler: RouteHandler<T>): boolean {
|
| 80 |
if (name in this.routes) {
|
| 81 |
return false;
|
|
|
|
| 90 |
return true;
|
| 91 |
}
|
| 92 |
|
| 93 |
+
/**
|
| 94 |
+
* Compares message param with names of registered routes
|
| 95 |
+
* If route exists, validates the schema and then calls
|
| 96 |
+
* the handler function with the formatted payload
|
| 97 |
+
* @param {WebSocketInterface} ws
|
| 98 |
+
* @param {string} message WebSocket message
|
| 99 |
+
* @returns
|
| 100 |
+
*/
|
| 101 |
handleRoute(ws: WebSocketInterface, message: string) {
|
| 102 |
// Parse JSON
|
| 103 |
let payload: any;
|
|
|
|
| 107 |
return ws.send(JSON.stringify({ error: "message not in JSON format" }));
|
| 108 |
}
|
| 109 |
|
| 110 |
+
// Check if message includes route
|
| 111 |
if (typeof payload != "object" || !Object.keys(payload).includes("route")) {
|
| 112 |
return ws.send(JSON.stringify({ error: "missing route" }));
|
| 113 |
}
|
| 114 |
|
| 115 |
+
// Check if route is registered
|
| 116 |
if (!(payload.route in this.routes)) {
|
| 117 |
return ws.send(JSON.stringify({ error: "route not found" }));
|
| 118 |
}
|
| 119 |
|
| 120 |
const route = this.routes[payload.route];
|
| 121 |
+
// Removes route from body
|
| 122 |
delete payload.route;
|
| 123 |
|
| 124 |
let user: User;
|
| 125 |
|
| 126 |
+
// Get user or create new one
|
| 127 |
if (this.users.has(ws)) {
|
| 128 |
user = this.users.get(ws)!;
|
| 129 |
} else {
|
|
|
|
| 132 |
user = newUser;
|
| 133 |
}
|
| 134 |
|
| 135 |
+
// Create context
|
| 136 |
let context: Context<any> = {
|
| 137 |
ws,
|
| 138 |
payload,
|
|
|
|
| 144 |
return ws.send(JSON.stringify({ error: "invalid route" }));
|
| 145 |
}
|
| 146 |
|
| 147 |
+
// Validates message body and calls handler
|
| 148 |
if (route.schema.safeParse(payload).success) {
|
| 149 |
route.handler(context);
|
| 150 |
} else {
|
server/src/socket/room.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { generateSessionToken } from "../helpers";
|
| 2 |
import { User } from "./hapticLinkServer";
|
| 3 |
|
|
|
|
| 4 |
export interface UserData {
|
| 5 |
username: string;
|
| 6 |
id: string;
|
|
@@ -12,18 +13,34 @@ export class Room {
|
|
| 12 |
static roomIdLength: number = 6;
|
| 13 |
id: string;
|
| 14 |
users: User[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
constructor(roomId?: string) {
|
| 16 |
this.id = roomId || generateSessionToken(Room.roomIdLength);
|
| 17 |
this.users = [];
|
| 18 |
}
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
addUser(user: User) {
|
| 21 |
-
// Generate random roomId if one wasn't created.
|
| 22 |
if (this.users.includes(user)) return;
|
| 23 |
this.users.push(user);
|
| 24 |
this.updateRoom();
|
| 25 |
}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
removeUserById(id: string): boolean {
|
| 28 |
const oldSize = this.users.length;
|
| 29 |
this.users = this.users.filter((user) => user.id != id);
|
|
@@ -31,13 +48,19 @@ export class Room {
|
|
| 31 |
return oldSize > this.users.length;
|
| 32 |
}
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
hasUser(id: string): boolean {
|
| 35 |
return this.users.some((user) => user.id == id);
|
| 36 |
}
|
| 37 |
|
| 38 |
/**
|
| 39 |
-
*
|
| 40 |
-
* @param
|
|
|
|
| 41 |
**/
|
| 42 |
broadcast(message: string, filter: string[] = []) {
|
| 43 |
this.users.forEach((user) => {
|
|
@@ -46,6 +69,10 @@ export class Room {
|
|
| 46 |
});
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
updateRoom() {
|
| 50 |
const usersData: UserData[] = [];
|
| 51 |
this.users.forEach((user) => {
|
|
|
|
| 1 |
import { generateSessionToken } from "../helpers";
|
| 2 |
import { User } from "./hapticLinkServer";
|
| 3 |
|
| 4 |
+
// User data that is safe to reveal to users
|
| 5 |
export interface UserData {
|
| 6 |
username: string;
|
| 7 |
id: string;
|
|
|
|
| 13 |
static roomIdLength: number = 6;
|
| 14 |
id: string;
|
| 15 |
users: User[];
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* @constructor
|
| 19 |
+
* @param roomId Optional, a secure one will be generated
|
| 20 |
+
*/
|
| 21 |
constructor(roomId?: string) {
|
| 22 |
this.id = roomId || generateSessionToken(Room.roomIdLength);
|
| 23 |
this.users = [];
|
| 24 |
}
|
| 25 |
|
| 26 |
+
/**
|
| 27 |
+
* Add a user to room, broadcasts message to all room users
|
| 28 |
+
* when user if user is successfully added. Won't add user
|
| 29 |
+
* if they are alreadu part of it
|
| 30 |
+
* @param {User} user
|
| 31 |
+
* @returns
|
| 32 |
+
*/
|
| 33 |
addUser(user: User) {
|
|
|
|
| 34 |
if (this.users.includes(user)) return;
|
| 35 |
this.users.push(user);
|
| 36 |
this.updateRoom();
|
| 37 |
}
|
| 38 |
|
| 39 |
+
/**
|
| 40 |
+
* Removed user from an ID
|
| 41 |
+
* @param {string} id User's ID
|
| 42 |
+
* @returns {boolean} true/false whether the user was deleted or not
|
| 43 |
+
*/
|
| 44 |
removeUserById(id: string): boolean {
|
| 45 |
const oldSize = this.users.length;
|
| 46 |
this.users = this.users.filter((user) => user.id != id);
|
|
|
|
| 48 |
return oldSize > this.users.length;
|
| 49 |
}
|
| 50 |
|
| 51 |
+
/**
|
| 52 |
+
* Used to check if a room contains a user
|
| 53 |
+
* @param {string} id User's ID
|
| 54 |
+
* @returns {boolean} true/false whether the room has that user
|
| 55 |
+
*/
|
| 56 |
hasUser(id: string): boolean {
|
| 57 |
return this.users.some((user) => user.id == id);
|
| 58 |
}
|
| 59 |
|
| 60 |
/**
|
| 61 |
+
* Broadcasts a message to every user in room
|
| 62 |
+
* @param {string} message Message to send to users
|
| 63 |
+
* @param {string[]} filter Array of user ID to ignore when sending
|
| 64 |
**/
|
| 65 |
broadcast(message: string, filter: string[] = []) {
|
| 66 |
this.users.forEach((user) => {
|
|
|
|
| 69 |
});
|
| 70 |
}
|
| 71 |
|
| 72 |
+
/**
|
| 73 |
+
* Broadcasts the user list of the current room.
|
| 74 |
+
* Should be used when a user is added or removed.
|
| 75 |
+
*/
|
| 76 |
updateRoom() {
|
| 77 |
const usersData: UserData[] = [];
|
| 78 |
this.users.forEach((user) => {
|
server/src/socket/routes.ts
CHANGED
|
@@ -6,6 +6,10 @@ import { SendVibrationHandler, SendVibrationSchema } from "./routes/send_touch";
|
|
| 6 |
import { SetUsernameHandler, SetUsernameSchema } from "./routes/set_username";
|
| 7 |
import { TestConnnectionSchema, TestConnectionHandler } from "./routes/test_connection";
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
export function registerRoutes(router: HapticLinkServer) {
|
| 10 |
router.addRoute("test_connection", TestConnnectionSchema, TestConnectionHandler);
|
| 11 |
router.addRoute("join_room", JoinRoomSchema, JoinRoomHandler);
|
|
|
|
| 6 |
import { SetUsernameHandler, SetUsernameSchema } from "./routes/set_username";
|
| 7 |
import { TestConnnectionSchema, TestConnectionHandler } from "./routes/test_connection";
|
| 8 |
|
| 9 |
+
/**
|
| 10 |
+
* Registers all the routes for Haptic Link
|
| 11 |
+
* @param {HapticLinkServer} router
|
| 12 |
+
*/
|
| 13 |
export function registerRoutes(router: HapticLinkServer) {
|
| 14 |
router.addRoute("test_connection", TestConnnectionSchema, TestConnectionHandler);
|
| 15 |
router.addRoute("join_room", JoinRoomSchema, JoinRoomHandler);
|