From 1f101fe7cc5cc31c66146a1e226fa4bae805fdd4 Mon Sep 17 00:00:00 2001 From: Leonardo Bishop Date: Thu, 9 Nov 2023 00:17:03 +0000 Subject: Refactor websockets --- backend/src/app.ts | 5 +- backend/src/config/coop-session-store.ts | 73 +++++++++++++++ backend/src/config/session-store.ts | 77 ---------------- backend/src/model/actions/coop.ts | 43 +++++++++ backend/src/model/session.ts | 16 ++++ backend/src/model/websocket.ts | 7 ++ backend/src/routes/session.route.ts | 6 +- backend/src/websocket/coop.ts | 142 ++++++++++++++++++++++++++++++ backend/src/websocket/game.ts | 97 -------------------- backend/src/websocket/websocket-router.ts | 18 ++++ frontend/src/views/HostView.vue | 57 +++++------- frontend/src/views/JoinView.vue | 43 +++++---- 12 files changed, 354 insertions(+), 230 deletions(-) create mode 100644 backend/src/config/coop-session-store.ts delete mode 100644 backend/src/config/session-store.ts create mode 100644 backend/src/model/actions/coop.ts create mode 100644 backend/src/model/session.ts create mode 100644 backend/src/model/websocket.ts create mode 100644 backend/src/websocket/coop.ts delete mode 100644 backend/src/websocket/game.ts create mode 100644 backend/src/websocket/websocket-router.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 41b8821..1f5df24 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,8 +1,7 @@ import express from 'express'; import router from './router.js'; import cors from 'cors'; -import createWebsocketServer from './websocket/game.js'; -import { WebSocketServer } from 'ws'; +import createWebsocketServer from './websocket/websocket-router.js'; const app = express(); @@ -19,4 +18,4 @@ const server = app.listen(port, () => { console.log(`Server listening on port ${port}`); }); -const websocketServer: WebSocketServer = createWebsocketServer(server); \ No newline at end of file +createWebsocketServer(server); \ No newline at end of file diff --git a/backend/src/config/coop-session-store.ts b/backend/src/config/coop-session-store.ts new file mode 100644 index 0000000..09fef87 --- /dev/null +++ b/backend/src/config/coop-session-store.ts @@ -0,0 +1,73 @@ +import { CoopSession, SessionState } from "../model/session"; +import { CoopWebSocket } from "../model/websocket"; + +const sessions: { [key: string]: CoopSession } = {}; + +function makeid(length) { + let result = ''; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + let counter = 0; + while (counter < length) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + counter += 1; + } + return result; +} + + +export const createNewSession = (host: CoopWebSocket): CoopSession => { + const id = makeid(10); + const session: CoopSession = { + id, + state: "waiting", + host: host, + clients: [], + }; + sessions[id] = session; + return session; +}; + +export const setSessionState = (id: string, state: SessionState): void => { + if (!sessions[id]) { + return; + } + + sessions[id].state = state; + + if (state === "finished") { + cleanupSession(id); + } +}; + +export const setSessionHost = (id: string, client: CoopWebSocket): void => { + if (!sessions[id]) { + return; + } + + sessions[id].host = client; +}; + +export const addSessionClient = (id: string, client: CoopWebSocket): void => { + if (!sessions[id]) { + return; + } + + sessions[id]?.clients.push(client); +}; + +export const cleanupSession = (id: string): void => { + if (!sessions[id]) { + return; + } + + sessions[id].host.close(); + + sessions[id].clients.forEach(client => client.close()); + + delete sessions[id]; +} + +export const getSession = (id: string): CoopSession => { + return sessions[id]; +}; \ No newline at end of file diff --git a/backend/src/config/session-store.ts b/backend/src/config/session-store.ts deleted file mode 100644 index 283031b..0000000 --- a/backend/src/config/session-store.ts +++ /dev/null @@ -1,77 +0,0 @@ -export type Session = { - id: string; - state: string; - host?: string; - clients: string[]; -}; - -const sessions: { [key: string]: Session } = {}; - -function makeid(length) { - let result = ''; - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const charactersLength = characters.length; - let counter = 0; - while (counter < length) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - counter += 1; - } - return result; -} - - -export const createNewSession = (): Session => { - const id = makeid(10); - const session = { - id, - state: "waiting", - host: undefined, - clients: [], - }; - sessions[id] = session; - return session; -}; - -export const setSessionState = (id: string, state: string): void => { - if (!sessions[id]) { - return; - } - - sessions[id].state = state; -}; - -export const setSessionHost = (id: string, clientId: string): void => { - if (!sessions[id]) { - return; - } - - sessions[id].host = clientId; -}; - -export const addSessionClient = (id: string, clientId: string): void => { - if (!sessions[id]) { - return; - } - - sessions[id]?.clients.push(clientId); -}; - -export const cleanupSession = (id: string): void => { - if (!sessions[id]) { - return; - } - -// if (sessions[id].host) { -// sessions[id].host!.close(); -// } -// -// sessions[id].clients.forEach((client) => { -// client.close(); -// }); - - delete sessions[id]; -} - -export const getSession = (id: string): Session => { - return sessions[id]; -}; \ No newline at end of file diff --git a/backend/src/model/actions/coop.ts b/backend/src/model/actions/coop.ts new file mode 100644 index 0000000..e1dfed5 --- /dev/null +++ b/backend/src/model/actions/coop.ts @@ -0,0 +1,43 @@ +import { SessionState } from "../session"; + +export type CoopSessionCreatedMessage = { + type: "create"; + payload: { + sessionId: string; + }; +}; + +export type ClientJoinedCoopSessionMessage = { + type: "join"; + payload: { + numberOfClients: number; + }; +}; + +export type CoopSessionStateChangeMessage = { + type: "state"; + payload: { + state: SessionState; + }; +}; + +export type CoopSessionMoveMessage = { + type: "move"; + payload: { + move: string; + }; +}; + +export type CoopSessionMoveTimeoutMessage = { + type: "timeout"; + payload: { + timeoutUntil: number; + }; +}; + +export type CoopSessionMessage = + | CoopSessionCreatedMessage + | ClientJoinedCoopSessionMessage + | CoopSessionStateChangeMessage + | CoopSessionMoveMessage + | CoopSessionMoveTimeoutMessage; \ No newline at end of file diff --git a/backend/src/model/session.ts b/backend/src/model/session.ts new file mode 100644 index 0000000..d2fdea3 --- /dev/null +++ b/backend/src/model/session.ts @@ -0,0 +1,16 @@ +import { CoopWebSocket } from "./websocket"; + +export type CoopSession = { + id: string; + state: SessionState; + host: CoopWebSocket; + clients: CoopWebSocket[]; +}; + +export type VersusSession = { + id: string; + state: SessionState; + clients: string[]; +}; + +export type SessionState = 'waiting' | 'playing' | 'finished'; \ No newline at end of file diff --git a/backend/src/model/websocket.ts b/backend/src/model/websocket.ts new file mode 100644 index 0000000..bb2ae8d --- /dev/null +++ b/backend/src/model/websocket.ts @@ -0,0 +1,7 @@ +import { WebSocket } from 'ws'; + +export interface CoopWebSocket extends WebSocket { + clientId: string; + sessionId: string; + nextMoveTimestamp: number; +} diff --git a/backend/src/routes/session.route.ts b/backend/src/routes/session.route.ts index 3ac8525..cc581ef 100644 --- a/backend/src/routes/session.route.ts +++ b/backend/src/routes/session.route.ts @@ -1,7 +1,7 @@ -import { createNewSession } from "../config/session-store.js"; +import { createNewSession } from "../config/coop-session-store.js"; import { Request, Response } from "express"; export function createSession(req: Request, res: Response) { - const session = createNewSession(); - res.status(201).send(session); + // const session = createNewSession(); + res.status(400).send(); } diff --git a/backend/src/websocket/coop.ts b/backend/src/websocket/coop.ts new file mode 100644 index 0000000..ce8bfa0 --- /dev/null +++ b/backend/src/websocket/coop.ts @@ -0,0 +1,142 @@ +import { + addSessionClient, + createNewSession, + getSession, + setSessionState, +} from "../config/coop-session-store.js"; +import { WebSocketServer } from "ws"; +import { v4 as uuidv4 } from "uuid"; +import { IncomingMessage } from "http"; +import { CoopWebSocket } from "../model/websocket.js"; +import { parse } from "url"; +import { + ClientJoinedCoopSessionMessage, + CoopSessionCreatedMessage, + CoopSessionMessage, + CoopSessionMoveMessage, + CoopSessionMoveTimeoutMessage, + CoopSessionStateChangeMessage, +} from "../model/actions/coop.js"; + +export const wss = new WebSocketServer({ noServer: true }); + +const sendMessageToClient = ( + client: CoopWebSocket, + message: CoopSessionMessage +) => { + if (client.readyState === client.OPEN) { + client.send(JSON.stringify(message)); + console.log( + `Sent message to client ${client.clientId}: ${JSON.stringify(message)}}` + ); + } +}; + +wss.on("connection", (ws: CoopWebSocket, req: IncomingMessage) => { + ws.clientId = uuidv4(); + + const url = parse(req.url!, true); + + if (url.query.action === "create") { + const session = createNewSession(ws); + sendMessageToClient(ws, { + type: "create", + payload: { + sessionId: session.id, + }, + }); + ws.sessionId = session.id; + } else if (url.query.action === "join") { + const session = getSession(url.query.sessionId as string); + if (!session) { + ws.close(); + return; + } + + addSessionClient(url.query.sessionId as string, ws); + + sendMessageToClient(session.host, { + type: "join", + payload: { + numberOfClients: session.clients.length, + }, + }); + sendMessageToClient(ws, { + type: "state", + payload: { + state: session.state, + }, + }); + + ws.sessionId = session.id; + } else { + ws.close(); + return; + } + + ws.on("message", (message) => { + console.log(`Received message from client ${ws.clientId}: ${message}`); + let data: any; + try { + data = JSON.parse(message.toString()); + } catch (e) { + return; + } + + const session = getSession(ws.sessionId); + if (!session) { + return; + } + + const type = data.type as string; + const payload = data.payload as any; + + if (!type || !payload) { + return; + } + + if (type === "move") { + if (Date.now() - ws.nextMoveTimestamp < 0) { + return; + } + + sendMessageToClient(session.host, { + type: "move", + payload: { + move: payload.move, + }, + }); + + ws.nextMoveTimestamp = Date.now() + 1000; + + sendMessageToClient(ws, { + type: "timeout", + payload: { + timeoutUntil: ws.nextMoveTimestamp, + }, + }); + } else if (type === "start") { + session.state = "playing"; + + [session.host, ...session.clients].forEach((client) => { + sendMessageToClient(client, { + type: "state", + payload: { + state: "playing", + }, + }); + }); + } else if (type === "end") { + session.state = "finished"; + + [session.host, ...session.clients].forEach((client) => { + sendMessageToClient(client, { + type: "state", + payload: { + state: "finished", + }, + }); + }); + } + }); +}); diff --git a/backend/src/websocket/game.ts b/backend/src/websocket/game.ts deleted file mode 100644 index c37b1c5..0000000 --- a/backend/src/websocket/game.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Server } from "http"; -import { addSessionClient, getSession, setSessionHost, setSessionState } from "../config/session-store.js"; -import { WebSocketServer } from "ws"; -import { v4 as uuidv4 } from "uuid"; - -const wss = new WebSocketServer({ noServer: true }); - -const sendToClient = (clientId: string, message: any) => { - wss.clients.forEach((client: any) => { - if (client.clientId === clientId) { - client.send(JSON.stringify(message)); - } - }); -}; - -const broadcastToClients = (clientIds: string[], message: any) => { - wss.clients.forEach((client: any) => { - if (clientIds.includes(client.clientId)) { - client.send(JSON.stringify(message)); - } - }); -}; - -export const createWebsocketServer = (server: Server): WebSocketServer => { - server.on("upgrade", (req, socket, head) => { - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit("connection", ws, req); - }); - }); - - wss.on("connection", (ws: any) => { - ws.clientId = uuidv4(); - - ws.on("message", (message) => { - console.log("received: %s", message); - let data; - try { - data = JSON.parse(message.toString()); - } catch (e) { - console.log("Invalid JSON"); - return; - } - - if (data.action === "host") { - setSessionHost(data.sessionId, ws.clientId); - } else if (data.action === "move") { - const session = getSession(data.sessionId); - if (!session) { - return; - } - - sendToClient(session.host!, { - action: "move", - move: data.move, - }); - } else if (data.action === "join") { - const session = getSession(data.sessionId); - if (!session) { - return; - } - - addSessionClient(data.sessionId, ws.clientId); - - sendToClient(session.host!, { - action: "join", - clients: session.clients.length, - }); - - ws.send(JSON.stringify({ - action: "state", - state: session.state, - })); - } else if (data.action === "start") { - const session = getSession(data.sessionId); - if (!session) { - return; - } - - setSessionState(data.sessionId, "playing"); - - sendToClient(session.host!, { - action: "state", - state: "playing", - }); - - broadcastToClients(session.clients, { - action: "state", - state: "playing", - }); - } - }); - }); - - return wss; -}; - -export default createWebsocketServer; diff --git a/backend/src/websocket/websocket-router.ts b/backend/src/websocket/websocket-router.ts new file mode 100644 index 0000000..0b11985 --- /dev/null +++ b/backend/src/websocket/websocket-router.ts @@ -0,0 +1,18 @@ +import { Server } from "http"; +import { wss as gameServer } from "./coop.js"; + +export const createWebsocketServer = (server: Server) => { + + server.on("upgrade", (req, socket, head) => { + if (req.url?.startsWith("/coop")) { + gameServer.handleUpgrade(req, socket, head, (ws) => { + gameServer.emit("connection", ws, req); + }); + } else { + socket.destroy(); + } + }); + +}; + +export default createWebsocketServer; diff --git a/frontend/src/views/HostView.vue b/frontend/src/views/HostView.vue index 8d6a6b9..adc9c05 100644 --- a/frontend/src/views/HostView.vue +++ b/frontend/src/views/HostView.vue @@ -16,46 +16,33 @@ function startGame() { return; } socket.send(JSON.stringify({ - action: 'start', - sessionId: code.value, + type: 'start', + payload: {} })); } -fetch(`${import.meta.env.VITE_BACKEND_BASE_URL}/session`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - } -).then(async (response) => { - const data: any = await response.json(); - code.value = data.id; - url.value = `${import.meta.env.VITE_FRONTEND_BASE_URL}/join/${data.id}`; - waitingForCode.value = false; - openSocket(data.id); -}); +socket = new WebSocket(`${import.meta.env.VITE_BACKEND_WS_URL}/coop?action=create`); -function openSocket(id: string) { - socket = new WebSocket(`${import.meta.env.VITE_BACKEND_WS_URL}`); +socket.onmessage = (event) => { + const data = JSON.parse(event.data); + const type = data.type; + const payload = data.payload; - socket.onopen = () => { - socket.send(JSON.stringify({ - action: 'host', - sessionId: id, - })); - }; - socket.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.action === 'join') { - numClients.value = data.clients; - } else if (data.action === 'state' && data.state === 'playing') { - started.value = true; - } else if (data.action === 'move') { - inputQueue.value.push(data.move); - } - }; -} + if (type === 'create') { + code.value = payload.sessionId; + waitingForCode.value = false; + url.value = `${import.meta.env.VITE_FRONTEND_BASE_URL}/join/${payload.sessionId}`; + + } else if (type === 'join') { + numClients.value = payload.numberOfClients; + + } else if (type === 'state') { + started.value = payload.state === 'playing'; + + } else if (type === 'move') { + inputQueue.value.push(payload.move); + } +};