// import { randomInt } from "./deps.ts"; const SERVER_VERSION = "1.0.0"; // TODO: version comparison type ID = number; type UUID = 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 | number; 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: number | null; uuid: string; name: string; socket: WebSocket; lobby: Lobby | null; constructor(socket: WebSocket) { this.id = null; this.uuid = crypto.randomUUID(); this.socket = socket; this.name = "Client"; this.lobby = null; allClients.set(this.uuid, this); } isConnected() { return this.socket.readyState == WebSocket.OPEN; } remove() { console.debug(`Removing Client ${this.uuid}`); this.lobbyLeave(); if (this.isConnected()) { this.socket.close(); } allClients.delete(this.uuid); } send(message: Message) { try { if (this.isConnected()) { this.socket.send( typeof message === "object" ? (JSON.stringify(message)) : message, ); } // TODO: maybe log when we try to send and we're not connected? } 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; }[] = []; this.lobby.clients.forEach(({ id, name }) => { if (typeof id === "number") peers.push({ id, name }); }); console.debug(`Sending peer list with ${peers.length} peers...`); this.send(buildMessage("peer_data", peers)); // TODO: chunk async? } lobbyList() { const netLobbies: { uuid: UUID; 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({ uuid }: Lobby) { if (this.lobby) return; this.send(buildMessage("lobby_delete", { uuid })); } lobbyCreate(opts?: Partial) { if (this.lobby) { this.send( buildMessage( "info", `cannot create lobby: already in lobby ${this.lobby.uuid}`, ), ); 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.uuid}`, ), ); return; } this.lobby = lobby; lobby.addClient(this); this.send( buildMessage("lobby_joined", { ...this.lobby.toData(), id: this.id, }), ); } lobbyLeave() { const leavingLobby = this.lobby; if (!leavingLobby) { this.send(`[info] cannot leave lobby (not in a lobby)`); return; } console.debug(`Client ${this.uuid} leaving Lobby ${this.lobby?.uuid}`); leavingLobby.removeClient(this); this.id = null; this.lobby = null; this.send(buildMessage("lobby_left", { uuid: leavingLobby.uuid })); } } class Lobby { uuid: UUID; name: string; clients: Map; hostClientUuid: UUID; maxPlayers: number; locked: boolean; _shouldNotify: boolean; constructor(host: Client, name?: string, maxPlayers = 20) { this.uuid = crypto.randomUUID(); this.hostClientUuid = host.uuid; this.clients = new Map(); this.name = name || this.uuid; this.maxPlayers = maxPlayers; this.locked = false; this._shouldNotify = true; console.debug( `Lobby ${this.uuid} created by Client ${this.hostClientUuid}`, ); allLobbies.set(this.uuid, this); host.id = 1; host.lobbyJoin(this); this.notify(); } toData() { return { uuid: this.uuid, 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.uuid === this.hostClientUuid) { 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; console.debug(`Removing Lobby ${this.uuid}`); allLobbies.delete(this.uuid); 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.id) { const arr = new Int32Array(1); crypto.getRandomValues(arr); client.id = Math.abs(arr[0]); } console.debug( `Adding Client ${client.uuid} Lobby ${this.uuid} as peer ${client.id}`, ); client.send(buildMessage("init_peer", client.id)); this.broadcast( buildMessage("peer_data", [{ id: client.id, name: client.name, }]), ); client.send( buildMessage( "peer_data", Array.from(this.clients.values()).map( ({ id, name }) => ({ id, name }), ), ), ); this.clients.set(client.id, client); this.notify(); } removeClient({ id, uuid }: Client) { if (typeof id !== "number") return; this.clients.delete(id); this.broadcast(buildMessage("peer_left", { id })); console.debug(`Removing Client ${this.uuid} from Lobby ${this.uuid}`); if (uuid === this.hostClientUuid) { console.warn(`Host left, removing Lobby ${this.uuid}`); this.remove(); } this.notify(); } getPeer(id: number): Client | null { for (const [_id, client] of this.clients) { if (client.id == id) 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("{")) { const { type, ...data } = JSON.parse(trimmedMessage); return { type, data }; } else { const splitAt = trimmedMessage.indexOf(":"); if (splitAt > 0) { return { type: trimmedMessage.substring(0, splitAt), data: trimmedMessage.substring(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.debug("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, 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 { id?: number }); if (!webrtcMessage.id || typeof webrtcMessage.id !== "number") { return; } console.log( `Received WebRTC Negotiation Message (type: ${msg.type}, from: ${client.id}, to: ${webrtcMessage.id})...`, ); const destClient = client.lobby.getPeer(webrtcMessage.id); if (!destClient || !destClient.id) return; webrtcMessage.id = client.id 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.uuid); } 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" }, }), ); } } })(); }