Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions tools/release/game-static.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Minimal static server for the built game.
*
* A dependency-free replacement for the separate `game-web` Caddy service,
* started by `start-all.mjs`. Content-hashed assets are cached hard; the shell
* must revalidate so an update is actually picked up (PRD §27.4).
*
* Two things a real file server does that this one used to skip, both found
* when Þrøngva's album shipped and not one track of it played in production:
*
* - **The URL is percent-decoded before it touches the filesystem.** The
* browser asks for `Þrøngva/After%20the%20Winter%20of%20Want/001.%20...`;
* looking that up literally finds nothing, and the SPA fallback answered
* every track with `index.html` and a 200. Any file with a space or a
* non-ASCII letter in its name had never been servable, including the old
* `More Than Enough.mp3`.
* - **Byte ranges.** An `<audio>` element seeks and, in Safari, starts
* playback with `Range` requests, and Safari will not play media from a
* server that ignores them. Files are streamed rather than read whole, so a
* 9 MB track is not buffered in memory per listener.
*/
import fs from "node:fs";
import http from "node:http";
import path from "node:path";

const TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".webmanifest": "application/manifest+json",
".svg": "image/svg+xml",
".png": "image/png",
".webp": "image/webp",
".woff2": "font/woff2",
".ktx2": "image/ktx2",
".glb": "model/gltf-binary",
".webm": "audio/webm",
".mp3": "audio/mpeg",
".m3u": "audio/x-mpegurl",
".wasm": "application/wasm",
};

/**
* Parse a single `bytes=` range against a file size.
*
* Returns `null` when there is no usable range header (serve the whole file),
* `"invalid"` for a range that cannot be satisfied (416), or the inclusive
* `[start, end]`. Multi-range requests are answered with the whole file, which
* the spec allows and no media element sends.
*/
export function parseRange(header, size) {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null;
const [, from, to] = match;
if (from === "" && to === "") return "invalid";
let start;
let end;
if (from === "") {
// Suffix range: the last N bytes.
const length = Number(to);
if (length === 0) return "invalid";
start = Math.max(0, size - length);
end = size - 1;
} else {
start = Number(from);
end = to === "" ? size - 1 : Math.min(Number(to), size - 1);
}
if (start >= size || start > end) return "invalid";
return [start, end];
}

/** @param {string} dist absolute path of the built game */
export function createGameServer(dist) {
const root = path.resolve(dist);

const sendShell = (req, res) => {
// SPA fallback so client-side routes work on reload.
fs.readFile(path.join(root, "index.html"), (error, html) => {
if (error) {
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
return;
}
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-cache",
});
res.end(req.method === "HEAD" ? undefined : html);
});
};

return http.createServer((req, res) => {
let url;
try {
url = decodeURIComponent((req.url ?? "/").split("?")[0]);
} catch {
// Malformed percent-encoding.
res.writeHead(400).end();
return;
}

if (url === "/health/live" || url === "/health/ready") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ status: "ok", service: "game-web" }));
return;
}

// Strip the /play prefix the gateway forwards.
let relative = url.replace(/^\/play(?=\/|$)/, "") || "/";
if (relative.endsWith("/")) relative += "index.html";

// Resolve and confirm the result stays inside dist: a static server is a
// classic path-traversal surface, and decoding makes `%2e%2e%2f` a real
// `../`. The separator matters: a bare prefix test would also admit a
// sibling directory such as `dist-old`.
const resolved = path.resolve(root, `.${relative}`);
if (relative.includes("\0") || !resolved.startsWith(root + path.sep)) {
res.writeHead(403).end();
return;
}

fs.stat(resolved, (error, stat) => {
if (error || !stat.isFile()) {
sendShell(req, res);
return;
}

const hashed = /\.[a-f0-9]{8,}\./.test(path.basename(resolved));
const headers = {
"content-type": TYPES[path.extname(resolved).toLowerCase()] ?? "application/octet-stream",
"cache-control": hashed ? "public, max-age=31536000, immutable" : "no-cache",
"accept-ranges": "bytes",
};

const range = parseRange(req.headers.range, stat.size);
if (range === "invalid") {
res.writeHead(416, { ...headers, "content-range": `bytes */${stat.size}` }).end();
return;
}
const [start, end] = range ?? [0, stat.size - 1];
const length = stat.size === 0 ? 0 : end - start + 1;
res.writeHead(range ? 206 : 200, {
...headers,
"content-length": length,
...(range ? { "content-range": `bytes ${start}-${end}/${stat.size}` } : {}),
});
if (req.method === "HEAD" || length === 0) {
res.end();
return;
}
fs.createReadStream(resolved, { start, end })
.on("error", () => res.destroy())
.pipe(res);
});
});
}
102 changes: 102 additions & 0 deletions tools/release/game-static.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// @ts-expect-error -- plain .mjs, run by node in production; no declarations.
import { createGameServer, parseRange } from "./game-static.mjs";

/**
* The production game file server. Its first version never percent-decoded
* the URL, so every album track (spaces, and a Þ in the artist folder) got
* `index.html` with a 200 and no track played.
*/
const ALBUM = "audio/music/Þrøngva/After the Winter of Want";
const TRACK = `${ALBUM}/001. Frost on the Oar.mp3`;
const BYTES = Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 256));

let dir: string;
let dist: string;
let base: string;
let server: ReturnType<typeof createGameServer>;

