Improve networking

This commit is contained in:
2026-09-25 00:08:01 -03:00
parent 6458bc0440
commit 2a2ccae9ce
18 changed files with 600 additions and 162 deletions
+50
View File
@@ -0,0 +1,50 @@
import type { ServerAddress, Welcome } from "./handshake.ts";
// asks before running a server's mods when the page came from somewhere else, see "Security" in MODS.md.
// resolves with whether the player wants to join
export function confirm_mods(address: ServerAddress, welcome: Welcome): Promise<boolean> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;" +
"background:rgba(0,0,0,0.85);color:white;font:16px system-ui,sans-serif;";
const panel = document.createElement("div");
panel.style.cssText = "max-width:480px;display:flex;flex-direction:column;gap:12px;";
const title = document.createElement("div");
title.style.cssText = "font-size:20px;";
title.textContent = `Join ${address.base.host}?`;
const warning = document.createElement("div");
warning.style.cssText = "opacity:0.8;";
warning.textContent = "This server runs these mods in your browser. They can do anything this page can, " +
"including reading what it saved. Only join servers you trust.";
const list = document.createElement("ul");
list.style.cssText = "margin:0;padding-left:20px;";
for (const mod of welcome.mods) {
const item = document.createElement("li");
item.textContent = `${mod.name} ${mod.version} (${mod.id})`;
list.append(item);
}
const buttons = document.createElement("div");
buttons.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
const button = (label: string, answer: boolean) => {
const element = document.createElement("button");
element.textContent = label;
element.style.cssText = "font:inherit;padding:6px 16px;cursor:pointer;";
element.addEventListener("click", () => {
overlay.remove();
resolve(answer);
});
return element;
};
buttons.append(button("Cancel", false), button("Join", true));
panel.append(title, warning, list, buttons);
overlay.append(panel);
document.body.append(overlay);
});
}
+191
View File
@@ -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
}
}
+39 -20
View File
@@ -1,8 +1,11 @@
import { AssetManager } from "./assets.ts";
import { ClientWorld } from "./client_world.ts";
import { InputManager } from "./input_manager.ts";
import { Connection, get_player_name, get_server_url } from "./network.ts";
import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
import { Connection, get_player_name } from "./network.ts";
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.ts";
import { confirm_mods } from "./confirm_mods.ts";
import { ModLoadError } from "$/common/mod_loader.ts";
import { begin_drawing, clear_background, end_drawing, init_font, init_window, load_texture } from "./renderer/mod.ts";
import { is_stopped, show_fatal_error } from "./fatal.ts";
import { load_client_mods, set_mods_world } from "./mods.ts";
@@ -76,9 +79,6 @@ InputManager.initialize(canvas);
AssetManager.instance.load("bworld:assets_text", "/assets/ASSETS.md");
AssetManager.instance.load("bworld:textures", "/assets/sprites/textures.png");
AssetManager.instance.load("bworld:textures_info", "/assets/sprites/textures.json");
AssetManager.instance.load("bworld:player", "/assets/sprites/player.png");
AssetManager.instance.load("bworld:roguelike", "/assets/sprites/roguelike.png");
AssetManager.instance.load("bworld:ui", "/assets/sprites/ui.png");
@@ -90,24 +90,37 @@ await AssetManager.instance.load_all();
init_font();
// the game only runs against a server, it owns the world and everything in it
const connection = await Connection.open(get_server_url(), get_player_name());
let mods_loaded = false;
if (connection) {
console.log(`Connected to ${get_server_url()}`);
// the game only runs against a server, it owns the world and everything in it. see "Delivery to clients" in MODS.md
async function join_server(): Promise<Connection> {
const address = server_address();
const { socket, welcome } = await connect(address, get_player_name());
console.log(`Connected to ${address.ws_url}`);
try {
// blocks, items and textures all come from mods, so this happens before anything else
await load_client_mods(connection.mods);
console.log(`Mods: ${connection.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
mods_loaded = true;
if (address.cross_origin && !is_trusted(address, welcome)) {
if (!await confirm_mods(address, welcome)) {
throw new HandshakeError(`You didn't join ${address.base.host}`);
}
remember_trust(address, welcome);
}
// textures, blocks and items all come from the server's mods, so this happens before anything else
const atlas = await load_atlas(address, welcome.atlas);
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
await load_client_mods(welcome.mods, address.base);
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
const joined = await join(socket);
return new Connection(socket, welcome, joined);
} catch (e) {
console.error(e);
show_fatal_error(`Couldn't load the server's mods: ${e instanceof Error ? e.message : e}`);
connection.socket.close();
socket.close();
throw e;
}
}
if (connection && mods_loaded) {
try {
const connection = await join_server();
const client_world = new ClientWorld(connection);
set_mods_world(client_world);
client_world.add_chat("Connected to the server");
@@ -116,6 +129,12 @@ if (connection && mods_loaded) {
loop.start();
console.log("Game started");
} else if (!connection) {
show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`);
} catch (e) {
console.error(e);
const message = e instanceof HandshakeError
? e.message
: e instanceof ModLoadError
? `Couldn't load the server's mods: ${e.message}`
: `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
show_fatal_error(message);
}
+26 -15
View File
@@ -8,6 +8,7 @@ import type { OreJson } from "$/common/mod_data.ts";
import { AIR_ID } from "$/common/protocol.ts";
import { Position } from "$/common/components/position.ts";
import type { ClientWorld } from "./client_world.ts";
import { code_url, fetch_verified } from "./handshake.ts";
// what the chunk workers need to generate the same world as the server
export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
@@ -19,26 +20,36 @@ export function set_mods_world(client_world: ClientWorld) {
world = client_world;
}
export async function load_client_mods(listings: ModListing[]) {
const site = new URL("/", location.href);
const datas = await Promise.all(listings.map(async (listing) => {
const response = await fetch(new URL(listing.data, site));
if (!response.ok) {
throw new ModLoadError(listing.id, `couldn't download its data (${response.status})`);
}
return { id: listing.id, data: await response.json() as ModData };
// downloads every mod's files and checks them against the hashes the server listed, then registers the data and
// runs the client scripts. everything a mod runs is imported from the checked bytes, never fetched twice
export async function load_client_mods(listings: ModListing[], base: URL) {
const downloads = await Promise.all(listings.map(async (listing) => {
const get = async (path: string | undefined, sha256: string | undefined, what: string) => {
if (!path) return undefined;
try {
return await fetch_verified(new URL(path, base), sha256 ?? "", what);
} catch (e) {
throw new ModLoadError(listing.id, (e as Error).message);
}
};
const [data, client, worldgen] = await Promise.all([
get(listing.data, listing.sha256.data, "its data"),
get(listing.client, listing.sha256.client, "its client script"),
get(listing.worldgen, listing.sha256.worldgen, "its worldgen script"),
]);
return { listing, data: JSON.parse(new TextDecoder().decode(data)) as ModData, client, worldgen };
}));
const recipes = register_mod_data(datas);
const recipes = register_mod_data(downloads.map(({ listing, data }) => ({ id: listing.id, data })));
worldgen_mods.ores = recipes.ores;
worldgen_mods.scripts = listings.flatMap((listing) =>
listing.worldgen ? [{ mod: listing.id, url: new URL(listing.worldgen, site).href }] : []
worldgen_mods.scripts = downloads.flatMap(({ listing, worldgen }) =>
worldgen ? [{ mod: listing.id, url: code_url(worldgen) }] : []
);
for (const listing of listings) {
if (!listing.client) continue;
const module = await import(new URL(listing.client, site).href);
for (const { listing, client } of downloads) {
if (!client) continue;
const module = await import(code_url(client));
if (typeof module.setup !== "function") {
throw new ModLoadError(listing.id, "the client script doesn't export a setup function");
}
+19 -65
View File
@@ -1,7 +1,6 @@
import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
import type { ModListing } from "$/common/mod_loader.ts";
const CONNECT_TIMEOUT_MS = 3000;
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
export interface RemotePlayer extends PlayerInfo {
// where we draw them, eased towards x/y/z so movement isnt choppy
@@ -12,7 +11,7 @@ export interface RemotePlayer extends PlayerInfo {
}
export class Connection {
socket: WebSocket;
#server: ServerSocket;
id: string;
name: string;
seed: string;
@@ -22,35 +21,31 @@ export class Connection {
selected_slot: number;
players = new Map<string, RemotePlayer>();
// handled by the network system inside the game loop, not whenever the socket feels like it
incoming: ServerMessage[] = [];
closed = false;
constructor(socket: WebSocket, welcome: Extract<ServerMessage, { type: "welcome" }>) {
this.socket = socket;
this.id = welcome.id;
this.name = welcome.name;
constructor(server: ServerSocket, welcome: Welcome, join: Join) {
this.#server = server;
this.id = join.id;
this.name = join.name;
this.seed = welcome.seed;
this.mods = welcome.mods;
this.initial_changes = welcome.changes;
this.spawn = welcome.spawn;
this.selected_slot = welcome.selected_slot;
for (const player of welcome.players) {
this.initial_changes = join.changes;
this.spawn = join.spawn;
this.selected_slot = join.selected_slot;
for (const player of join.players) {
this.add_player(player);
}
}
socket.addEventListener("message", (event) => {
this.incoming.push(JSON.parse(event.data));
});
socket.addEventListener("close", () => {
this.closed = true;
});
// handled by the network system inside the game loop, not whenever the socket feels like it
get incoming(): ServerMessage[] {
return this.#server.messages;
}
get closed() {
return this.#server.closed;
}
send(message: ClientMessage) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
}
this.#server.send(message);
}
add_player(player: PlayerInfo) {
@@ -62,40 +57,6 @@ export class Connection {
color: color_from_name(player.name),
});
}
static open(url: string, name: string): Promise<Connection | undefined> {
return new Promise((resolve) => {
let socket: WebSocket;
try {
socket = new WebSocket(url);
} catch {
resolve(undefined);
return;
}
const timeout = setTimeout(() => {
socket.close();
resolve(undefined);
}, CONNECT_TIMEOUT_MS);
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ type: "hello", name } satisfies ClientMessage));
});
socket.addEventListener("message", (event) => {
const message: ServerMessage = JSON.parse(event.data);
if (message.type === "welcome") {
clearTimeout(timeout);
resolve(new Connection(socket, message));
}
}, { once: true });
socket.addEventListener("error", () => {
clearTimeout(timeout);
resolve(undefined);
});
});
}
}
function color_from_name(name: string): [number, number, number] {
@@ -122,13 +83,6 @@ function color_from_name(name: string): [number, number, number] {
return [r + m, g + m, b + m];
}
export function get_server_url(): string {
const params = new URLSearchParams(location.search);
const server = params.get("server");
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${server ?? location.host}/ws`;
}
export function get_player_name(): string {
return new URLSearchParams(location.search).get("name") ?? "";
}
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from "./core.ts";
import { Texture } from "./types.ts";
export function load_texture(image: HTMLImageElement): Texture {
export function load_texture(image: HTMLImageElement | ImageBitmap): Texture {
const texture = create_texture(image.width, image.height);
device.queue.copyExternalImageToTexture({ source: image }, { texture }, [image.width, image.height]);