// 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< string, string | number | symbol | null | boolean >; type ServerData = ServerDataObject[] | ServerDataObject | string | boolean; type Message = string | DataMessage; const buildMessage = ( type: string, data: ServerData | ServerData[], ) => Object.assign( { type }, Array.isArray(data) ? { data } : (typeof data === "object" ? data : { data }), ); class Client { id: ID; peerId: number | null; name: string; socket: WebSocket; lobby: Lobby | null; ready: boolean; constructor(socket: WebSocket) { this.id = crypto.randomUUID(); this.socket = socket; this.peerId = null; this.name = "Client"; this.lobby = null; this.ready = false; allClients.set(this.id, this); } isConnected() { return this.socket.readyState == WebSocket.OPEN; } setReady(ready: boolean) { this.ready = ready; this.lobby?.broadcast( buildMessage("ready_change", { id: this.id, ready: this.ready }), ); } 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) return; const peers: { id: ID; name: string; peerId: number | null; ready: boolean; }[] = []; this.lobby.clients.forEach(({ id, name, peerId, ready }) => peers.push({ id, name, peerId, ready }) ); this.send(buildMessage("peer_joined", peers)); // TODO: chunk async? } lobbyList() { const netLobbies: { id: ID; name: string; maxPlayers: number; locked: boolean; currentPlayers: number; }[] = []; allLobbies.forEach((lobby) => { const { id, name, maxPlayers, locked } = lobby; netLobbies.push({ id, name, maxPlayers, locked, currentPlayers: lobby.clients.size, }); }); // TODO: chunk async? this.send(buildMessage("lobby_list", netLobbies)); } lobbyNew(lobby: Lobby) { // if the client is already in a lobby, they don't care about new lobbies if (this.lobby) return; const { id, name, maxPlayers, locked } = lobby; this.send( buildMessage("lobby_new", { id, name, maxPlayers, locked, currentPlayers: lobby.clients.size, }), ); } lobbyDelete({ id }: Lobby) { if (this.lobby) return; this.send(buildMessage("lobby_delete", { id })); } lobbyCreate(opts?: Partial) { if (this.lobby) { this.send( `[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(`[info] cannot join lobby (already in lobby ${this.lobby.id})`); return; } this.lobby = lobby; lobby.addClient(this); this.send( buildMessage("lobby_joined", { id: lobby.id, name: lobby.name, 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; 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; allLobbies.set(this.id, this); host.peerId = 1; host.lobbyJoin(this); this.notify(); } 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() { allClients.forEach((client) => client.lobbyNew(this)); } remove() { 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("your_peer_id", client.peerId.toString())); this.broadcast( buildMessage("peer_joined", [{ id: client.id, name: client.name, peerId: client.peerId, }]), ); console.log("Sending peer_joined..."); client.send( buildMessage( "peer_joined", 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(); } } interface ClientMessage { type: | "candidate" | "offer" | "answer" | "init" | "lobby_create" | "update_lobby"; data: ServerDataObject; } // events function onMessage(client: Client, ev: MessageEvent) { // TODO: log who from? const msg = ev.data.trim(); if (msg === "pong") return; console.log("Client Message Received", msg); if (msg === "init") { client.send( buildMessage("init", { id: client.id, name: client.name, serverVersion: SERVER_VERSION, }), ); } if (msg === "lobby_create") client.lobbyCreate(); if (msg.startsWith("set_ready:")) { client.setReady(JSON.parse(msg.split(":", 2)[1])); } if (msg === "lobby_leave") client.lobbyLeave(); if (msg === "request_lobby_list") client.lobbyList(); if (msg === "request_peer_list") { if (client.lobby == null) { client.send(`[info] not in a lobby`); } else { client.peerList(); } } if (msg.startsWith("update_display_name:")) { const displayName = msg.substr("update_display_name:".indexOf(":") + 1); client.name = displayName; } if (msg.startsWith("lobby_join:")) { const id = msg.substr(11); const lobby = allLobbies.get(id); if (lobby) { client.lobbyJoin(lobby); // client.peerList(); } else client.send(`[info] could not find lobby ${id}`); } if (msg.startsWith("json:")) { const data: ClientMessage = JSON.parse(msg.substr(5)); if (data.type === "init") { if (data.data.name) { client.name = data.data.name.toString(); } client.send(buildMessage("init", { id: client.id, name: client.name })); } else if (data.type === "update_lobby") { if (client.lobby) { client.lobby.update(client, data["data"]); } else { client.send("[info] cannot update lobby (you're not even in one!)"); } } else if (data.type === "lobby_create") { client.lobbyCreate({ name: data.data?.name?.toString() }); if (data.data.name) { client.name = data.data.name.toString(); } client.send(buildMessage("init", { id: client.id, name: client.name })); } else if (["candidate", "answer", "offer"].includes(data.type)) { console.log("Received WebRTC Negotiation Message..."); if (typeof data.data === "object") { const subdata = data.data; if (typeof subdata["peerId"] === "number") { const destPeerId: number = subdata["peerId"]; for (const iClient of client.lobby?.clients.values() || []) { if (iClient.peerId == destPeerId) { const payload = Object.assign({}, data); const srcPeerId = client.peerId; payload.data.peerId = srcPeerId; console.log( `Forwarding WebRTC Negotiation Message from peer ${srcPeerId} to peer ${destPeerId}...`, ); iClient.send(payload); 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 Close"); onClientLeave(client); } function onSocketError(client: Client, _ev: Event) { console.log("Client 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) { // console.debug("Connection received:", conn); (async () => { const server = Deno.serveHttp(conn); for await (const { respondWith, request } of server) { // console.debug("HTTP Request Received", request); try { // console.warn(JSON.stringify([allClients, allLobbies])); 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" }, }), ); } } })(); }