const encoded = (p: string) => p.split("/").map(encodeURIComponent).join("/");

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), "nc7-static-"));
dist = join(dir, "dist");
mkdirSync(join(dist, ALBUM), { recursive: true });
writeFileSync(join(dist, "index.html"), "<!doctype html><title>shell</title>");
writeFileSync(join(dist, TRACK), BYTES);
// A sibling a bare prefix check would wrongly admit.
mkdirSync(join(dir, "dist-old"));
writeFileSync(join(dir, "dist-old", "secret.txt"), "secret");

server = createGameServer(dist);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});

afterAll(() => {
server.close();
rmSync(dir, { recursive: true, force: true });
});

describe("game static server", () => {
it("serves a track whose path has spaces and non-ASCII letters", async () => {
const res = await fetch(`${base}/play/${encoded(TRACK)}`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("audio/mpeg");
expect(res.headers.get("accept-ranges")).toBe("bytes");
expect(Buffer.from(await res.arrayBuffer()).equals(BYTES)).toBe(true);
});

it("answers a byte range with 206 and just those bytes", async () => {
const res = await fetch(`${base}/play/${encoded(TRACK)}`, {
headers: { range: "bytes=100-199" },
});
expect(res.status).toBe(206);
expect(res.headers.get("content-range")).toBe("bytes 100-199/1000");
expect(Buffer.from(await res.arrayBuffer()).equals(BYTES.subarray(100, 200))).toBe(true);
});

it("rejects a range past the end with 416", async () => {
const res = await fetch(`${base}/play/${encoded(TRACK)}`, {
headers: { range: "bytes=5000-" },
});
expect(res.status).toBe(416);
expect(res.headers.get("content-range")).toBe("bytes */1000");
});

it("still falls back to the shell for an unknown route", async () => {
const res = await fetch(`${base}/play/some/client/route`);
expect(res.status).toBe(200);
expect(await res.text()).toContain("<title>shell</title>");
});

it("does not let decoding open a path out of dist", async () => {
const res = await fetch(`${base}/play/%2e%2e%2fdist-old%2fsecret.txt`);
expect(res.status).toBe(403);
});

it("rejects malformed percent-encoding", async () => {
const res = await fetch(`${base}/play/%E0%A4%A`);
expect(res.status).toBe(400);
});
});

describe("parseRange", () => {
it("reads open, closed and suffix ranges", () => {
expect(parseRange(undefined, 1000)).toBeNull();
expect(parseRange("bytes=0-", 1000)).toEqual([0, 999]);
expect(parseRange("bytes=10-19", 1000)).toEqual([10, 19]);
expect(parseRange("bytes=-100", 1000)).toEqual([900, 999]);
expect(parseRange("bytes=990-5000", 1000)).toEqual([990, 999]);
});

it("refuses what cannot be satisfied", () => {
expect(parseRange("bytes=1000-", 1000)).toBe("invalid");
expect(parseRange("bytes=20-10", 1000)).toBe("invalid");
expect(parseRange("bytes=-", 1000)).toBe("invalid");
});
});
79 changes: 3 additions & 76 deletions tools/release/start-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@
* server is dead is worse than an honest restart.
*/
import { spawn } from "node:child_process";
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { createGameServer } from "./game-static.mjs";

const ROOT = process.cwd();
const PUBLIC_PORT = Number(process.env.PORT ?? 8080);
Expand Down Expand Up @@ -60,81 +59,9 @@ function start(name, command, args, env) {
return child;
}

/**
* Minimal static server for the built game.
*
* A dependency-free replacement for the separate `game-web` Caddy service.
* Content-hashed assets are cached hard; the shell must revalidate so an
* update is actually picked up (PRD §27.4).
*/
/** Serve the built game; see `game-static.mjs`. */
function startGameStatic() {
const dist = path.join(ROOT, "apps/game/dist");
const types = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".webmanifest": "application/manifest+json",
".svg": "image/svg+xml",
".png": "image/png",
".webp": "image/webp",
".woff2": "font/woff2",
".ktx2": "image/ktx2",
".glb": "model/gltf-binary",
".webm": "audio/webm",
".mp3": "audio/mpeg",
".wasm": "application/wasm",
};

const server = http.createServer((req, res) => {
const url = (req.url ?? "/").split("?")[0];

if (url === "/health/live" || url === "/health/ready") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ status: "ok", service: "game-web" }));
return;
}

// Strip the /play prefix the gateway forwards.
let relative = url.replace(/^\/play/, "") || "/";
if (relative.endsWith("/")) relative += "index.html";

// Resolve and confirm the result stays inside dist — a static server is a
// classic path-traversal surface.
const resolved = path.resolve(dist, `.${relative}`);
if (!resolved.startsWith(dist)) {
res.writeHead(403).end();
return;
}

fs.readFile(resolved, (error, data) => {
if (error) {
// SPA fallback so client-side routes work on reload.
fs.readFile(path.join(dist, "index.html"), (fallbackError, html) => {
if (fallbackError) {
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
return;
}
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-cache",
});
res.end(html);
});
return;
}

const ext = path.extname(resolved);
const hashed = /\.[a-f0-9]{8,}\./.test(path.basename(resolved));
res.writeHead(200, {
"content-type": types[ext] ?? "application/octet-stream",
"cache-control": hashed ? "public, max-age=31536000, immutable" : "no-cache",
});
res.end(data);
});
});

const server = createGameServer(path.join(ROOT, "apps/game/dist"));
server.listen(PORTS.game, () => log("game-web", `listening on ${PORTS.game}`));
children.push({ name: "game-web", child: { kill: () => server.close() } });
}
Expand Down
Loading