Deno desktop stuff
This commit is contained in:
+195
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user