avallef commited on
Commit
2b43822
·
1 Parent(s): 38937c3

Added websocket router, handlers, and basic rooms

Browse files
server/package-lock.json CHANGED
@@ -11,7 +11,8 @@
11
  "dependencies": {
12
  "@types/ws": "^8.5.9",
13
  "express": "^4.18.2",
14
- "ws": "^8.14.2"
 
15
  },
16
  "devDependencies": {
17
  "@types/express": "^4.17.21",
@@ -1399,6 +1400,14 @@
1399
  "engines": {
1400
  "node": ">=6"
1401
  }
 
 
 
 
 
 
 
 
1402
  }
1403
  }
1404
  }
 
11
  "dependencies": {
12
  "@types/ws": "^8.5.9",
13
  "express": "^4.18.2",
14
+ "ws": "^8.14.2",
15
+ "zod": "^3.22.4"
16
  },
17
  "devDependencies": {
18
  "@types/express": "^4.17.21",
 
1400
  "engines": {
1401
  "node": ">=6"
1402
  }
1403
+ },
1404
+ "node_modules/zod": {
1405
+ "version": "3.22.4",
1406
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
1407
+ "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
1408
+ "funding": {
1409
+ "url": "https://github.com/sponsors/colinhacks"
1410
+ }
1411
  }
1412
  }
1413
  }
server/package.json CHANGED
@@ -23,6 +23,7 @@
23
  "dependencies": {
24
  "@types/ws": "^8.5.9",
25
  "express": "^4.18.2",
26
- "ws": "^8.14.2"
 
27
  }
28
  }
 
23
  "dependencies": {
24
  "@types/ws": "^8.5.9",
25
  "express": "^4.18.2",
26
+ "ws": "^8.14.2",
27
+ "zod": "^3.22.4"
28
  }
29
  }
server/src/helpers.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import * as crypto from "crypto";
2
+
3
+ export function generateSessionToken(length: number): string {
4
+ return crypto.randomBytes(length).toString("hex");
5
+ }
6
+
server/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
  import express, { Application, Request, Response } from "express";
2
  import * as http from "http";
3
  import * as WebSocket from "ws";
 
 
4
 
5
  const port: number = parseInt(process.env.PORT as string, 10) || 3000;
6
 
@@ -12,13 +14,30 @@ app.get("/", (_req: Request, res: Response) => {
12
  res.send("Bonjour");
13
  });
14
 
 
 
 
 
 
 
 
 
15
  wss.on("connection", (ws: WebSocket) => {
16
  console.log("Client Connected");
17
 
18
  ws.on("message", (message: string) => {
19
  console.log(`Received Message: ${message}`);
 
20
  });
21
 
 
 
 
 
 
 
 
 
22
  ws.send("Welcome");
23
  });
24
 
 
1
  import express, { Application, Request, Response } from "express";
2
  import * as http from "http";
3
  import * as WebSocket from "ws";
4
+ import { HapticLinkServer } from "./socket/hapticLinkServer";
5
+ import { registerRoutes } from "./socket/routes";
6
 
7
  const port: number = parseInt(process.env.PORT as string, 10) || 3000;
8
 
 
14
  res.send("Bonjour");
15
  });
16
 
17
+ // Routes are in socket/routes/*.ts
18
+ // registerRoutes imports and adds them all to router
19
+ const hapticLink = new HapticLinkServer();
20
+ registerRoutes(hapticLink);
21
+
22
+ // When a user sends a message, the router checks if that route is available
23
+ // and then calls the handler. It also generates a User object for them to store
24
+ // data for later requests such as an ID
25
  wss.on("connection", (ws: WebSocket) => {
26
  console.log("Client Connected");
27
 
28
  ws.on("message", (message: string) => {
29
  console.log(`Received Message: ${message}`);
30
+ hapticLink.handleRoute(ws, message);
31
  });
32
 
33
+ // When a user disconnects, their account is removed, and they are removed from all groups
34
+ ws.on("close", () => {
35
+ hapticLink.removeUser(ws);
36
+ })
37
+ ws.on("error", () => {
38
+ hapticLink.removeUser(ws);
39
+ })
40
+
41
  ws.send("Welcome");
42
  });
43
 
