Improve networking
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
// 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<ServerMessage, { type: "welcome" }>;
|
||||
export type Join = Extract<ServerMessage, { type: "join" }>;
|
||||
|
||||
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 {}
|
||||
|
||||
export function server_address(page = new URL(location.href)): ServerAddress {
|
||||
// ?server=host:port, otherwise the server that served the page
|
||||
const host = page.searchParams.get("server") ?? page.host;
|
||||
const secure = page.protocol === "https:";
|
||||
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<T extends ServerMessage["type"]>(...types: T[]): Promise<Extract<ServerMessage, { type: T }>> {
|
||||
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<ServerMessage, { type: T }>;
|
||||
}
|
||||
if (this.closed) {
|
||||
throw new HandshakeError("The server closed the connection");
|
||||
}
|
||||
await new Promise<void>((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<WebSocket>((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<Join> {
|
||||
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<Uint8Array<ArrayBuffer>> {
|
||||
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<ArrayBuffer>): 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<string, { x: number; y: number }>,
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user