170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
import { serveDir } from "@std/http/file-server";
|
|
import { GameToHost, HostToGame } from "./game/host_protocol.ts";
|
|
import type { ServerModIndex } from "../build.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}`);
|
|
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);
|
|
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");
|
|
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);
|
|
});
|