server/src/socket/hapticLinkServer.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as WebSocket from "ws";
2
+ import { ZodSchema } from "zod";
3
+ import { generateSessionToken } from "../helpers";
4
+ import { Room } from "./room";
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
+
14
+ export class User {
15
+ id: string;
16
+ username?: string;
17
+ socket: WebSocket;
18
+ currentRoom?: Room;
19
+ rooms: Room[];
20
+
21
+ constructor(socket: WebSocket) {
22
+ this.id = generateSessionToken(16);
23
+ this.socket = socket;
24
+ this.rooms = [];
25
+ }
26
+ }
27
+
28
+ export interface Context<T> {
29
+ ws: WebSocket;
30
+ user: User;
31
+ payload: T;
32
+ server: HapticLinkServer;
33
+ }
34
+
35
+ export class HapticLinkServer {
36
+ routes: { [key: string]: Route<any> }
37
+ rooms: { [key: string]: Room }
38
+ users: Map<WebSocket, User>;
39
+
40
+ constructor() {
41
+ this.routes = {};
42
+ this.users = new Map();
43
+ this.rooms = {};
44
+ }
45
+
46
+ removeUser(ws: WebSocket): boolean {
47
+ const user = this.users.get(ws);
48
+ if (!user) return false;
49
+ user.rooms.forEach((room) => {
50
+ room.removeUserById(user.id);
51
+ })
52
+
53
+ return true;
54
+ }
55
+
56
+ addRoute(route: Route<any>): boolean {
57
+ if (route.name in this.routes) {
58
+ return false
59
+ }
60
+
61
+ this.routes[route.name] = route;
62
+
63
+ return true;
64
+ }
65
+
66
+ handleRoute(ws: WebSocket, message: string) {
67
+ // Parse JSON
68
+ let payload: any;
69
+ try {
70
+ payload = JSON.parse(message);
71
+ } catch (e) {
72
+ return ws.send(JSON.stringify({ error: "message not in JSON format" }));
73
+ }
74
+
75
+ if (typeof payload != "object" || !Object.keys(payload).includes("route")) {
76
+ return ws.send(JSON.stringify({ error: "missing route" }));
77
+ }
78
+
79
+ if (!(payload.route in this.routes)) {
80
+ return ws.send(JSON.stringify({ error: "route not found" }));
81
+ }
82
+
83
+ if (!(payload.route in this.routes)) {
84
+ return ws.send(JSON.stringify({ error: "route not found" }));
85
+ }
86
+
87
+
88
+ const route = this.routes[payload.route];
89
+ delete payload.route;
90
+
91
+ let user: User;
92
+
93
+ if (this.users.has(ws)) {
94
+ user = this.users.get(ws)!;
95
+ } else {
96
+ const newUser = new User(ws);
97
+ this.users.set(ws, newUser);
98
+ user = newUser;
99
+ }
100
+
101
+ let context: Context<any> = {
102
+ ws,
103
+ payload,
104
+ server: this,
105
+ user
106
+ }
107
+
108
+ if (route && route.schema.safeParse(payload).success) {
109
+ route.handler(context);
110
+ } else {
111
+ return ws.send(JSON.stringify({ error: "invalid payload format" }));
112
+ }
113
+ }
114
+ }
server/src/socket/room.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { generateSessionToken } from "../helpers";
2
+ import { User } from "./hapticLinkServer";
3
+
4
+ interface UserData {
5
+ username: string;
6
+ id: string;
7
+ online: boolean;
8
+ lastOnline: number;
9
+ }
10
+
11
+ export class Room {
12
+ id: string;
13
+ users: User[];
14
+ constructor(roomId?: string) {
15
+ this.id = roomId || generateSessionToken(16);
16
+ this.users = [];
17
+ }
18
+
19
+ addUser(user: User) {
20
+ this.users.push(user);
21
+ user.rooms.push(this);
22
+ this.updateRoom();
23
+ }
24
+
25
+ removeUserById(id: string): boolean {
26
+ const oldSize = this.users.length;
27
+ this.users = this.users.filter(user => user.id != id);
28
+ this.updateRoom();
29
+ return oldSize > this.users.length;
30
+ }
31
+
32
+ hasUser(id: string): boolean {
33
+ return this.users.some(user => user.id == id);
34
+ }
35
+
36
+ /**
37
+ * @param message Message to send to users
38
+ * @param filter Array of user ID to ignore when sending
39
+ **/
40
+ broadcast(message: string, filter: string[] = []) {
41
+ this.users.forEach(user => {
42
+ if (filter.includes(user.id)) return;
43
+ user.socket.send(message);
44
+ })
45
+ }
46
+
47
+ updateRoom() {
48
+ const usersData: UserData[] = [];
49
+ this.users.forEach((user) => {
50
+ usersData.push({
51
+ username: user.username || "Unknown",
52
+ id: user.id,
53
+ online: true,
54
+ lastOnline: 0,
55
+ })
56
+ })
57
+
58
+ this.broadcast(JSON.stringify({
59
+ message: "room_update",
60
+ roomId: this.id,
61
+ users: usersData,
62
+ }))
63
+ }
64
+ }
server/src/socket/routes.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HapticLinkServer } from "./hapticLinkServer";
2
+ import JoinRoomRoute from "./routes/join_room";
3
+ import LeaveRoomRoute from "./routes/leave_room";
4
+ import TestConnectionRoute from "./routes/test_connection";
5
+
6
+ export function registerRoutes(router: HapticLinkServer) {
7
+ router.addRoute(TestConnectionRoute);
8
+ router.addRoute(JoinRoomRoute);
9
+ router.addRoute(LeaveRoomRoute);
10
+ }
server/src/socket/routes/join_room.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { Context, Route } from "../hapticLinkServer";
3
+ import { generateSessionToken } from "../../helpers";
4
+ import { Room } from "../room";
5
+
6
+ interface JoinRoomPayload {
7
+ roomId?: string;
8
+ username?: string;
9
+ }
10
+ const JoinRoomSchema = z.object({
11
+ roomId: z.string().optional(),
12
+ username: z.string().optional(),
13
+ })
14
+
15
+ const JoinRoomRoute: Route<JoinRoomPayload> = {
16
+ name: "join_room",
17
+ handler: function(ctx: Context<JoinRoomPayload>) {
18
+ // Set username if payload has it
19
+ if (ctx.payload.username) {
20
+ ctx.user.username = ctx.payload.username;
21
+ }
22
+
23
+ if (ctx.user.rooms.length > 4) {
24
+ return ctx.ws.send(JSON.stringify({
25
+ message: "join_room_response",
26
+ status: "you are already part of 5 groups. leave one to join another."
27
+ }))
28
+ }
29
+
30
+ let room: Room;
31
+
32
+ if (ctx.payload.roomId && !ctx.server.rooms[ctx.payload.roomId]) {
33
+ room = new Room(ctx.payload.roomId);
34
+ ctx.server.rooms[room.id] = room;
35
+ } else if (!ctx.payload.roomId) {
36
+ ctx.payload.roomId = generateSessionToken(12);
37
+ room = new Room(ctx.payload.roomId);
38
+ ctx.server.rooms[room.id] = room;
39
+ } else {
40
+ room = ctx.server.rooms[ctx.payload.roomId]
41
+ }
42
+
43
+ // Broadcasts message to room
44
+ room.addUser(ctx.user);
45
+ },
46
+ schema: JoinRoomSchema,
47
+ }
48
+
49
+ export default JoinRoomRoute;
server/src/socket/routes/leave_room.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { z } from "zod";
3
+ import { Context, Route } from "../hapticLinkServer";
4
+
5
+ interface LeaveRoomPayload {
6
+ roomId: string;
7
+ }
8
+ const LeaveRoomSchema = z.object({
9
+ roomId: z.string(),
10
+ })
11
+
12
+ const LeaveRoomRoute: Route<LeaveRoomPayload> = {
13
+ name: "leave_room",
14
+ handler: function(ctx: Context<LeaveRoomPayload>) {
15
+ const room = ctx.user.rooms.find((r) => {
16
+ return ctx.payload.roomId == r.id;
17
+ })
18
+
19
+ if (!room) {
20
+ return ctx.ws.send(JSON.stringify({
21
+ message: "leave_room_response",
22
+ status: "you are not part of that room"
23
+ }))
24
+ } else {
25
+ room.removeUserById(ctx.user.id);
26
+ ctx.user.rooms = ctx.user.rooms.filter((r) => {
27
+ return r != room
28
+ })
29
+ return ctx.ws.send(JSON.stringify({
30
+ message: "leave_room_response",
31
+ status: "room left"
32
+ }))
33
+ }
34
+ },
35
+ schema: LeaveRoomSchema,
36
+ }
37
+
38
+ export default LeaveRoomRoute;
server/src/socket/routes/test_connection.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { Context, Route } from "../hapticLinkServer";
3
+
4
+ interface TestConnectionPayload { }
5
+ const TestConnnectionSchema = z.object({})
6
+
7
+ const TestConnectionRoute: Route<TestConnectionPayload> = {
8
+ name: "test_connection",
9
+ handler: function (ctx: Context<TestConnectionPayload>) {
10
+ if (ctx.user) {
11
+ return ctx.ws.send(JSON.stringify({
12
+ "message": "test_connection_response",
13
+ "authenticated": true,
14
+ "username": ctx.user.username
15
+ }))
16
+ }
17
+ return ctx.ws.send(JSON.stringify({
18
+ "message": "test_connection_response",
19
+ "authenticated": false,
20
+ }))
21
+ },
22
+ schema: TestConnnectionSchema,
23
+ }
24
+
25
+ export default TestConnectionRoute;