Deno desktop stuff

This commit is contained in:
2026-09-26 13:25:34 -03:00
parent b68fe3a19f
commit 86d5c39eab
10 changed files with 457 additions and 170 deletions
+195
View File
@@ -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<Response>;
// saves the world and stops the game. resolves once it's on disk
shutdown(): Promise<void>;
}
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<number, WebSocket>();
let next_conn = 1;
let on_final_save: (() => void) | undefined;
game.onmessage = (event: MessageEvent<GameToHost>) => {
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<Extract<HostToGame, { type: "init" }>, "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;
}
+27 -155
View File
@@ -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<number, WebSocket>();
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<GameToHost>) => {
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<Extract<HostToGame, { type: "init" }>, "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,
);