212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
// 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 {}
|
|
|
|
// 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<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
|
|
}
|
|
}
|