// 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; }