Deno desktop stuff
This commit is contained in:
@@ -2,3 +2,4 @@ build/
|
|||||||
world.json
|
world.json
|
||||||
world.json.tmp
|
world.json.tmp
|
||||||
server_mods/
|
server_mods/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const TITLE_SCALE = 8;
|
|||||||
// for the player when it fails
|
// for the player when it fails
|
||||||
export type JoinServer = (address: ServerAddress, name: string, status: (text: string) => void) => Promise<void>;
|
export type JoinServer = (address: ServerAddress, name: string, status: (text: string) => void) => Promise<void>;
|
||||||
|
|
||||||
// the first thing players see, like minecraft's title and multiplayer screens rolled into one:
|
// the first thing players see, like minecraft's title and multiplayer screens rolled into one: a name, a server,
|
||||||
// a name, a server, and a button to join it
|
// 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 {
|
export class TitleScreen extends GuiScreen {
|
||||||
#join_server: JoinServer;
|
#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");
|
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());
|
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());
|
credits_button = new Button("Credits", WIDTH, ROW_HEIGHT, () => this.#open_credits());
|
||||||
// open over the title screen, it gets the input while it's there
|
// open over the title screen, it gets the input while it's there
|
||||||
#credits: CreditsScreen | undefined;
|
#credits: CreditsScreen | undefined;
|
||||||
@@ -75,6 +79,7 @@ export class TitleScreen extends GuiScreen {
|
|||||||
if (InputManager.is_key_pressed("Enter")) {
|
if (InputManager.is_key_pressed("Enter")) {
|
||||||
this.#join();
|
this.#join();
|
||||||
}
|
}
|
||||||
|
this.singleplayer_button?.handle_input();
|
||||||
this.join_button.handle_input();
|
this.join_button.handle_input();
|
||||||
this.credits_button.handle_input();
|
this.credits_button.handle_input();
|
||||||
}
|
}
|
||||||
@@ -92,6 +97,7 @@ export class TitleScreen extends GuiScreen {
|
|||||||
this.#label("Name", this.name);
|
this.#label("Name", this.name);
|
||||||
this.#label("Server", this.server);
|
this.#label("Server", this.server);
|
||||||
this.name.render();
|
this.name.render();
|
||||||
|
this.singleplayer_button?.render();
|
||||||
this.server.render();
|
this.server.render();
|
||||||
this.join_button.render();
|
this.join_button.render();
|
||||||
this.credits_button.render();
|
this.credits_button.render();
|
||||||
@@ -121,12 +127,18 @@ export class TitleScreen extends GuiScreen {
|
|||||||
|
|
||||||
#layout() {
|
#layout() {
|
||||||
const x = (canvas.width - WIDTH) / 2;
|
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;
|
let y = (canvas.height - total) / 2 + 40;
|
||||||
for (const field of [this.name, this.server]) {
|
for (const field of [this.name, this.server]) {
|
||||||
field.x = x;
|
field.x = x;
|
||||||
field.y = y + LABEL_GAP;
|
field.y = y + LABEL_GAP;
|
||||||
y += LABEL_GAP + ROW_HEIGHT + 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.x = x;
|
||||||
this.join_button.y = y;
|
this.join_button.y = y;
|
||||||
@@ -151,10 +163,21 @@ export class TitleScreen extends GuiScreen {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
remember(LAST_SERVER_KEY, this.server.value.trim());
|
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.#set_joining(true);
|
||||||
this.status = `Connecting to ${address.base.host}...`;
|
this.status = status;
|
||||||
this.status_is_error = false;
|
this.status_is_error = false;
|
||||||
try {
|
try {
|
||||||
await this.#join_server(address, this.name.value, (text) => this.status = text);
|
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) {
|
#set_joining(joining: boolean) {
|
||||||
this.#joining = joining;
|
this.#joining = joining;
|
||||||
this.name.active = this.server.active = this.join_button.active = !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) {
|
#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=
|
function has_singleplayer() {
|
||||||
// or ?name=, so the fields show what was used last
|
return new URL(location.href).searchParams.has("singleplayer");
|
||||||
export function back_to_title() {
|
}
|
||||||
location.href = location.pathname;
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -25,12 +25,11 @@ export function default_server(page = new URL(location.href)): string {
|
|||||||
return page.searchParams.get("server") ?? page.host;
|
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 {
|
export function server_address(input: string, page = new URL(location.href)): ServerAddress {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
let host = text;
|
let host = text;
|
||||||
// without a scheme it's as secure as the page, browsers block insecure sockets from secure pages anyway
|
let secure: boolean;
|
||||||
let secure = page.protocol === "https:";
|
|
||||||
if (text.includes("://")) {
|
if (text.includes("://")) {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
@@ -40,6 +39,10 @@ export function server_address(input: string, page = new URL(location.href)): Se
|
|||||||
}
|
}
|
||||||
host = url.host;
|
host = url.host;
|
||||||
secure = url.protocol === "https:" || url.protocol === "wss:";
|
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)) {
|
if (!host || /[\s/?#]/.test(host)) {
|
||||||
throw new HandshakeError(`${text || "An empty address"} isn't a server address`);
|
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
|
// a socket whose messages all land in one queue, so none get lost between the handshake and the game
|
||||||
export class ServerSocket {
|
export class ServerSocket {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
|
|||||||
@@ -5,17 +5,19 @@
|
|||||||
"server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts",
|
"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",
|
"new-mod": "deno run --allow-read --allow-write tools/new_mod.ts",
|
||||||
"check-mods": "deno run --allow-read --allow-run tools/check_mods.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": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"]
|
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"]
|
||||||
},
|
},
|
||||||
"unstable": ["bundle", "raw-imports"],
|
"unstable": ["bundle", "raw-imports", "worker-options"],
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"useTabs": true,
|
"useTabs": true,
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"lineWidth": 120,
|
"lineWidth": 120,
|
||||||
"newLineKind": "lf"
|
"newLineKind": "lf",
|
||||||
|
"exclude": ["desktop/*.md"]
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"$/": "./",
|
"$/": "./",
|
||||||
@@ -30,5 +32,10 @@
|
|||||||
"@std/path": "jsr:@std/path@^1.0.0",
|
"@std/path": "jsr:@std/path@^1.0.0",
|
||||||
"gl-matrix": "npm:gl-matrix@^3.4.4",
|
"gl-matrix": "npm:gl-matrix@^3.4.4",
|
||||||
"marked": "npm:marked@^17.0.3"
|
"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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"server": "bworld.moder.fans"
|
||||||
|
}
|
||||||
@@ -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 <app folder>, 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(" ")}`);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/// <reference lib="deno.desktop" />
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+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;
|
||||||
|
}
|
||||||
+28
-156
@@ -1,169 +1,41 @@
|
|||||||
import { serveDir } from "@std/http/file-server";
|
// a dedicated server: the host from server/host.ts on a port, saving the world when it's stopped
|
||||||
import { GameToHost, HostToGame } from "./game/host_protocol.ts";
|
import { type Host, start_host } from "./host.ts";
|
||||||
import type { ServerModIndex } from "../build.ts";
|
|
||||||
|
|
||||||
const PORT = Number(Deno.env.get("PORT") ?? 8000);
|
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
|
let host: Host;
|
||||||
const game = new Worker(new URL("./game/worker.ts", import.meta.url), {
|
try {
|
||||||
type: "module",
|
host = start_host({
|
||||||
deno: { permissions: "none" },
|
build_dir: Deno.env.get("BUILD_DIR") ?? "build",
|
||||||
} as WorkerOptions);
|
server_mods_dir: Deno.env.get("SERVER_MODS_DIR") ?? "server_mods",
|
||||||
|
root: ".",
|
||||||
const sockets = new Map<number, WebSocket>();
|
world_file: Deno.env.get("WORLD_FILE") ?? "world.json",
|
||||||
let next_conn = 1;
|
seed: Deno.env.get("SEED"),
|
||||||
|
on_fatal(message) {
|
||||||
function post(message: HostToGame) {
|
console.error(message);
|
||||||
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);
|
Deno.exit(1);
|
||||||
break;
|
},
|
||||||
case "send": {
|
});
|
||||||
const socket = sockets.get(message.conn);
|
} catch (e) {
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
console.error((e as Error).message);
|
||||||
socket.send(message.data);
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
async function shutdown() {
|
||||||
case "close":
|
try {
|
||||||
sockets.get(message.conn)?.close();
|
await host.shutdown();
|
||||||
break;
|
|
||||||
case "save":
|
|
||||||
write_world(message.data);
|
|
||||||
if (message.final) {
|
|
||||||
console.log("World saved");
|
|
||||||
Deno.exit(0);
|
Deno.exit(0);
|
||||||
}
|
} catch (e) {
|
||||||
break;
|
console.error((e as Error).message);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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);
|
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);
|
Deno.addSignalListener("SIGINT", shutdown);
|
||||||
if (Deno.build.os !== "windows") {
|
if (Deno.build.os !== "windows") {
|
||||||
Deno.addSignalListener("SIGTERM", shutdown);
|
Deno.addSignalListener("SIGTERM", shutdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.serve({ port: PORT, onListen: ({ port }) => console.log(`bworld server on http://localhost:${port}/`) }, (req) => {
|
Deno.serve(
|
||||||
const url = new URL(req.url);
|
{ port: PORT, onListen: ({ port }) => console.log(`bworld server on http://localhost:${port}/`) },
|
||||||
|
host.handle,
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user