import { randomInt } from "./deps.ts"; const SERVER_VERSION = "0.1.0"; // TODO: version comparison type ID = string; // app state const allLobbies = new Map(); const allClients = new Map(); interface DataMessage { type: string; } type ServerData = Record | string; type Message = string | DataMessage; const broadcast = (message: Message) => allClients.forEach((client) => client.send(message)); const buildMessage = ( type: string, data: ServerData | ServerData[], ) => Object.assign( { type }, Array.isArray(data) ? { data } : (typeof data === "object" ? data : { data }), ); class Client { id: ID; name: string; socket: WebSocket; lobby: Lobby | null; constructor(socket: WebSocket) { this.id = crypto.randomUUID(); this.socket = socket; this.name = "Client"; this.lobby = null; this.send(buildMessage("your-id", this.id)); console.log(this); 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}. Disconnecting and removing...`, ); } } clientList() { if (!this.lobby) return; const netClients: { id: ID; name: string }[] = []; this.lobby.clients.forEach(({ id, name }) => netClients.push({ id, name })); // TODO: chunk async? this.send(buildMessage("peer_list", netClients)); } lobbyList() { const netLobbies: { id: ID; name: string }[] = []; allLobbies.forEach(({ id, name }) => netLobbies.push({ id, name })); // TODO: chunk async? this.send(buildMessage("lobby_list", netLobbies)); } lobbyNew({ id, name }: Lobby) { // if the client is already in a lobby, we don't care about new lobbies if (this.lobby) return; this.send(buildMessage("lobby_new", { id, name })); } lobbyDelete({ id }: Lobby) { if (this.lobby) return; this.send(buildMessage("lobby_delete", { id })); } lobbyCreate() { if (this.lobby) { this.send( `[info] cannot create lobby (already in lobby ${this.lobby.id})`, ); return; } new Lobby(this); } lobbyJoin(lobby: Lobby) { if (this.lobby) { this.send(`[info] cannot join lobby (already in lobby ${this.lobby.id})`); return; } this.lobby = lobby; this.send(buildMessage("lobby_joined", { id: lobby.id })); lobby.addClient(this); } lobbyLeave() { const leavingLobby = this.lobby; if (!leavingLobby) { this.send(`[info] cannot leave lobby (not in a lobby)`); return; } 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; constructor(host: Client, name?: string) { this.id = crypto.randomUUID(); this.hostClientId = host.id; this.clients = new Map(); this.name = name || this.id; allLobbies.set(this.id, this); host.lobbyJoin(this); allClients.forEach((client) => client.lobbyNew(this)); } remove() { allClients.forEach((client) => client.lobbyDelete(this)); this.clients.forEach((client) => { client.lobbyLeave(); }); allLobbies.delete(this.id); } broadcast(message: Message) { this.clients.forEach((client) => client.send(message)); } addClient(client: Client) { this.clients.set(client.id, client); this.broadcast( buildMessage("peer_joined", { id: client.id, name: client.name }), ); } removeClient({ id }: Client) { this.clients.delete(id); this.broadcast(buildMessage("peer_left", { id })); if (id === this.hostClientId) { console.warn("Host left!"); this.remove(); } } } // events function onMessage(client: Client, ev: MessageEvent) { // TODO: log who from? console.log("Client Message Received", ev.data); if (ev.data === "lobby_create") client.lobbyCreate(); if (ev.data === "lobby_leave") client.lobbyLeave(); if (ev.data === "request_lobby_list") client.lobbyList(); if (ev.data === "request_peer_list") client.clientList(); if (ev.data.startsWith("lobby_join:")) { const id = ev.data.substr(11); const lobby = allLobbies.get(id); if (lobby) client.lobbyJoin(lobby); else client.send(`[info] could not find lobby ${id}`); } } function onSocketOpen(_client: Client, _ev: Event) { console.log("New Client"); } 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); } 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" }, }), ); } } })(); } /* function peerCreatesLobby(peer: Peer, lobbyName: string) { // TODO: ensure we can create a lobby lobbies.add({ }) } */ /* function peerJoinsLobby(peer: Peer, lobby: Lobby) { lobbyName = randomSecret(); lobbies.set(lobbyName, new Lobby(lobbyName, this.id)); console.log(`Peer ${this.id} created lobby ${lobbyName}`); console.log(`Open lobbies: ${lobbies.size}`); } const lobby = lobbies.get(lobbyName); if (!lobby) throw new ProtoError(4000, STR_LOBBY_DOES_NOT_EXIST); if (lobby.sealed) throw new ProtoError(4000, STR_LOBBY_IS_SEALED); this.lobby = lobbyName; console.log( `Peer ${this.id} joining lobby ${lobbyName} ` + `with ${lobby.peers.length} peers`, ); lobby.join(this); this.ws.send(`J: ${lobbyName}\n`); } // TODO: ensure peer not already in a lobby const assigned = this.getPeerId(peer); peer.ws.send(`I: ${assigned}\n`); this.peers.forEach((p) => { p.ws.send(`N: ${assigned}\n`); peer.ws.send(`N: ${this.getPeerId(p)}\n`); }); this.peers.push(peer); } */ /* function peerLeavesLobby(peer: Peer) { const idx = this.peers.findIndex((p) => peer === p); if (idx === -1) return false; const assigned = this.getPeerId(peer); const close = assigned === 1; this.peers.forEach((p) => { try { // room host disconnected if (close) p.ws.close(4000, STR_HOST_DISCONNECTED); // notify peers else p.ws.send(`D: ${assigned}\n`); } catch (e) { console.error(`Error when leaving: ${e}`); } }); this.peers.splice(idx, 1); if (close && this.closeTimer >= 0) { // we are closing already. clearTimeout(this.closeTimer); this.closeTimer = -1; } return close; } */ /* function graduateLobby(lobby: Lobby) { // only host can seal if (peer.id !== this.host) { throw new ProtoError(4000, STR_ONLY_HOST_CAN_SEAL); } this.sealed = true; this.peers.forEach((p) => { p.ws.send("S: \n"); }); console.log( `Peer ${peer.id} sealed lobby ${this.name} ` + `with ${this.peers.length} peers`, ); this.closeTimer = setTimeout(() => { // close peer connection to host (and thus the lobby) this.peers.forEach((p) => { p.ws.close(1000, STR_SEAL_COMPLETE); }); }, SEAL_CLOSE_TIMEOUT); } } */ /* function parseMsg(peer: Peer, msg: string) { // TODO: modify this? // O: Client is sending an offer. // A: Client is sending an answer. // C: Client is sending a candidate. let destId = parseInt(cmd.substr(3).trim()); // Dest is not an ID. if (!destId) throw new ProtoError(4000, STR_INVALID_DEST); if (destId === 1) destId = lobby.host; const dest = lobby.peers.find((p: Peer) => p.id === destId); // Dest is not in this room. if (!dest) throw new ProtoError(4000, STR_INVALID_DEST); function isCmd(what: string) { return cmd.startsWith(`${what}: `); } if (isCmd("O") || isCmd("A") || isCmd("C")) { dest.ws.send(cmd[0] + ": " + lobby.getPeerId(peer) + data); return; } throw new ProtoError(4000, STR_INVALID_CMD); } */