// import { randomInt } from "./deps.ts"; const SERVER_VERSION = "0.2.0"; // TODO: version comparison type ID = string; // app state const allLobbies = new Map(); const allClients = new Map(); // TODO: client index by id? interface DataMessage { type: string; } type ServerDataObject = Record< "peerId" | string, string | number | symbol | null | boolean >; type ServerData = ServerDataObject[] | ServerDataObject | string | boolean; type Message = string | DataMessage; const buildMessage = ( type: string, data: ServerData | ServerData[], ) => { if (Array.isArray(data)) return { type, data }; else if (typeof data === "object") return { type, ...data }; return `${type}:${data}`; }; class Client { id: ID; peerId: number | null; name: string; socket: WebSocket; lobby: Lobby | null; constructor(socket: WebSocket) { this.id = crypto.randomUUID(); this.socket = socket; this.peerId = null; this.name = "Client"; this.lobby = null; allClients.set(this.id, this); } isConnected() { return this.socket.readyState == WebSocket.OPEN; } remove() { this.lobbyLeave(); if (this.isConnected()) { this.socket.close(); } allClients.delete(this.id); } send(message: Message) { try { if (this.isConnected()) { this.socket.send( typeof message === "object" ? ("json:" + JSON.stringify(message)) : message, ); } } catch (e) { console.error( `Failed to send on socket ${this.socket} to client ${this.id}: ${ JSON.stringify(e) }`, ); this.remove(); } } ping() { this.send(buildMessage("ping", {})); } peerList() { if (!this.lobby) { this.send( buildMessage( "info", "you cannot request a list of peers unless you are in a lobby", ), ); return; } const peers: { id: ID; name: string; peerId: number | null; }[] = []; this.lobby.clients.forEach(({ id, name, peerId }) => peers.push({ id, name, peerId }) ); this.send(buildMessage("peer_data", peers)); // TODO: chunk async? } lobbyList() { const netLobbies: { id: ID; name: string; maxPlayers: number; locked: boolean; currentPlayers: number; }[] = []; allLobbies.forEach((lobby) => netLobbies.push(lobby.toData())); this.send(buildMessage("lobby_data", netLobbies)); } lobbyNew(lobby: Lobby) { // if the client is already in a lobby, do not send them lobby updates if (this.lobby) return; this.send( buildMessage("lobby_data", [lobby.toData()]), ); } lobbyDelete({ id }: Lobby) { if (this.lobby) return; this.send(buildMessage("lobby_delete", { id })); } lobbyCreate(opts?: Partial) { if (this.lobby) { this.send( buildMessage( "info", `cannot create lobby: already in lobby ${this.lobby.id}`, ), ); return; } new Lobby(this, opts?.name || `${this.name}'s lobby`, opts?.maxPlayers); } lobbyJoin(lobby: Lobby) { if (this.lobby) { this.send( buildMessage( `info`, `cannot join lobby: already in lobby ${this.lobby.id}`, ), ); return; } this.lobby = lobby; lobby.addClient(this); this.send( buildMessage("lobby_joined", { ...this.lobby.toData(), peerId: this.peerId, }), ); } lobbyLeave() { const leavingLobby = this.lobby; if (!leavingLobby) { this.send(`[info] cannot leave lobby (not in a lobby)`); return; } this.peerId = null; this.lobby = null; if (this.isConnected()) { this.send(buildMessage("lobby_left", { id: leavingLobby.id })); } leavingLobby.removeClient(this); } } class Lobby { id: ID; name: string; clients: Map; hostClientId: ID; maxPlayers: number; locked: boolean; _shouldNotify: boolean; constructor(host: Client, name?: string, maxPlayers = 20) { this.id = crypto.randomUUID(); this.hostClientId = host.id; this.clients = new Map(); this.name = name || this.id; this.maxPlayers = maxPlayers; this.locked = false; this._shouldNotify = true; allLobbies.set(this.id, this); host.peerId = 1; host.lobbyJoin(this); this.notify(); } toData() { return { id: this.id, name: this.name, maxPlayers: this.maxPlayers, locked: this.locked, currentPlayers: this.clients.size, }; } update( requestor: Client, newValues: { name?: string; maxPlayers?: number; locked?: boolean }, ) { if (requestor.peerId === 1) { for (const k in newValues) { switch (k) { case "name": if (newValues?.name) this.name = newValues.name; break; case "maxPlayers": if ( newValues?.maxPlayers && typeof newValues.maxPlayers === "number" ) { this.maxPlayers = newValues.maxPlayers; } break; case "locked": if (newValues?.locked) this.locked = newValues.locked; break; } } this.notify(); } else { requestor.send( "[info] you cannot update this lobby (you are not the host!)", ); } } notify() { if (!this._shouldNotify) return; allClients.forEach((client) => client.lobbyNew(this)); } remove() { this._shouldNotify = false; allLobbies.delete(this.id); allClients.forEach((client) => client.lobbyDelete(this)); this.clients.forEach((client) => { client.lobbyLeave(); }); } broadcast(message: Message) { this.clients.forEach((client) => client.send(message)); } addClient(client: Client) { if (!client.peerId) { const arr = new Int32Array(1); crypto.getRandomValues(arr); client.peerId = Math.abs(arr[0]); } client.send(buildMessage("init_peer", client.peerId.toString())); this.broadcast( buildMessage("peer_data", [{ id: client.id, name: client.name, peerId: client.peerId, }]), ); client.send( buildMessage( "peer_data", Array.from(this.clients.values()).map( ({ id, name, peerId }) => ({ id, name, peerId }), ), ), ); this.clients.set(client.id, client); this.notify(); } removeClient({ id }: Client) { this.clients.delete(id); this.broadcast(buildMessage("peer_left", [{ id }])); if (id === this.hostClientId) { console.warn("Host left!"); this.remove(); } this.notify(); } getPeer(peerId: number): Client | null { for (const [_id, client] of this.clients) { if (client.peerId == peerId) return client; } return null; } } interface ClientMessage { type: | "candidate" | "offer" | "answer" | "init" | "lobby_create" | "update_lobby"; data: ServerDataObject; } function parseMessage(message: string): { type: string; data?: ServerData } { const trimmedMessage = message.trim(); if (trimmedMessage.startsWith("json:")) { const { type, ...data } = JSON.parse(trimmedMessage.substr(5)); return { type, data }; } else { const splitAt = trimmedMessage.indexOf(":"); if (splitAt > 0) { return { type: trimmedMessage.substr(0, splitAt), data: trimmedMessage.substr(splitAt + 1), }; } else { return { type: trimmedMessage }; } } } // events function onMessage(client: Client, ev: MessageEvent) { // TODO: log who from? IPs etc.? const msg = parseMessage(ev.data); if (msg.type === "pong") return; console.log("Client Message Received", msg); switch (msg.type) { case "init": if (msg.data && (msg.data as { name: string })["name"]) { client.name = (msg.data as { name: string }).name.toString(); } client.send(buildMessage("init", { id: client.id, peerId: client.peerId, name: client.name, serverVersion: SERVER_VERSION, })); break; case "lobby_create": client.lobbyCreate(msg.data as Partial); break; case "lobby_leave": client.lobbyLeave(); break; case "request_lobby_list": client.lobbyList(); break; case "request_peer_list": client.peerList(); break; case "update_display_name": if (msg.data) client.name = msg.data.toString(); break; case "lobby_join": if (msg.data) { const lobby = allLobbies.get(msg.data.toString()); if (lobby) { client.lobbyJoin(lobby); } else { client.send(buildMessage("info", `count not find lobby ${msg.data}`)); } } break; case "update_lobby": if (client.lobby) { if (typeof (msg.data) === "object") { client.lobby.update(client, msg.data as Partial); } } else { client.send( buildMessage( "info", "failed to update lobby info: you are not in a lobby", ), ); } break; case "candidate": /* falls through */ case "answer": /* falls through */ case "offer": { if (!client.lobby) return; if (typeof msg.data !== "object") return; const webrtcMessage = (msg.data as { peerId?: number }); if (!webrtcMessage.peerId || typeof webrtcMessage.peerId !== "number") { return; } console.log( `Received WebRTC Negotiation Message (type: ${msg.type}, from: ${client.peerId}, to: ${webrtcMessage.peerId})...`, ); const destClient = client.lobby.getPeer(webrtcMessage.peerId); if (!destClient || !destClient.peerId) return; webrtcMessage.peerId = client.peerId as number; destClient.send(buildMessage(msg.type, webrtcMessage)); break; } default: console.debug("Unknown message: ", msg); break; } } function onSocketOpen(client: Client, _ev: Event) { console.log("New Client", client.id); } function onClientLeave(client: Client) { client.remove(); } function onSocketClose(client: Client, _ev: Event) { console.log("Client Socket Close"); onClientLeave(client); } function onSocketError(client: Client, _ev: Event) { console.log("Client Socket Error"); onClientLeave(client); } setInterval(() => { allLobbies.forEach((lobby) => { if (lobby.clients.size <= 0) lobby.remove(); }); allClients.forEach((client) => client.ping()); }, 5000); const PORT = parseInt(Deno.env.get("PORT") || "80"); console.log("Listening on port", PORT); const listener = Deno.listen({ port: PORT }); for await (const conn of listener) { (async () => { const server = Deno.serveHttp(conn); for await (const { respondWith, request } of server) { try { const { socket, response } = Deno.upgradeWebSocket(request); const client = new Client(socket); socket.onmessage = (ev) => onMessage(client, ev); socket.onopen = (ev) => onSocketOpen(client, ev); socket.onclose = (ev) => onSocketClose(client, ev); socket.onerror = (ev) => onSocketError(client, ev); respondWith(response); } catch (e) { console.log("Could not add client for unhandled reason:", e); respondWith( new Response("400 Bad Request", { status: 400, headers: { "content-type": "text/html" }, }), ); } } })(); }