From 86d5c39eabd9f54f44e5fd046d50bc165577c9ff Mon Sep 17 00:00:00 2001 From: Paula Date: Sat, 26 Sep 2026 13:25:34 -0300 Subject: [PATCH] Deno desktop stuff --- .gitignore | 1 + client/gui/title_screen.ts | 55 ++++++++-- client/handshake.ts | 22 +++- deno.json | 13 ++- desktop/config.json | 3 + desktop/linux_launcher.ts | 45 ++++++++ desktop/main.ts | 86 +++++++++++++++ server/host.ts | 195 +++++++++++++++++++++++++++++++++++ server/main.ts | 182 +++++--------------------------- tests/server_address_test.ts | 25 +++++ 10 files changed, 457 insertions(+), 170 deletions(-) create mode 100644 desktop/config.json create mode 100644 desktop/linux_launcher.ts create mode 100644 desktop/main.ts create mode 100644 server/host.ts create mode 100644 tests/server_address_test.ts diff --git a/.gitignore b/.gitignore index aea4f6b..2dda3bd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ build/ world.json world.json.tmp server_mods/ +dist/ diff --git a/client/gui/title_screen.ts b/client/gui/title_screen.ts index 649113f..9c06605 100644 --- a/client/gui/title_screen.ts +++ b/client/gui/title_screen.ts @@ -20,8 +20,8 @@ const TITLE_SCALE = 8; // for the player when it fails export type JoinServer = (address: ServerAddress, name: string, status: (text: string) => void) => Promise; -// the first thing players see, like minecraft's title and multiplayer screens rolled into one: -// a name, a server, and a button to join it +// the first thing players see, like minecraft's title and multiplayer screens rolled into one: a name, a server, +// and a button to join it. in the desktop app there's also singleplayer, which joins the app's own server export class TitleScreen extends GuiScreen { #join_server: JoinServer; @@ -33,6 +33,10 @@ export class TitleScreen extends GuiScreen { ); server = new EditBox(WIDTH, ROW_HEIGHT, new TextInput(initial_server(), 256, (char) => char !== " "), "host:port"); join_button = new Button("Join server", WIDTH, ROW_HEIGHT, () => this.#join()); + // the page was served by a game server of its own to play on alone, see desktop/main.ts + singleplayer_button = has_singleplayer() + ? new Button("Singleplayer", WIDTH, ROW_HEIGHT, () => this.#join_singleplayer()) + : undefined; credits_button = new Button("Credits", WIDTH, ROW_HEIGHT, () => this.#open_credits()); // open over the title screen, it gets the input while it's there #credits: CreditsScreen | undefined; @@ -75,6 +79,7 @@ export class TitleScreen extends GuiScreen { if (InputManager.is_key_pressed("Enter")) { this.#join(); } + this.singleplayer_button?.handle_input(); this.join_button.handle_input(); this.credits_button.handle_input(); } @@ -92,6 +97,7 @@ export class TitleScreen extends GuiScreen { this.#label("Name", this.name); this.#label("Server", this.server); this.name.render(); + this.singleplayer_button?.render(); this.server.render(); this.join_button.render(); this.credits_button.render(); @@ -121,12 +127,18 @@ export class TitleScreen extends GuiScreen { #layout() { const x = (canvas.width - WIDTH) / 2; - const total = 2 * (LABEL_GAP + ROW_HEIGHT + GAP) + 2 * ROW_HEIGHT + GAP; + const singleplayer_height = this.singleplayer_button ? ROW_HEIGHT + GAP : 0; + const total = 2 * (LABEL_GAP + ROW_HEIGHT + GAP) + singleplayer_height + 2 * ROW_HEIGHT + GAP; let y = (canvas.height - total) / 2 + 40; for (const field of [this.name, this.server]) { field.x = x; field.y = y + LABEL_GAP; y += LABEL_GAP + ROW_HEIGHT + GAP; + if (field === this.name && this.singleplayer_button) { + this.singleplayer_button.x = x; + this.singleplayer_button.y = y; + y += singleplayer_height; + } } this.join_button.x = x; this.join_button.y = y; @@ -151,10 +163,21 @@ export class TitleScreen extends GuiScreen { return; } remember(LAST_SERVER_KEY, this.server.value.trim()); - remember(LAST_NAME_KEY, this.name.value); + await this.#connect(address); + } + // the server that served this page + async #join_singleplayer() { + if (this.#joining) { + return; + } + await this.#connect(server_address(location.host), "Starting singleplayer..."); + } + + async #connect(address: ServerAddress, status = `Connecting to ${address.base.host}...`) { + remember(LAST_NAME_KEY, this.name.value); this.#set_joining(true); - this.status = `Connecting to ${address.base.host}...`; + this.status = status; this.status_is_error = false; try { await this.#join_server(address, this.name.value, (text) => this.status = text); @@ -167,6 +190,9 @@ export class TitleScreen extends GuiScreen { #set_joining(joining: boolean) { this.#joining = joining; this.name.active = this.server.active = this.join_button.active = !joining; + if (this.singleplayer_button) { + this.singleplayer_button.active = !joining; + } } #show_error(message: string) { @@ -202,8 +228,19 @@ function remember(key: string, value: string) { } } -// a server's mods can't be unloaded, so going back to the title screen starts the page over. without ?server= -// or ?name=, so the fields show what was used last -export function back_to_title() { - location.href = location.pathname; +function has_singleplayer() { + return new URL(location.href).searchParams.has("singleplayer"); +} + +// a server's mods can't be unloaded, so going back to the title screen starts the page over. without ?server= +// or ?name=, so the fields show what was used last. the desktop app keeps its ?singleplayer and ?server=, its saved +// fields don't last between launches and the page's own address isn't a server to join +export function back_to_title() { + const page = new URL(location.href); + const next = new URL(location.pathname, location.href); + if (has_singleplayer()) { + next.searchParams.set("server", page.searchParams.get("server") ?? ""); + next.searchParams.set("singleplayer", ""); + } + location.href = next.href; } diff --git a/client/handshake.ts b/client/handshake.ts index e8c48e0..a58112b 100644 --- a/client/handshake.ts +++ b/client/handshake.ts @@ -25,12 +25,11 @@ 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 +// what a player typed as the server: host:port, or a full http(s) or ws(s) url to pick the scheme 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:"; + let secure: boolean; if (text.includes("://")) { let url: URL; try { @@ -40,6 +39,10 @@ export function server_address(input: string, page = new URL(location.href)): Se } host = url.host; secure = url.protocol === "https:" || url.protocol === "wss:"; + } else { + // servers on this machine are plain ws and everything else is wss, whatever the page was served over. the + // desktop app's page is plain http on localhost, but the servers it joins are real domains behind tls + secure = !is_local_host(host); } if (!host || /[\s/?#]/.test(host)) { throw new HandshakeError(`${text || "An empty address"} isn't a server address`); @@ -52,6 +55,19 @@ export function server_address(input: string, page = new URL(location.href)): Se }; } +const LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "0.0.0.0"]; + +// host is host:port, or just a host +function is_local_host(host: string) { + let hostname: string; + try { + hostname = new URL(`http://${host}/`).hostname; + } catch { + return false; + } + return LOCAL_HOSTS.includes(hostname); +} + // a socket whose messages all land in one queue, so none get lost between the handshake and the game export class ServerSocket { socket: WebSocket; diff --git a/deno.json b/deno.json index 1235e7d..8070a7f 100644 --- a/deno.json +++ b/deno.json @@ -5,17 +5,19 @@ "server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts", "new-mod": "deno run --allow-read --allow-write tools/new_mod.ts", "check-mods": "deno run --allow-read --allow-run tools/check_mods.ts", - "test": "deno test --allow-read --allow-write --allow-run tests/" + "test": "deno test --allow-read --allow-write --allow-run tests/", + "desktop": "deno run -A build.ts --once && deno desktop --allow-read --allow-write --allow-net --allow-env=BWORLD_SERVER,HOME,USERPROFILE,APPDATA,XDG_DATA_HOME --include build --include server_mods --include server/game/worker.ts --include desktop/config.json desktop/main.ts && deno run --allow-read --allow-write desktop/linux_launcher.ts dist/bworld" }, "compilerOptions": { "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"] }, - "unstable": ["bundle", "raw-imports"], + "unstable": ["bundle", "raw-imports", "worker-options"], "fmt": { "useTabs": true, "indentWidth": 4, "lineWidth": 120, - "newLineKind": "lf" + "newLineKind": "lf", + "exclude": ["desktop/*.md"] }, "imports": { "$/": "./", @@ -30,5 +32,10 @@ "@std/path": "jsr:@std/path@^1.0.0", "gl-matrix": "npm:gl-matrix@^3.4.4", "marked": "npm:marked@^17.0.3" + }, + "desktop": { + "app": { "name": "bworld", "identifier": "com.bworld.game" }, + "backend": "cef", + "output": { "macos": "./dist/bworld.app", "windows": "./dist/bworld", "linux": "./dist/bworld" } } } diff --git a/desktop/config.json b/desktop/config.json new file mode 100644 index 0000000..c0e79b1 --- /dev/null +++ b/desktop/config.json @@ -0,0 +1,3 @@ +{ + "server": "bworld.moder.fans" +} diff --git a/desktop/linux_launcher.ts b/desktop/linux_launcher.ts new file mode 100644 index 0000000..24b2893 --- /dev/null +++ b/desktop/linux_launcher.ts @@ -0,0 +1,45 @@ +// deno run --allow-read --allow-write desktop/linux_launcher.ts dist/bworld +// deno desktop has no setting for chromium's command line, but its linux launcher passes its arguments on to chromium. +// so this moves the launcher to bworld-bin and puts a script in its place that starts it with the switches webgpu +// needs on some linux setups. run it on the app folder after deno desktop builds it; running it again does nothing +const SWITCHES = [ + "--enable-unsafe-webgpu", + "--ozone-platform=x11", + "--use-angle=vulkan", + "--enable-features=Vulkan,VulkanFromANGLE", +]; + +const dir = Deno.args[0]; +if (!dir) { + console.error("usage: linux_launcher.ts , like dist/bworld"); + Deno.exit(1); +} +const name = dir.replace(/\/+$/, "").split("/").pop()!; +const launcher = `${dir}/${name}`; +const binary = `${launcher}-bin`; + +// already wrapped: the launcher is a script now +const head = new Uint8Array(4); +const file = Deno.openSync(launcher); +file.readSync(head); +file.close(); +const is_elf = head[0] === 0x7f && head[1] === 0x45 && head[2] === 0x4c && head[3] === 0x46; +if (!is_elf) { + console.log(`${launcher} is already wrapped`); + Deno.exit(0); +} + +Deno.renameSync(launcher, binary); +Deno.writeTextFileSync( + launcher, + `#!/bin/sh +# starts ${name} with the chromium switches webgpu needs on some linux setups, see desktop/linux_launcher.ts. +# BWORLD_CHROMIUM_FLAGS replaces them, set it to "" to start without any +here="$(dirname "$(readlink -f "$0")")" +# the binary finds its runtime by its own name, which is ${name}.so and not ${name}-bin.so +export LAUFEY_RUNTIME_PATH="$here/${name}.so" +exec "$here/${name}-bin" \${BWORLD_CHROMIUM_FLAGS-${SWITCHES.join(" ")}} "$@" +`, +); +Deno.chmodSync(launcher, 0o755); +console.log(`Wrapped ${launcher}: ${SWITCHES.join(" ")}`); diff --git a/desktop/main.ts b/desktop/main.ts new file mode 100644 index 0000000..7cb960d --- /dev/null +++ b/desktop/main.ts @@ -0,0 +1,86 @@ +/// + +// the desktop app: the web client in a bundled chromium (deno desktop's cef backend), so people whose browser has no +// webgpu (firefox) can still play. it joins servers like the browser does, and runs its own game server for +// singleplayer, like minecraft java. build it with deno task desktop, see the desktop/ docs for how deno desktop works +import { fromFileUrl, join } from "@std/path"; +import { type Host, serve_static, start_host } from "../server/host.ts"; + +// where the project was built from: build/ and server_mods/ are bundled into the app with --include +const ROOT = fromFileUrl(new URL("../", import.meta.url)); +const BUILD_DIR = join(ROOT, "build"); + +// the server the address field starts with. "" leaves it empty for the player to fill in +const config: { server?: string } = JSON.parse( + await Deno.readTextFile(new URL("./config.json", import.meta.url)), +); +const default_server = Deno.env.get("BWORLD_SERVER") ?? config.server ?? ""; + +const win = new Deno.BrowserWindow({ title: "bworld", width: 1280, height: 720 }); + +// the singleplayer game server, started the first time singleplayer connects so players who only join other servers +// don't get a world made for them +let host: Host | undefined; + +function singleplayer_host(): Host { + if (!host) { + const worlds = join(data_dir(), "worlds"); + Deno.mkdirSync(worlds, { recursive: true }); + host = start_host({ + build_dir: BUILD_DIR, + server_mods_dir: join(ROOT, "server_mods"), + root: ROOT, + world_file: join(worlds, "world.json"), + on_fatal(message) { + console.error(message); + alert(`The singleplayer world stopped: ${message}`); + Deno.exit(1); + }, + }); + } + return host; +} + +// the world gets saved before the app closes +win.addEventListener("close", async (event) => { + if (!host) { + return; + } + event.preventDefault(); + try { + await host.shutdown(); + } catch (e) { + console.error((e as Error).message); + } + Deno.exit(0); +}); + +// deno desktop binds this to a private local address and opens the window on it, the port given here is ignored +Deno.serve((req) => { + const url = new URL(req.url); + if (url.pathname === "/") { + // ?server= fills in the address field, without it the field would start with this app's own address. + // ?singleplayer shows the singleplayer button, which joins this app's own server + const start = new URL("/client/", url); + start.searchParams.set("server", default_server); + start.searchParams.set("singleplayer", ""); + return Response.redirect(start, 302); + } + if (url.pathname === "/ws") { + return singleplayer_host().handle(req); + } + return serve_static(BUILD_DIR, req, url); +}); + +// where the app keeps its worlds, the usual place for app data on each os +function data_dir() { + const home = Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE") ?? "."; + switch (Deno.build.os) { + case "windows": + return join(Deno.env.get("APPDATA") ?? join(home, "AppData", "Roaming"), "bworld"); + case "darwin": + return join(home, "Library", "Application Support", "bworld"); + default: + return join(Deno.env.get("XDG_DATA_HOME") ?? join(home, ".local", "share"), "bworld"); + } +} diff --git a/server/host.ts b/server/host.ts new file mode 100644 index 0000000..4ce9552 --- /dev/null +++ b/server/host.ts @@ -0,0 +1,195 @@ +// the part of a game server that has permissions: files and networking. the game itself runs in a worker with none. +// server/main.ts runs it as a dedicated server, and the desktop app runs it for singleplayer +import { serveDir } from "@std/http/file-server"; +import { isAbsolute, join } from "@std/path"; +import { GameToHost, HostToGame } from "./game/host_protocol.ts"; +import type { ServerModIndex } from "../build.ts"; + +const MAX_MESSAGE_SIZE = 4096; +const SHUTDOWN_TIMEOUT_MS = 5000; + +export interface HostOptions { + // what deno task build made: the client, assets and mods players download + build_dir: string; + // the server scripts, with index.json + server_mods_dir: string; + // what the paths in server_mods/index.json are relative to, the folder the build ran in + root: string; + world_file: string; + seed?: string; + // the game can't go on, like the worker crashing + on_fatal(message: string): void; +} + +export interface Host { + handle(req: Request): Response | Promise; + // saves the world and stops the game. resolves once it's on disk + shutdown(): Promise; +} + +export function start_host(options: HostOptions): Host { + const mods = read_mods(options); + console.log(`Mods: ${mods.mods.map((mod) => `${mod.listing.id} ${mod.listing.version}`).join(", ") || "none"}`); + + const game = new Worker(new URL("./game/worker.ts", import.meta.url), { + type: "module", + deno: { permissions: "none" }, + } as WorkerOptions); + const post = (message: HostToGame) => game.postMessage(message); + + const sockets = new Map(); + let next_conn = 1; + let on_final_save: (() => void) | undefined; + + game.onmessage = (event: MessageEvent) => { + const message = event.data; + switch (message.type) { + case "ready": + console.log(`World ${options.world_file} ready, seed ${message.seed}`); + break; + case "failed": + options.on_fatal(`Couldn't start the game: ${message.error}`); + break; + case "send": { + const socket = sockets.get(message.conn); + if (socket?.readyState === WebSocket.OPEN) { + socket.send(message.data); + } + break; + } + case "close": + sockets.get(message.conn)?.close(); + break; + case "save": + write_world(options.world_file, message.data); + if (message.final) { + console.log("World saved"); + on_final_save?.(); + } + break; + } + }; + game.onerror = (event) => { + event.preventDefault(); + options.on_fatal(`Game server crashed: ${event.message}`); + }; + + post({ + type: "init", + save: read_world(options.world_file), + default_seed: options.seed ?? crypto.randomUUID(), + ...mods, + }); + + function handle_socket(socket: WebSocket) { + const conn = next_conn++; + + socket.addEventListener("open", () => { + sockets.set(conn, socket); + post({ type: "connect", conn }); + }); + + socket.addEventListener("message", (event) => { + if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) { + return; + } + post({ type: "message", conn, data: event.data }); + }); + + socket.addEventListener("close", () => { + if (sockets.delete(conn)) { + post({ type: "disconnect", conn }); + } + }); + } + + return { + handle(req) { + const url = new URL(req.url); + if (url.pathname === "/ws") { + if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return new Response("Expected a websocket", { status: 426 }); + } + const { socket, response } = Deno.upgradeWebSocket(req); + handle_socket(socket); + return response; + } + if (url.pathname === "/") { + return Response.redirect(new URL("/client/", url), 302); + } + return serve_static(options.build_dir, req, url); + }, + + shutdown() { + console.log("Saving world..."); + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Game server didn't save in time")), + SHUTDOWN_TIMEOUT_MS, + ); + on_final_save = () => { + clearTimeout(timeout); + game.terminate(); + resolve(); + }; + post({ type: "shutdown" }); + }); + }, + }; +} + +function read_world(file: string): string | undefined { + try { + return Deno.readTextFileSync(file); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + return undefined; + } + throw e; + } +} + +function write_world(file: string, data: string) { + // write then rename so a crash mid write doesnt eat the world + Deno.writeTextFileSync(`${file}.tmp`, data); + Deno.renameSync(`${file}.tmp`, file); +} + +// what deno task build made. no index means the build failed or never ran, and the game never runs without its mods +function read_mods(options: HostOptions): Pick, "mods" | "atlas"> { + let index: ServerModIndex; + try { + index = JSON.parse(Deno.readTextFileSync(join(options.server_mods_dir, "index.json"))); + } catch { + throw new Error( + `No ${options.server_mods_dir}/index.json, run deno task build first (it also reports mod errors)`, + ); + } + const from_root = (path: string) => isAbsolute(path) ? path : join(options.root, path); + return { + atlas: index.atlas, + mods: index.mods.map(({ listing, server }) => ({ + listing, + data: JSON.parse(Deno.readTextFileSync(join(options.build_dir, listing.data))), + server_code: server ? Deno.readTextFileSync(from_root(server)) : undefined, + worldgen_code: listing.worldgen + ? Deno.readTextFileSync(join(options.build_dir, listing.worldgen)) + : undefined, + })), + }; +} + +// mod files and the atlas are named by their hash, so they never change and pages on other origins can load them +export async function serve_static(build_dir: string, req: Request, url: URL) { + const response = await serveDir(req, { fsRoot: build_dir, quiet: true }); + const shared = url.pathname.startsWith("/mods/") || url.pathname.startsWith("/assets/"); + if (shared && response.ok) { + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", "*"); + if (url.pathname.startsWith("/mods/") || /^\/assets\/sprites\/textures\.[0-9a-f]+\./.test(url.pathname)) { + headers.set("Cache-Control", "public, max-age=31536000, immutable"); + } + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); + } + return response; +} diff --git a/server/main.ts b/server/main.ts index 00756e9..92ff0d1 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,169 +1,41 @@ -import { serveDir } from "@std/http/file-server"; -import { GameToHost, HostToGame } from "./game/host_protocol.ts"; -import type { ServerModIndex } from "../build.ts"; +// a dedicated server: the host from server/host.ts on a port, saving the world when it's stopped +import { type Host, start_host } from "./host.ts"; const PORT = Number(Deno.env.get("PORT") ?? 8000); -const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json"; -const STATIC_ROOT = Deno.env.get("BUILD_DIR") ?? "build"; -const SERVER_MODS_DIR = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods"; -const MAX_MESSAGE_SIZE = 4096; -const SHUTDOWN_TIMEOUT_MS = 5000; -// the host only does files and networking, the game runs in a worker with no permissions -const game = new Worker(new URL("./game/worker.ts", import.meta.url), { - type: "module", - deno: { permissions: "none" }, -} as WorkerOptions); - -const sockets = new Map(); -let next_conn = 1; - -function post(message: HostToGame) { - game.postMessage(message); -} - -function read_world(): string | undefined { - try { - return Deno.readTextFileSync(WORLD_FILE); - } catch (e) { - if (e instanceof Deno.errors.NotFound) { - return undefined; - } - throw e; - } -} - -function write_world(data: string) { - // write then rename so a crash mid write doesnt eat the world - Deno.writeTextFileSync(`${WORLD_FILE}.tmp`, data); - Deno.renameSync(`${WORLD_FILE}.tmp`, WORLD_FILE); -} - -game.onmessage = (event: MessageEvent) => { - const message = event.data; - switch (message.type) { - case "ready": - console.log(`World ${WORLD_FILE} ready, seed ${message.seed}`); - break; - case "failed": - console.error(`Couldn't start the game: ${message.error}`); +let host: Host; +try { + host = start_host({ + build_dir: Deno.env.get("BUILD_DIR") ?? "build", + server_mods_dir: Deno.env.get("SERVER_MODS_DIR") ?? "server_mods", + root: ".", + world_file: Deno.env.get("WORLD_FILE") ?? "world.json", + seed: Deno.env.get("SEED"), + on_fatal(message) { + console.error(message); Deno.exit(1); - break; - case "send": { - const socket = sockets.get(message.conn); - if (socket?.readyState === WebSocket.OPEN) { - socket.send(message.data); - } - break; - } - case "close": - sockets.get(message.conn)?.close(); - break; - case "save": - write_world(message.data); - if (message.final) { - console.log("World saved"); - Deno.exit(0); - } - break; - } -}; - -// what deno task build made. no index means the build failed or never ran, and the game never runs without its mods -function read_mods(): Pick, "mods" | "atlas"> { - let index: ServerModIndex; - try { - index = JSON.parse(Deno.readTextFileSync(`${SERVER_MODS_DIR}/index.json`)); - } catch { - console.error(`No ${SERVER_MODS_DIR}/index.json, run deno task build first (it also reports mod errors)`); - Deno.exit(1); - } - return { - atlas: index.atlas, - mods: index.mods.map(({ listing, server }) => ({ - listing, - data: JSON.parse(Deno.readTextFileSync(`${STATIC_ROOT}/${listing.data}`)), - server_code: server ? Deno.readTextFileSync(server) : undefined, - worldgen_code: listing.worldgen ? Deno.readTextFileSync(`${STATIC_ROOT}/${listing.worldgen}`) : undefined, - })), - }; -} - -game.onerror = (event) => { - console.error("Game server crashed:", event.message); + }, + }); +} catch (e) { + console.error((e as Error).message); Deno.exit(1); -}; - -const { mods, atlas } = read_mods(); -console.log(`Mods: ${mods.map((mod) => `${mod.listing.id} ${mod.listing.version}`).join(", ") || "none"}`); -const save = read_world(); -post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID(), atlas, mods }); - -// mod files and the atlas are named by their hash, so they never change and pages on other origins can load them -async function serve_static(req: Request, url: URL) { - const response = await serveDir(req, { fsRoot: STATIC_ROOT, quiet: true }); - const shared = url.pathname.startsWith("/mods/") || url.pathname.startsWith("/assets/"); - if (shared && response.ok) { - const headers = new Headers(response.headers); - headers.set("Access-Control-Allow-Origin", "*"); - if (url.pathname.startsWith("/mods/") || /^\/assets\/sprites\/textures\.[0-9a-f]+\./.test(url.pathname)) { - headers.set("Cache-Control", "public, max-age=31536000, immutable"); - } - return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); - } - return response; } -function handle_socket(socket: WebSocket) { - const conn = next_conn++; - - socket.addEventListener("open", () => { - sockets.set(conn, socket); - post({ type: "connect", conn }); - }); - - socket.addEventListener("message", (event) => { - if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) { - return; - } - post({ type: "message", conn, data: event.data }); - }); - - socket.addEventListener("close", () => { - if (sockets.delete(conn)) { - post({ type: "disconnect", conn }); - } - }); -} - -function shutdown() { - console.log("Saving world..."); - post({ type: "shutdown" }); - setTimeout(() => { - console.error("Game server didn't save in time"); +async function shutdown() { + try { + await host.shutdown(); + Deno.exit(0); + } catch (e) { + console.error((e as Error).message); Deno.exit(1); - }, SHUTDOWN_TIMEOUT_MS); + } } Deno.addSignalListener("SIGINT", shutdown); if (Deno.build.os !== "windows") { Deno.addSignalListener("SIGTERM", shutdown); } -Deno.serve({ port: PORT, onListen: ({ port }) => console.log(`bworld server on http://localhost:${port}/`) }, (req) => { - const url = new URL(req.url); - - if (url.pathname === "/ws") { - if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") { - return new Response("Expected a websocket", { status: 426 }); - } - const { socket, response } = Deno.upgradeWebSocket(req); - handle_socket(socket); - return response; - } - - if (url.pathname === "/") { - return Response.redirect(new URL("/client/", url), 302); - } - - return serve_static(req, url); -}); +Deno.serve( + { port: PORT, onListen: ({ port }) => console.log(`bworld server on http://localhost:${port}/`) }, + host.handle, +); diff --git a/tests/server_address_test.ts b/tests/server_address_test.ts new file mode 100644 index 0000000..dead530 --- /dev/null +++ b/tests/server_address_test.ts @@ -0,0 +1,25 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { server_address } from "$/client/handshake.ts"; + +const DESKTOP_PAGE = new URL("http://127.0.0.1:33549/client/"); +const HTTPS_PAGE = new URL("https://bworld.moder.fans/client/"); + +Deno.test("servers on this machine use ws, everything else wss, whatever the page was served over", () => { + for (const host of ["localhost:8000", "127.0.0.1:8000", "0.0.0.0:8000", "[::1]:8000", "localhost"]) { + assertEquals(server_address(host, DESKTOP_PAGE).ws_url, `ws://${host}/ws`, host); + assertEquals(server_address(host, HTTPS_PAGE).ws_url, `ws://${host}/ws`, host); + } + for (const host of ["bworld.moder.fans", "example.com:8000", "192.168.1.5:8000", "localhost.example.com"]) { + assertEquals(server_address(host, DESKTOP_PAGE).ws_url, `wss://${host}/ws`, host); + assertEquals(server_address(host, DESKTOP_PAGE).base.protocol, "https:", host); + } +}); + +Deno.test("a scheme picks ws or wss itself", () => { + assertEquals(server_address("ws://192.168.1.5:8000", DESKTOP_PAGE).ws_url, "ws://192.168.1.5:8000/ws"); + assertEquals(server_address("http://example.com", DESKTOP_PAGE).base.href, "http://example.com/"); + assertEquals(server_address("wss://localhost:8000", DESKTOP_PAGE).ws_url, "wss://localhost:8000/ws"); + assertEquals(server_address("https://bworld.moder.fans/", HTTPS_PAGE).cross_origin, false); + assertThrows(() => server_address("", DESKTOP_PAGE)); + assertThrows(() => server_address("not a host", DESKTOP_PAGE)); +});