// connecting to a server and getting what it needs before joining, see "Delivery to clients" in MODS.md: // hello -> welcome, download and check everything, ready -> join import type { AtlasListing } from "$/common/mod_loader.ts"; import { ClientMessage, PROTOCOL_VERSION, ServerMessage } from "$/common/protocol.ts"; const CONNECT_TIMEOUT_MS = 5000; const TRUST_KEY_PREFIX = "bworld:trusted:"; export type Welcome = Extract; export type Join = Extract; export interface ServerAddress { ws_url: string; // where the server's files are, mod paths are relative to this base: URL; // the page came from somewhere else than the server, so its mods need the player's ok cross_origin: boolean; } // something went wrong in a way the player should see export class HandshakeError extends Error {} // what the server field starts as: ?server=host:port, otherwise the server that served the page export function default_server(page = new URL(location.href)): string { return page.searchParams.get("server") ?? page.host; } // what a player typed as the server: host:port, or a full http(s) or ws(s) url export function server_address(input: string, page = new URL(location.href)): ServerAddress { const text = input.trim(); let host = text; // without a scheme it's as secure as the page, browsers block insecure sockets from secure pages anyway let secure = page.protocol === "https:"; if (text.includes("://")) { let url: URL; try { url = new URL(text); } catch { throw new HandshakeError(`${text} isn't a server address`); } host = url.host; secure = url.protocol === "https:" || url.protocol === "wss:"; } if (!host || /[\s/?#]/.test(host)) { throw new HandshakeError(`${text || "An empty address"} isn't a server address`); } const base = new URL(`${secure ? "https" : "http"}://${host}/`); return { ws_url: `${secure ? "wss" : "ws"}://${host}/ws`, base, cross_origin: base.origin !== page.origin, }; } // a socket whose messages all land in one queue, so none get lost between the handshake and the game export class ServerSocket { socket: WebSocket; messages: ServerMessage[] = []; closed = false; #waiters: (() => void)[] = []; constructor(socket: WebSocket) { this.socket = socket; socket.addEventListener("message", (event) => { this.messages.push(JSON.parse(event.data)); this.#wake(); }); socket.addEventListener("close", () => { this.closed = true; this.#wake(); }); } send(message: ClientMessage) { if (this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); } } close() { this.socket.close(); } // takes the first message of one of these types out of the queue, waiting for it if needed async next(...types: T[]): Promise> { while (true) { const index = this.messages.findIndex((m) => (types as string[]).includes(m.type)); if (index !== -1) { return this.messages.splice(index, 1)[0] as Extract; } if (this.closed) { throw new HandshakeError("The server closed the connection"); } await new Promise((resolve) => this.#waiters.push(resolve)); } } #wake() { for (const waiter of this.#waiters.splice(0)) waiter(); } } export async function connect( address: ServerAddress, name: string, ): Promise<{ socket: ServerSocket; welcome: Welcome }> { const socket = await new Promise((resolve, reject) => { let ws: WebSocket; try { ws = new WebSocket(address.ws_url); } catch { reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`)); return; } const timeout = setTimeout(() => { ws.close(); reject(new HandshakeError(`The server at ${address.ws_url} didn't answer`)); }, CONNECT_TIMEOUT_MS); ws.addEventListener("open", () => { clearTimeout(timeout); resolve(ws); }); ws.addEventListener("error", () => { clearTimeout(timeout); reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`)); }); }); const server = new ServerSocket(socket); server.send({ type: "hello", name, protocol: PROTOCOL_VERSION }); const answer = await server.next("welcome", "rejected"); if (answer.type === "rejected") { server.close(); throw new HandshakeError(answer.reason); } return { socket: server, welcome: answer }; } // tell the server everything's loaded and wait to be let in export async function join(socket: ServerSocket): Promise { socket.send({ type: "ready" }); const answer = await socket.next("join", "rejected"); if (answer.type === "rejected") { socket.close(); throw new HandshakeError(answer.reason); } return answer; } // downloads a file and checks it's the one the server listed. use the bytes returned, never fetch it again export async function fetch_verified(url: URL, sha256: string, what: string): Promise> { let response: Response; try { response = await fetch(url); } catch { throw new HandshakeError(`Couldn't download ${what} from ${url}`); } if (!response.ok) { throw new HandshakeError(`Couldn't download ${what} (${response.status})`); } const bytes = new Uint8Array(await response.arrayBuffer()); const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))] .map((b) => b.toString(16).padStart(2, "0")) .join(""); if (digest !== sha256) { throw new HandshakeError(`${what} doesn't match what the server listed`); } return bytes; } // a url for code that was already downloaded and checked, so importing it can't fetch something else export function code_url(bytes: Uint8Array): string { return URL.createObjectURL(new Blob([bytes], { type: "text/javascript" })); } export async function load_atlas(address: ServerAddress, atlas: AtlasListing) { const [png, json] = await Promise.all([ fetch_verified(new URL(atlas.png, address.base), atlas.sha256.png, "the texture atlas"), fetch_verified(new URL(atlas.json, address.base), atlas.sha256.json, "the texture atlas"), ]); return { image: await createImageBitmap(new Blob([png], { type: "image/png" })), regions: JSON.parse(new TextDecoder().decode(json)) as Record, }; } // players ok a cross-origin server's mods once, per server and exact mod versions function trust_key(address: ServerAddress) { return TRUST_KEY_PREFIX + address.base.origin; } function mod_versions(welcome: Welcome) { return welcome.mods.map((mod) => `${mod.id}@${mod.hash}`).sort().join(","); } export function is_trusted(address: ServerAddress, welcome: Welcome): boolean { try { return localStorage.getItem(trust_key(address)) === mod_versions(welcome); } catch { return false; } } export function remember_trust(address: ServerAddress, welcome: Welcome) { try { localStorage.setItem(trust_key(address), mod_versions(welcome)); } catch { // private windows and blocked storage just ask again next time } }