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
+1
View File
@@ -2,3 +2,4 @@ build/
world.json
world.json.tmp
server_mods/
dist/
+46 -9
View File
@@ -20,8 +20,8 @@ const TITLE_SCALE = 8;
// for the player when it fails
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:
// a name, a server, and a button to join it
// the first thing players see, like minecraft's title and multiplayer screens rolled into one: a name, a server,
// 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 {
#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");
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());
// open over the title screen, it gets the input while it's there
#credits: CreditsScreen | undefined;
@@ -75,6 +79,7 @@ export class TitleScreen extends GuiScreen {
if (InputManager.is_key_pressed("Enter")) {
this.#join();
}
this.singleplayer_button?.handle_input();
this.join_button.handle_input();
this.credits_button.handle_input();
}
@@ -92,6 +97,7 @@ export class TitleScreen extends GuiScreen {
this.#label("Name", this.name);
this.#label("Server", this.server);
this.name.render();
this.singleplayer_button?.render();
this.server.render();
this.join_button.render();
this.credits_button.render();
@@ -121,12 +127,18 @@ export class TitleScreen extends GuiScreen {
#layout() {
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;
for (const field of [this.name, this.server]) {
field.x = x;
field.y = y + LABEL_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.y = y;
@@ -151,10 +163,21 @@ export class TitleScreen extends GuiScreen {
return;
}
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.status = `Connecting to ${address.base.host}...`;
this.status = status;
this.status_is_error = false;
try {
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) {
this.#joining = 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) {
@@ -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=
// or ?name=, so the fields show what was used last
export function back_to_title() {
location.href = location.pathname;
function has_singleplayer() {
return new URL(location.href).searchParams.has("singleplayer");
}
// 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
View File
@@ -25,12 +25,11 @@ export function default_server(page = new URL(location.href)): string {
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 {
const text = input.trim();
let host = text;
// without a scheme it's as secure as the page, browsers block insecure sockets from secure pages anyway
let secure = page.protocol === "https:";
let secure: boolean;
if (text.includes("://")) {
let url: URL;
try {
@@ -40,6 +39,10 @@ export function server_address(input: string, page = new URL(location.href)): Se
}
host = url.host;
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)) {
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
export class ServerSocket {
socket: WebSocket;
+10 -3
View File
@@ -5,17 +5,19 @@
"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",
"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": {
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"]
},
"unstable": ["bundle", "raw-imports"],
"unstable": ["bundle", "raw-imports", "worker-options"],
"fmt": {
"useTabs": true,
"indentWidth": 4,
"lineWidth": 120,
"newLineKind": "lf"
"newLineKind": "lf",
"exclude": ["desktop/*.md"]
},
"imports": {
"$/": "./",
@@ -30,5 +32,10 @@
"@std/path": "jsr:@std/path@^1.0.0",
"gl-matrix": "npm:gl-matrix@^3.4.4",
"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" }
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"server": "bworld.moder.fans"
}
+45
View File
@@ -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(" ")}`);
+86
View File
@@ -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
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;
}
+24 -152
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 {
let host: Host;
try {
return Deno.readTextFileSync(WORLD_FILE);
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);
},
});
} 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}`);
console.error((e as 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;
async function shutdown() {
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)`);
await host.shutdown();
Deno.exit(0);
} catch (e) {
console.error((e as Error).message);
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);
});
Deno.serve(
{ port: PORT, onListen: ({ port }) => console.log(`bworld server on http://localhost:${port}/`) },
host.handle,
);
+25
View File
@@ -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));
});