Public: anyone can read this; the crew can change it.
forked from trevato/cs
The engine a visitor reads about is one they can take: /engine serves the guide and the packed package #391
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/225-the-engine-a-visitor-reads-about-is-one → main 1 commit fork
13 files
changed +1051
−11
README.md +58 −3
| @@ -14,7 +14,8 @@ game — one room seen from above, one plate, thirty seconds and a score, drawn | ||
| 14 | 14 | in 2D through the engine's `overlay` and nothing else — served to say what |
| 15 | 15 | building on this engine actually costs. The engine in |
| 16 | 16 | `src/engine/` is the point of the repository; the shooter is what proves it, |
| 17 | and the example is the price tag. | |
| 17 | and the example is the price tag. `/engine` is where a stranger takes it: the | |
| 18 | package as one download, the modules behind its door, and its whole guide. | |
| 18 | 19 | |
| 19 | 20 | sh |
| 20 | 21 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| @@ -423,6 +424,39 @@ in, so the fix is obvious. Words are still words: "about eight kilobytes", | ||
| 423 | 424 | "thirty seconds on the clock" and "a quarter of the air acceleration" are |
| 424 | 425 | prose, and the rule is about digits, not about claims. |
| 425 | 426 | |
| 427 | ## The engine, to take away | |
| 428 | ||
| 429 | `/engine` is the engine as something you can leave with. It says what the | |
| 430 | package is — TypeScript sources, one door (`index.ts`), no dependencies, the | |
| 431 | guide and the sample game — and offers it as one file, | |
| 432 | `cs-engine-<version>.tar.gz`, with its size, its sha256 and the two lines that | |
| 433 | start a game on it: | |
| 434 | ||
| 435 | sh | |
| 436 | tar -xzf cs-engine-<version>.tar.gz | |
| 437 | bun build cs-engine-<version>/example.ts --outdir out | |
| 438 | ||
| 439 | ||
| 440 | Under that are the sizes the guide's cost story is made of (the same `Cost` | |
| 441 | `/example` prints, plus the package's own bytes), every module the door | |
| 442 | re-exports with the comment the door writes above it, and `GUIDE.md` itself. | |
| 443 | The guide is shown as **its own text, escaped and preformatted**, with an | |
| 444 | anchor on every heading (`/engine#guide-renderer`) and a contents list — not | |
| 445 | rendered as markdown, because that is a dependency or a second parser, and the | |
| 446 | page says so. A prettier reading is a later change. | |
| 447 | ||
| 448 | What is real: the archive is built **by this server when it starts**, from the | |
| 449 | tree it runs — `scripts/pack.ts` into `FORGE_APP_DATA/engine/cs-engine-<version>/`, | |
| 450 | tarred by `src/tar.ts` and gzipped at level 9 into | |
| 451 | `FORGE_APP_DATA/engine/cs-engine-<version>.tar.gz`, then read back once. That | |
| 452 | costs about 30 ms of boot (printed in the boot log) and never runs again; a | |
| 453 | request is a map lookup and a write of bytes that already exist. The version is | |
| 454 | the one in `src/engine/surface.json` that the door is held to, and it names the | |
| 455 | *surface*, not the behaviour — so the download's URL also carries ten hex digits | |
| 456 | of the archive's sha256, which is what makes a year of `immutable` caching | |
| 457 | honest. Every file in the archive has mtime 0 and owner 0, so the same tree | |
| 458 | always packs to the same bytes and the same hash. | |
| 459 | ||
| 426 | 460 | ## Playing |
| 427 | 461 | |
| 428 | 462 | <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, |
| @@ -868,8 +902,9 @@ never checked itself. | ||
| 868 | 902 | an ES module, `"exports": { ".": "./index.ts" }` and no dependencies, because |
| 869 | 903 | there are none — and `example.ts` beside them as the sample. No `.test.ts` and |
| 870 | 904 | no `.probe.ts` come along, and the script writes only under that directory. |
| 871 | It is a script and not the app: nothing runs at request time and nothing about | |
| 872 | `FORGE_APP_DATA` changes. `scripts/pack.test.ts` is the measurement: it packs | |
| 905 | Run by hand it writes where it is told; the server also calls `pack` once at | |
| 906 | start-up, into `FORGE_APP_DATA/engine`, for the download `/engine` serves | |
| 907 | (`src/archive.ts`). It never runs at request time. `scripts/pack.test.ts` is the measurement: it packs | |
| 873 | 908 | into a temporary directory of its own, builds the sample against the packed |
| 874 | 909 | door with a plugin that records where every module was read from, and fails if |
| 875 | 910 | one of them resolved back into this repository — then imports the packed door |
| @@ -1393,6 +1428,26 @@ never checked itself. | ||
| 1393 | 1428 | bargain the 404 makes. The shooter's page gets `weaponsSummary()` instead of |
| 1394 | 1429 | the tables: three sentences read off the same sheet, and the link. That is |
| 1395 | 1430 | one module rendering the numbers in two shapes, never two copies of them. |
| 1431 | - `src/pages/engine.ts` — `/engine`, the second read route no game owns, on | |
| 1432 | the same terms as `/weapons`: rendered and weighed once at start-up, its own | |
| 1433 | style hash, no script. It renders what it is handed — the archive, the `Cost`, | |
| 1434 | the door's modules and the guide's text — and reads no file. The guide's | |
| 1435 | headings become anchors (`guide-<slug>`, deduplicated) outside code fences, | |
| 1436 | and every line of it is escaped. | |
| 1437 | - `src/archive.ts` — **the package as one file**: packs the engine into | |
| 1438 | `FORGE_APP_DATA/engine`, tars and gzips exactly the files the pack wrote, | |
| 1439 | writes the archive beside them and hands back its bytes, sha256 and href. The | |
| 1440 | server calls it once before it listens; `src/archive.test.ts` unpacks the | |
| 1441 | result and reads the version out of its `package.json`. | |
| 1442 | - `src/tar.ts` — **ustar by hand**: a 512-byte header per regular file, data | |
| 1443 | padded to 512, two zero blocks at the end, and `untar`, its inverse, which | |
| 1444 | checks every checksum. It refuses a path that is absolute, has a `..`, is not | |
| 1445 | ASCII or does not fit the header, rather than writing an archive that unpacks | |
| 1446 | somewhere else. `src/tar.test.ts` pins the header byte by byte and, where the | |
| 1447 | machine has one, has the system's `tar -xzf` unpack it. | |
| 1448 | - `src/door.ts` — the modules `src/engine/index.ts` re-exports, in its order, | |
| 1449 | with the doc comment above each: the list `/engine` prints, read off the door | |
| 1450 | rather than typed beside it. | |
| 1396 | 1451 | - `src/cost.ts` and `src/bytes.ts` — **the numbers `/example` states about |
| 1397 | 1452 | itself are measured, not typed.** That page's whole claim is what a third game |
| 1398 | 1453 | costs, so `src/cost.ts` weighs the bundles this boot built with |
scripts/pack.ts +5 −4
| @@ -26,10 +26,11 @@ | ||
| 26 | 26 | * builds first, not a module of the package, which is why `index.ts` does not |
| 27 | 27 | * offer it and why this script does not count it as one. |
| 28 | 28 | * |
| 29 | * It is a script and not the app: it runs when a person runs it, it never runs | |
| 30 | * at request time, and it writes only under the directory it was given — which | |
| 31 | * it creates if it is missing and otherwise leaves alone. Nothing here reads or | |
| 32 | * writes `FORGE_APP_DATA`. Anything already in the target that this pack does | |
| 29 | * It runs when a person runs it, and once when the server starts — | |
| 30 | * `src/archive.ts` packs into `FORGE_APP_DATA/engine` for the download `/engine` | |
| 31 | * serves — and never at request time. Either way it writes only under the | |
| 32 | * directory it was given, which it creates if it is missing and otherwise | |
| 33 | * leaves alone; nothing here names `FORGE_APP_DATA` itself. Anything already in the target that this pack does | |
| 33 | 34 | * not write is left where it is; the copy is the files below, not the |
| 34 | 35 | * directory's whole contents. |
| 35 | 36 | */ |
src/archive.test.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { GUIDE, manifest, NAME, SAMPLE, VERSION } from "../scripts/pack.ts"; | |
| 6 | import { archiveDir, baseName, buildArchive } from "./archive.ts"; | |
| 7 | import { untar } from "./tar.ts"; | |
| 8 | ||
| 9 | /** A data directory of this test's own, removed however the body ends. */ | |
| 10 | function inTemp<T>(body: (dir: string) => T): T { | |
| 11 | const dir = mkdtempSync(join(tmpdir(), "archive-")); | |
| 12 | try { | |
| 13 | return body(dir); | |
| 14 | } finally { | |
| 15 | rmSync(dir, { recursive: true, force: true }); | |
| 16 | } | |
| 17 | } | |
| 18 | ||
| 19 | test("the archive lives under the data directory, never the repository", () => { | |
| 20 | expect(archiveDir("/srv/data")).toBe(join("/srv/data", "engine")); | |
| 21 | expect(baseName("1.2.3")).toBe(`${NAME}-1.2.3`); | |
| 22 | }); | |
| 23 | ||
| 24 | test("the tarball unpacks to one directory whose package.json names the engine's version", () => { | |
| 25 | inTemp((data) => { | |
| 26 | const archive = buildArchive(archiveDir(data)); | |
| 27 | // The pack, and the archive beside it, and nothing else. | |
| 28 | expect(readdirSync(archiveDir(data)).sort()).toEqual([baseName(), `${baseName()}.tar.gz`].sort()); | |
| 29 | expect(archive.file).toBe(`${NAME}-${VERSION}.tar.gz`); | |
| 30 | ||
| 31 | const read = untar(Bun.gunzipSync(archive.bytes)); | |
| 32 | const top = `${NAME}-${VERSION}/`; | |
| 33 | for (const { name } of read) expect(name.startsWith(top)).toBe(true); | |
| 34 | const at = (name: string) => read.find((entry) => entry.name === top + name); | |
| 35 | ||
| 36 | const pkg = JSON.parse(new TextDecoder().decode(at("package.json")?.data)); | |
| 37 | expect(pkg).toEqual(manifest()); | |
| 38 | expect(pkg.version).toBe(VERSION); | |
| 39 | expect(pkg.dependencies).toBeUndefined(); | |
| 40 | ||
| 41 | // The door, the guide and the sample are in it, and they are this tree's. | |
| 42 | const engine = join(import.meta.dir, "engine"); | |
| 43 | for (const name of ["index.ts", GUIDE, SAMPLE]) | |
| 44 | expect(new TextDecoder().decode(at(name)?.data)).toBe(readFileSync(join(engine, name), "utf8")); | |
| 45 | // Every module the pack counted, and no test. | |
| 46 | for (const module of archive.modules) expect(at(module)).toBeDefined(); | |
| 47 | expect(read.some(({ name }) => name.endsWith(".test.ts"))).toBe(false); | |
| 48 | expect(read.map(({ name }) => name)).toEqual(archive.files); | |
| 49 | expect(archive.unpacked).toBe(read.reduce((sum, { size }) => sum + size, 0)); | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | test("the bytes held are the file written, and the href carries their hash", () => { | |
| 54 | inTemp((data) => { | |
| 55 | const archive = buildArchive(archiveDir(data)); | |
| 56 | expect(readFileSync(join(archiveDir(data), archive.file))).toEqual(Buffer.from(archive.bytes)); | |
| 57 | const hex = new Bun.CryptoHasher("sha256").update(archive.bytes).digest("hex"); | |
| 58 | expect(archive.sha256).toBe(hex); | |
| 59 | expect(archive.href).toBe(`/engine/${NAME}-${VERSION}.${hex.slice(0, 10)}.tar.gz`); | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the same tree packs to the same bytes, so a hash in the URL is honest", () => { | |
| 64 | inTemp((data) => { | |
| 65 | const once = buildArchive(join(data, "a")); | |
| 66 | const twice = buildArchive(join(data, "b")); | |
| 67 | expect(twice.sha256).toBe(once.sha256); | |
| 68 | expect(twice.bytes).toEqual(once.bytes); | |
| 69 | }); | |
| 70 | }); |
src/archive.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * The engine as one file a stranger can take: `scripts/pack.ts` run once at | |
| 3 | * start-up into this deployment's data directory, tarred by `./tar.ts`, gzipped | |
| 4 | * by Bun, written beside it, and read back. | |
| 5 | * | |
| 6 | * FORGE_APP_DATA/engine/cs-engine-<version>/ ← the pack, as files | |
| 7 | * FORGE_APP_DATA/engine/cs-engine-<version>.tar.gz ← the same files, as one | |
| 8 | * | |
| 9 | * Nothing here writes into the repository and nothing here runs per request: | |
| 10 | * `src/server.ts` calls `buildArchive` once before it listens and serves the | |
| 11 | * bytes it hands back. What those bytes are is the pack's own list — every | |
| 12 | * shipping module, the guide, the sample and a generated `package.json` — under | |
| 13 | * one top-level directory named for the version, which is what `tar -xzf` | |
| 14 | * leaves behind. | |
| 15 | * | |
| 16 | * The version is `VERSION` from `scripts/pack.ts`, which is the one in | |
| 17 | * `src/engine/surface.json` that `scripts/surface.test.ts` holds the door | |
| 18 | * against (#191). There is no second copy of it here. | |
| 19 | * | |
| 20 | * The archive is a function of the files: every mtime is 0, owner and group | |
| 21 | * are 0, and Bun's gzip writes no timestamp. So its sha256 changes exactly when | |
| 22 | * a byte of the package does — which is what lets the URL carry a slice of that | |
| 23 | * hash and be cached for a year, when the version alone would not: behaviour | |
| 24 | * moves under a version that stands still (see the guide's "The version, and | |
| 25 | * what it promises"). | |
| 26 | */ | |
| 27 | ||
| 28 | import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 29 | import { join } from "node:path"; | |
| 30 | import { NAME, pack, VERSION } from "../scripts/pack.ts"; | |
| 31 | import { tar } from "./tar.ts"; | |
| 32 | ||
| 33 | /** The level the archive is gzipped at: it is built once, so it is built small. */ | |
| 34 | export const ARCHIVE_LEVEL = 9; | |
| 35 | ||
| 36 | /** The directory the pack and its archive go in, under this deployment's data. */ | |
| 37 | export const archiveDir = (data = process.env.FORGE_APP_DATA ?? "./data"): string => join(data, "engine"); | |
| 38 | ||
| 39 | /** The name a download is saved under, and the directory it unpacks to. */ | |
| 40 | export const baseName = (version = VERSION): string => `${NAME}-${version}`; | |
| 41 | ||
| 42 | /** What start-up made, as the page and the route need it. Read once, held. */ | |
| 43 | export type Archive = { | |
| 44 | /** The package's name and version, as its `package.json` says them. */ | |
| 45 | name: string; | |
| 46 | version: string; | |
| 47 | /** What a browser saves it as: `cs-engine-0.3.0.tar.gz`. */ | |
| 48 | file: string; | |
| 49 | /** Where it answers: the file name with ten hex digits of its sha256 in it. */ | |
| 50 | href: string; | |
| 51 | /** The archive, exactly the bytes on disk. */ | |
| 52 | bytes: Uint8Array<ArrayBuffer>; | |
| 53 | /** Hex sha256 of `bytes`: what a person checks a download against. */ | |
| 54 | sha256: string; | |
| 55 | /** Modules of the package, as the pack counted them — `index.ts` included. */ | |
| 56 | modules: string[]; | |
| 57 | /** Every path in the archive, top directory and all, in archive order. */ | |
| 58 | files: string[]; | |
| 59 | /** Bytes of the files before tar and gzip. */ | |
| 60 | unpacked: number; | |
| 61 | }; | |
| 62 | ||
| 63 | /** | |
| 64 | * Pack into `dir`, tar and gzip what was packed, write the archive, and read it | |
| 65 | * back. The entries are the files `pack` says it wrote, read from where it wrote | |
| 66 | * them — so the archive is the pack on disk, not a second opinion of it. | |
| 67 | */ | |
| 68 | export function buildArchive(dir = archiveDir()): Archive { | |
| 69 | const base = baseName(); | |
| 70 | mkdirSync(dir, { recursive: true }); | |
| 71 | const packed = pack(join(dir, base)); | |
| 72 | const names = [...packed.files].sort(); | |
| 73 | const entries = names.map((name) => ({ | |
| 74 | name: `${base}/${name}`, | |
| 75 | data: new Uint8Array(readFileSync(join(packed.dir, name))), | |
| 76 | })); | |
| 77 | const file = `${base}.tar.gz`; | |
| 78 | const path = join(dir, file); | |
| 79 | writeFileSync(path, Bun.gzipSync(tar(entries), { level: ARCHIVE_LEVEL })); | |
| 80 | const bytes = new Uint8Array(readFileSync(path)); | |
| 81 | const sha256 = new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); | |
| 82 | return { | |
| 83 | name: NAME, | |
| 84 | version: VERSION, | |
| 85 | file, | |
| 86 | href: `/engine/${base}.${sha256.slice(0, 10)}.tar.gz`, | |
| 87 | bytes, | |
| 88 | sha256, | |
| 89 | modules: packed.modules, | |
| 90 | files: entries.map(({ name }) => name), | |
| 91 | unpacked: entries.reduce((sum, { data }) => sum + data.length, 0), | |
| 92 | }; | |
| 93 | } |
src/door.test.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { readFileSync } from "node:fs"; | |
| 3 | import { join } from "node:path"; | |
| 4 | import { isModule, sources } from "../scripts/pack.ts"; | |
| 5 | import { doorModules } from "./door.ts"; | |
| 6 | ||
| 7 | const ENGINE = join(import.meta.dir, "engine"); | |
| 8 | const F = "fr" + "om"; | |
| 9 | ||
| 10 | test("each module once, in first-appearance order, with the comment right above it", () => { | |
| 11 | const source = [ | |
| 12 | "/** The file's own comment, which is nobody's. */", | |
| 13 | "", | |
| 14 | "/** Arithmetic,", | |
| 15 | " * over two lines. */", | |
| 16 | `export { a } ${F} "./math.ts";`, | |
| 17 | `export type { V } ${F} "./math.ts";`, | |
| 18 | `export { b } ${F} "./trace.ts";`, | |
| 19 | "/**", | |
| 20 | " * Types only.", | |
| 21 | " */", | |
| 22 | `export type { T } ${F} "./shape.ts";`, | |
| 23 | ].join("\n"); | |
| 24 | expect(doorModules(source)).toEqual([ | |
| 25 | { module: "math", says: "Arithmetic, over two lines." }, | |
| 26 | { module: "trace", says: "" }, | |
| 27 | { module: "shape", says: "Types only." }, | |
| 28 | ]); | |
| 29 | }); | |
| 30 | ||
| 31 | test("the door's modules are exactly the modules the pack ships, less the door itself", () => { | |
| 32 | const door = doorModules(readFileSync(join(ENGINE, "index.ts"), "utf8")); | |
| 33 | const shipped = sources(ENGINE).filter(isModule).filter((file) => file !== "index.ts"); | |
| 34 | expect(door.map(({ module }) => `${module}.ts`).sort()).toEqual(shipped.sort()); | |
| 35 | // And every one of them is described: the door writes a comment per group. | |
| 36 | for (const { module, says } of door) expect(`${module}: ${says}`.length).toBeGreaterThan(module.length + 12); | |
| 37 | }); |
src/door.ts added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | /** | |
| 2 | * The engine's door, read as a list: which modules `src/engine/index.ts` | |
| 3 | * re-exports, in its order, each with the comment the door writes above it. | |
| 4 | * | |
| 5 | * `/engine` shows a stranger what is in the package, and the honest list is the | |
| 6 | * one the door already keeps — a module and the line or paragraph it says about | |
| 7 | * itself. Typing that list into a page would make a second copy that goes stale | |
| 8 | * the first time a module arrives; reading it off the door makes the page move | |
| 9 | * with it. `boundary.test.ts` pins the *names* the door offers; this is the | |
| 10 | * *modules* those names come from, which is what a reader scanning the package | |
| 11 | * wants first. | |
| 12 | * | |
| 13 | * Plain text in, plain data out: it parses the source it is handed and reads no | |
| 14 | * file itself. The server reads the door once at start-up and hands it here. | |
| 15 | */ | |
| 16 | ||
| 17 | /** One module behind the door: its name (`math`, not `./math.ts`) and what the door says about it. */ | |
| 18 | export type Room = { module: string; says: string }; | |
| 19 | ||
| 20 | /** The text of a `/** … *\/` comment, its stars and line breaks taken out. */ | |
| 21 | const prose = (comment: string): string => | |
| 22 | comment | |
| 23 | .replace(/^\/\*\*|\*\/$/g, "") | |
| 24 | .split("\n") | |
| 25 | .map((line) => line.replace(/^\s*\*\s?/, "").trim()) | |
| 26 | .filter((line) => line !== "") | |
| 27 | .join(" "); | |
| 28 | ||
| 29 | /** | |
| 30 | * Every module the door re-exports from, once, in the order it first appears, | |
| 31 | * with the doc comment immediately above its first `export`. A `type`-only | |
| 32 | * re-export of a module that already has values is the same module; a module | |
| 33 | * the door only offers types from is still a module of the package. | |
| 34 | */ | |
| 35 | export function doorModules(source: string): Room[] { | |
| 36 | const out: Room[] = []; | |
| 37 | const seen = new Set<string>(); | |
| 38 | const pattern = /(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+(?:type\s+)?\{[^}]*\}\s*from\s*["']\.\/([a-z][a-z0-9-]*)\.ts["']/g; | |
| 39 | for (let m = pattern.exec(source); m !== null; m = pattern.exec(source)) { | |
| 40 | const module = m[2] as string; | |
| 41 | if (seen.has(module)) continue; | |
| 42 | seen.add(module); | |
| 43 | out.push({ module, says: m[1] === undefined ? "" : prose(m[1].trim()) }); | |
| 44 | } | |
| 45 | return out; | |
| 46 | } |
src/pages/engine.test.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | import { afterAll, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { VERSION } from "../../scripts/pack.ts"; | |
| 6 | import { buildArchive } from "../archive.ts"; | |
| 7 | import { bytes, kB } from "../bytes.ts"; | |
| 8 | import type { Cost } from "../cost.ts"; | |
| 9 | import { doorModules } from "../door.ts"; | |
| 10 | import { enginePage, guideHtml, headingsOf, slug } from "./engine.ts"; | |
| 11 | ||
| 12 | const ENGINE = join(import.meta.dir, "..", "engine"); | |
| 13 | const GUIDE = readFileSync(join(ENGINE, "GUIDE.md"), "utf8"); | |
| 14 | const DOOR = doorModules(readFileSync(join(ENGINE, "index.ts"), "utf8")); | |
| 15 | ||
| 16 | const DATA = mkdtempSync(join(tmpdir(), "engine-page-")); | |
| 17 | afterAll(() => rmSync(DATA, { recursive: true, force: true })); | |
| 18 | const ARCHIVE = buildArchive(DATA); | |
| 19 | ||
| 20 | /** Figures nobody measured, so a digit on the page is one this test handed it. */ | |
| 21 | const COST: Cost = { script: { raw: 12_345, gzip: 4_321 }, shooter: { raw: 98_765, gzip: 34_567 }, game: 432, wiring: 219 }; | |
| 22 | ||
| 23 | const render = (guide = GUIDE): string => | |
| 24 | enginePage({ env: "local", pull: undefined, commit: "abc1234", ssh: undefined, archive: ARCHIVE, cost: COST, door: DOOR, guide }); | |
| 25 | ||
| 26 | test("a heading becomes an anchor to itself; a # inside a code block does not", () => { | |
| 27 | const guide = "# Title\n\ntext\n\n## Two & <three>\n\n```ts\n# not a heading\n```\n\n### math\n\n## Two & <three>\n"; | |
| 28 | const headings = headingsOf(guide); | |
| 29 | expect(headings).toEqual([ | |
| 30 | { level: 1, text: "Title", id: "guide-title" }, | |
| 31 | { level: 2, text: "Two & <three>", id: "guide-two-three" }, | |
| 32 | { level: 3, text: "math", id: "guide-math" }, | |
| 33 | { level: 2, text: "Two & <three>", id: "guide-two-three-2" }, | |
| 34 | ]); | |
| 35 | const html = guideHtml(guide); | |
| 36 | expect(html).toContain(`<span class="h" id="guide-two-three"><a href="#guide-two-three">## Two & <three></a></span>`); | |
| 37 | expect(html).toContain(`id="guide-two-three-2"`); | |
| 38 | expect(html).toContain("\n# not a heading\n"); | |
| 39 | expect(slug("1. An entry")).toBe("1-an-entry"); | |
| 40 | expect(slug("???")).toBe("section"); | |
| 41 | }); | |
| 42 | ||
| 43 | test("the guide is on the page whole, escaped, with every heading an anchor in the contents", () => { | |
| 44 | const body = render(); | |
| 45 | const headings = headingsOf(GUIDE); | |
| 46 | expect(headings.length).toBeGreaterThan(30); | |
| 47 | for (const { id, level } of headings) { | |
| 48 | expect(body).toContain(`<span class="h" id="${id}">`); | |
| 49 | if (level > 1) expect(body).toContain(`<a href="#${id}">`); | |
| 50 | } | |
| 51 | expect(body).toContain(`id="guide-what-a-game-brings"`); | |
| 52 | // Escaped: a guide line with markup in it cannot become markup. | |
| 53 | const hostile = render("# Hi\n\n<script>alert(1)</script> & \"q\"\n"); | |
| 54 | expect(hostile).not.toContain("<script>alert"); | |
| 55 | expect(hostile).toContain("<script>alert(1)</script> & "q""); | |
| 56 | // And the page is honest that it is text, not a rendering. | |
| 57 | expect(body).toContain("It is not rendered as markdown"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the page lists exactly the modules the door exports, each linked to its section of the guide", () => { | |
| 61 | const body = render(); | |
| 62 | const listed = [...body.matchAll(/<li data-module="([^"]+)">/g)].map((m) => m[1]); | |
| 63 | const exported = ARCHIVE.modules.filter((file) => file !== "index.ts").map((file) => file.replace(/\.ts$/, "")); | |
| 64 | expect(listed.sort()).toEqual(exported.sort()); | |
| 65 | expect(body).toContain(`<li data-module="math"><a href="#guide-math"><code>math</code></a> — Vectors, matrices`); | |
| 66 | }); | |
| 67 | ||
| 68 | test("the package is offered as it is: its version, its href, its size and hash, and the sizes handed in", () => { | |
| 69 | const body = render(); | |
| 70 | expect(body).toContain(`<b>cs-engine ${VERSION}</b>`); | |
| 71 | expect(body).toContain(`href="${ARCHIVE.href}" download="cs-engine-${VERSION}.tar.gz"`); | |
| 72 | expect(body).toContain(`${bytes(ARCHIVE.bytes.byteLength)} bytes (${kB(ARCHIVE.bytes.byteLength)}) gzipped`); | |
| 73 | expect(body).toContain(ARCHIVE.sha256); | |
| 74 | expect(body).toContain(`${ARCHIVE.modules.length} TypeScript modules behind one door`); | |
| 75 | expect(body).toContain(`tar -xzf cs-engine-${VERSION}.tar.gz\nbun build cs-engine-${VERSION}/example.ts`); | |
| 76 | // The cost story is the `Cost` it was handed, formatted by `bytes.ts`. | |
| 77 | for (const said of ["432 lines", "219 lines", "12,345 B", kB(4_321), "98,765 B", kB(34_567)]) expect(body).toContain(said); | |
| 78 | expect(body).toContain("abc1234"); | |
| 79 | // Nothing runs on it. | |
| 80 | expect(body).not.toContain("<script"); | |
| 81 | }); |
src/pages/engine.ts added +245
| @@ -0,0 +1,245 @@ | ||
| 1 | /** | |
| 2 | * The engine's page: what the package is, the one file to take it as, what a | |
| 3 | * game costs on it, the modules behind its door, and its guide — at `/engine`, | |
| 4 | * for somebody who has not cloned this repository and should not have to. | |
| 5 | * | |
| 6 | * Nothing on it is typed that could be read instead. The version and the | |
| 7 | * archive's size and hash are what `src/archive.ts` built at start-up; the | |
| 8 | * sizes a game costs are `src/cost.ts`'s, the same `Cost` `/example` prints; | |
| 9 | * the modules are `src/door.ts` reading `src/engine/index.ts`; and the guide is | |
| 10 | * `src/engine/GUIDE.md`, the file that ships in the archive. The server reads | |
| 11 | * all of those once, renders this once, and serves the bytes: nothing here | |
| 12 | * touches the filesystem, and nothing here runs per request. | |
| 13 | * | |
| 14 | * The guide is shown as what it is — its own text, escaped and preformatted — | |
| 15 | * with an anchor on every heading and a contents list over it. There is no | |
| 16 | * markdown renderer, because that is a dependency or a second parser to own, | |
| 17 | * and the page says so rather than pretending the text is a rendering. | |
| 18 | */ | |
| 19 | ||
| 20 | import { ARCHIVE_LEVEL, type Archive } from "../archive.ts"; | |
| 21 | import { bytes, kB } from "../bytes.ts"; | |
| 22 | import { type Cost, GZIP_LEVEL } from "../cost.ts"; | |
| 23 | import type { Room } from "../door.ts"; | |
| 24 | import { navFor } from "./games.ts"; | |
| 25 | import { escape, page, type PageOptions as ShellOptions } from "./shell.ts"; | |
| 26 | ||
| 27 | /** Where the page answers. The example links it; `src/server.ts` routes it. */ | |
| 28 | export const ENGINE_HREF = "/engine"; | |
| 29 | ||
| 30 | /** Every heading's id starts with this, so no heading can take an id the shell uses. */ | |
| 31 | const ID_PREFIX = "guide-"; | |
| 32 | ||
| 33 | /** | |
| 34 | * This page's own CSS. The guide is 80-column text and a phone is not, so the | |
| 35 | * preformatted block wraps instead of scrolling the page sideways; the contents | |
| 36 | * run in columns so forty links are not forty screens; and every link a thumb | |
| 37 | * has to hit is 44px tall. On paper the chrome goes, the guide stays, and the | |
| 38 | * colours become ink. | |
| 39 | */ | |
| 40 | export const STYLE = ` | |
| 41 | #take .download { display:flex; flex-wrap:wrap; gap:.5rem 1rem; align-items:center; } | |
| 42 | #take a.get { display:inline-flex; align-items:center; min-height:44px; padding:.6rem 1.1rem; border-radius:.45rem; | |
| 43 | background:#2563eb; color:#fff; font-weight:600; text-decoration:none; } | |
| 44 | #take a.get:focus-visible { outline:2px solid var(--ct); outline-offset:2px; } | |
| 45 | #take .download span, #take .hash, #guide .said { color:var(--dim); font-size:.86rem; } | |
| 46 | #take .hash code { overflow-wrap:anywhere; } | |
| 47 | pre { background:var(--panel); border:1px solid var(--line); border-radius:.45rem; padding:.75rem; | |
| 48 | white-space:pre-wrap; overflow-wrap:anywhere; font-size:.82rem; line-height:1.45; } | |
| 49 | #engine-cost td.n, #engine-cost th.n { text-align:right; white-space:nowrap; } | |
| 50 | ul.door { list-style:none; padding:0; margin:.5rem 0; font-size:.88rem; } | |
| 51 | ul.door li { padding:.35rem 0; border-bottom:1px solid var(--line); } | |
| 52 | ul.door li > a, ul.door li > code { display:inline-flex; align-items:center; min-height:44px; min-width:44px; } | |
| 53 | ol.contents { columns:15rem; margin:.5rem 0; padding-left:1.2rem; font-size:.88rem; } | |
| 54 | ol.contents li { break-inside:avoid; } | |
| 55 | ol.contents li.l3 { margin-left:1rem; list-style:circle; } | |
| 56 | ol.contents a { display:inline-flex; align-items:center; min-height:44px; } | |
| 57 | a { color:var(--ct); } | |
| 58 | pre.guide .h { color:var(--t); font-weight:700; } | |
| 59 | pre.guide .h a { color:inherit; text-decoration:none; } | |
| 60 | pre.guide .h a:hover, pre.guide .h a:focus-visible { text-decoration:underline; } | |
| 61 | @media print { | |
| 62 | header nav, header .env, header small.commit, footer, p.terminal, #take .download a.get { display:none; } | |
| 63 | body { background:#fff; color:#000; } | |
| 64 | pre { background:#fff; border:0; padding:0; font-size:8pt; } | |
| 65 | pre.guide .h, a, #take .download span, #take .hash, #guide .said { color:#000; } | |
| 66 | ol.contents a { min-height:0; } | |
| 67 | } | |
| 68 | `; | |
| 69 | ||
| 70 | /** One heading of the guide: its depth, its words, and the id its anchor carries. */ | |
| 71 | export type Heading = { level: number; text: string; id: string }; | |
| 72 | ||
| 73 | /** A heading's words as an id: lower-case letters, digits and single dashes. */ | |
| 74 | export const slug = (text: string): string => | |
| 75 | text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section"; | |
| 76 | ||
| 77 | /** A line that opens or closes a fenced code block, where a `#` is code, not a heading. */ | |
| 78 | const FENCE = /^\s*```/; | |
| 79 | ||
| 80 | /** `## What a game brings` → level 2 and its words; anything else is not a heading. */ | |
| 81 | const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/; | |
| 82 | ||
| 83 | /** | |
| 84 | * Every heading of the guide, outside its code blocks, in order, each with an | |
| 85 | * id nobody else on the page has. Two headings with the same words get `-2`, | |
| 86 | * `-3` after the second and third, the way every renderer does it. | |
| 87 | */ | |
| 88 | export function headingsOf(guide: string): Heading[] { | |
| 89 | const out: Heading[] = []; | |
| 90 | const used = new Map<string, number>(); | |
| 91 | let fenced = false; | |
| 92 | for (const line of guide.split("\n")) { | |
| 93 | if (FENCE.test(line)) fenced = !fenced; | |
| 94 | const m = fenced ? null : HEADING.exec(line); | |
| 95 | if (m === null) continue; | |
| 96 | const text = m[2] as string; | |
| 97 | const base = ID_PREFIX + slug(text); | |
| 98 | const seen = (used.get(base) ?? 0) + 1; | |
| 99 | used.set(base, seen); | |
| 100 | out.push({ level: (m[1] as string).length, text, id: seen === 1 ? base : `${base}-${seen}` }); | |
| 101 | } | |
| 102 | return out; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * The guide as one preformatted block: every line its own text, escaped, and | |
| 107 | * every heading line wrapped in an anchor to itself so a link to one section | |
| 108 | * is a thing a person can copy. | |
| 109 | */ | |
| 110 | export function guideHtml(guide: string, headings = headingsOf(guide)): string { | |
| 111 | let next = 0; | |
| 112 | let fenced = false; | |
| 113 | const lines = guide.replace(/\n$/, "").split("\n").map((line) => { | |
| 114 | if (FENCE.test(line)) fenced = !fenced; | |
| 115 | const heading = !fenced && HEADING.test(line) ? headings[next++] : undefined; | |
| 116 | if (heading === undefined) return escape(line); | |
| 117 | return `<span class="h" id="${escape(heading.id)}"><a href="#${escape(heading.id)}">${escape(line)}</a></span>`; | |
| 118 | }); | |
| 119 | return `<pre class="guide">${lines.join("\n")}</pre>`; | |
| 120 | } | |
| 121 | ||
| 122 | /** The contents: a link to every heading below the title, a sub-heading indented under its section. */ | |
| 123 | export const contentsHtml = (headings: readonly Heading[]): string => | |
| 124 | `<nav aria-label="The guide's contents"><ol class="contents">${headings | |
| 125 | .filter(({ level }) => level > 1) | |
| 126 | .map(({ level, text, id }) => `<li class="l${Math.min(level, 3)}"><a href="#${escape(id)}">${escape(text)}</a></li>`) | |
| 127 | .join("")}</ol></nav>`; | |
| 128 | ||
| 129 | /** | |
| 130 | * The modules behind the door, each as the door describes it — and linked to | |
| 131 | * the guide's section on it when the guide has a heading of that name, which | |
| 132 | * today is every one of them. | |
| 133 | */ | |
| 134 | export function doorHtml(door: readonly Room[], headings: readonly Heading[]): string { | |
| 135 | const ids = new Map(headings.map(({ text, id }) => [text, id])); | |
| 136 | return `<ul class="door">${door | |
| 137 | .map(({ module, says }) => { | |
| 138 | const id = ids.get(module); | |
| 139 | const name = id === undefined | |
| 140 | ? `<code>${escape(module)}</code>` | |
| 141 | : `<a href="#${escape(id)}"><code>${escape(module)}</code></a>`; | |
| 142 | return `<li data-module="${escape(module)}">${name}${says === "" ? "" : ` — ${escape(says)}`}</li>`; | |
| 143 | }) | |
| 144 | .join("")}</ul>`; | |
| 145 | } | |
| 146 | ||
| 147 | /** The package, what it holds and the one file to take it as. */ | |
| 148 | export function takeHtml(archive: Archive, commit: string): string { | |
| 149 | const { name, version, file, href, sha256, modules, files, unpacked } = archive; | |
| 150 | const top = `${name}-${version}`; | |
| 151 | return ` <section id="take"> | |
| 152 | <h2>Take the engine</h2> | |
| 153 | <p><b>${escape(name)} ${escape(version)}</b> is the engine every game on this server is built on, as a | |
| 154 | package: ${modules.length} TypeScript modules behind one door, <code>index.ts</code>, and no | |
| 155 | dependencies — its <code>package.json</code> has no dependencies key at all, because there is | |
| 156 | nothing to install. It ships as source: no build step, no compiled copy, no types to fetch. | |
| 157 | Beside the modules are the guide, <code>GUIDE.md</code>, which is the whole of the text below, and | |
| 158 | the guide's game, <code>example.ts</code>, the sample you build first.</p> | |
| 159 | <p class="download"><a class="get" href="${escape(href)}" download="${escape(file)}">Download ${escape(file)}</a> | |
| 160 | <span>${bytes(archive.bytes.byteLength)} bytes (${kB(archive.bytes.byteLength)}) gzipped · | |
| 161 | ${files.length} files, ${bytes(unpacked)} bytes unpacked</span></p> | |
| 162 | <p class="hash">sha256 <code>${escape(sha256)}</code></p> | |
| 163 | <p>Start here, with <a href="https://bun.sh">Bun</a> and nothing else:</p> | |
| 164 | <pre>tar -xzf ${escape(file)} | |
| 165 | bun build ${escape(top)}/example.ts --outdir out</pre> | |
| 166 | <p>The first line leaves one directory, <code>${escape(top)}/</code>. The second builds the sample against | |
| 167 | the door in it, and nothing else on your disk: this repository's own tests pack the engine the same | |
| 168 | way and assert that every module the bundler read came out of the copy. The version is the engine's | |
| 169 | surface — it moves when a name the door offers arrives, leaves or is renamed — and not its | |
| 170 | behaviour, so the commit is the real identity of a pack${commit === "" ? "" : `; this one was packed | |
| 171 | at <code>${escape(commit)}</code>`}. The archive is written by this server when it starts, from the | |
| 172 | tree it runs, and the same tree always packs to the same bytes.</p> | |
| 173 | </section>`; | |
| 174 | } | |
| 175 | ||
| 176 | /** | |
| 177 | * The sizes the guide's cost story is made of, as a table: the lines a game | |
| 178 | * brings, the lines of wiring the example needed, the bytes of its bundle | |
| 179 | * against the shooter's, and the bytes of the package itself. Every figure is | |
| 180 | * handed in; `../bytes.ts` writes each one. | |
| 181 | */ | |
| 182 | export function costHtml(cost: Cost, archive: Archive): string { | |
| 183 | const row = (what: string, raw: string, gzip: string): string => | |
| 184 | `<tr><td>${what}</td><td class="n">${raw}</td><td class="n">${gzip}</td></tr>`; | |
| 185 | return ` <section id="engine-cost"> | |
| 186 | <h2>What a game costs on it</h2> | |
| 187 | <p>Measured by this server when it started, from the bundles it built and the sources it runs — the | |
| 188 | same figures <a href="/example">the example</a> writes its two paragraphs out of. The bundles are | |
| 189 | gzipped at level ${GZIP_LEVEL} and the package at level ${ARCHIVE_LEVEL}, and a kB is 1,024 bytes.</p> | |
| 190 | <table> | |
| 191 | <thead><tr><th></th><th class="n">as written</th><th class="n">gzipped</th></tr></thead> | |
| 192 | <tbody> | |
| 193 | ${row("the guide's game, <code>src/engine/example.ts</code>", `${bytes(cost.game)} lines`, "—")} | |
| 194 | ${row("the wiring that gave it a page", `${bytes(cost.wiring)} lines`, "—")} | |
| 195 | ${row("the example's bundle", `${bytes(cost.script.raw)} B`, kB(cost.script.gzip))} | |
| 196 | ${row("the shooter's bundle, for scale", `${bytes(cost.shooter.raw)} B`, kB(cost.shooter.gzip))} | |
| 197 | ${row("this package, whole", `${bytes(archive.unpacked)} B`, kB(archive.bytes.byteLength))} | |
| 198 | </tbody> | |
| 199 | </table> | |
| 200 | </section>`; | |
| 201 | } | |
| 202 | ||
| 203 | /** What the shell needs that this page cannot know, and everything it prints. */ | |
| 204 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "ssh"> & { | |
| 205 | archive: Archive; | |
| 206 | cost: Cost; | |
| 207 | door: readonly Room[]; | |
| 208 | guide: string; | |
| 209 | }; | |
| 210 | ||
| 211 | /** | |
| 212 | * The page, whole. Like `/weapons` it hands the shell no script — nothing on it | |
| 213 | * runs — and its nav marks nothing current, because it is not a game. | |
| 214 | */ | |
| 215 | export function enginePage({ archive, cost, door, guide, ...options }: PageOptions): string { | |
| 216 | const headings = headingsOf(guide); | |
| 217 | return page({ | |
| 218 | ...options, | |
| 219 | title: `engine — ${archive.name} ${archive.version}, its guide and the package`, | |
| 220 | description: | |
| 221 | `The engine every game here runs on, as one download: ${archive.modules.length} TypeScript modules behind one door, no dependencies, its guide and a sample game.`, | |
| 222 | heading: "engine", | |
| 223 | nav: navFor(ENGINE_HREF), | |
| 224 | terminalLead: "The games' boards in a terminal:", | |
| 225 | footer: "no database, no cookie: this page is the package and its guide", | |
| 226 | extraStyle: STYLE, | |
| 227 | stageHtml: "", | |
| 228 | sectionsHtml: `${takeHtml(archive, options.commit)} | |
| 229 | ${costHtml(cost, archive)} | |
| 230 | <section id="door"> | |
| 231 | <h2>Behind the door</h2> | |
| 232 | <p>Every module <code>index.ts</code> re-exports, in its order, with what the door says about it. A game | |
| 233 | imports from the door and from nowhere deeper.</p> | |
| 234 | ${doorHtml(door, headings)} | |
| 235 | </section> | |
| 236 | <section id="guide"> | |
| 237 | <h2>The guide</h2> | |
| 238 | <p class="said"><code>GUIDE.md</code> as it ships in the archive: its own text, escaped and | |
| 239 | preformatted, with an anchor on every heading. It is not rendered as markdown — there is no | |
| 240 | renderer here and no dependency for one; a prettier reading is a later change, not this one.</p> | |
| 241 | ${contentsHtml(headings)} | |
| 242 | ${guideHtml(guide, headings)} | |
| 243 | </section>`, | |
| 244 | }); | |
| 245 | } |
src/pages/example.ts +3
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | import { bytes, kB } from "../bytes.ts"; |
| 14 | 14 | import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts"; |
| 15 | 15 | import { type Cost, GZIP_LEVEL } from "../cost.ts"; |
| 16 | import { ENGINE_HREF } from "./engine.ts"; | |
| 16 | 17 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; |
| 17 | 18 | import { page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 18 | 19 | |
| @@ -105,6 +106,8 @@ export function examplePage(options: PageOptions): string { | ||
| 105 | 106 | sectionsHtml: ` <section> |
| 106 | 107 | <h2>What this cost</h2> |
| 107 | 108 | ${costHtml(options.cost)} |
| 109 | <p>The engine is yours to take: <a href="${ENGINE_HREF}">the package, its guide and every module behind | |
| 110 | its door</a>, as one download.</p> | |
| 108 | 111 | <p>Nothing on this page is stored: no endpoint, no table and no cookie. The leaderboard, the |
| 109 | 112 | database and the terminal belong to the games next door: ${othersHtml("/example")}</p> |
| 110 | 113 | </section>`, |
src/server.test.ts +69 −3
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 2 | import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { bundle } from "../scripts/build.ts"; |
| @@ -13,6 +13,10 @@ import { LONGEST_PASTE } from "./matchlink.ts"; | ||
| 13 | 13 | import { NOTHING_POSTED } from "./client/pending.ts"; |
| 14 | 14 | import { BAD_ID, BAD_MAP, UNDRAWN } from "./pages/game.ts"; |
| 15 | 15 | import { WEAPONS_HREF, WEAPONS_SECTION } from "./pages/weapons.ts"; |
| 16 | import { ENGINE_HREF } from "./pages/engine.ts"; | |
| 17 | import { archiveDir } from "./archive.ts"; | |
| 18 | import { untar } from "./tar.ts"; | |
| 19 | import { NAME as PACKAGE, VERSION } from "../scripts/pack.ts"; | |
| 16 | 20 | import { WEAPONS } from "./client/weapons.ts"; |
| 17 | 21 | import { endpoints, escape } from "./pages/shell.ts"; |
| 18 | 22 | import { localPlan, parsePlan } from "./plan/plan.ts"; |
| @@ -578,6 +582,61 @@ test("the reference stands on the same security floor as the games, and HEAD ans | ||
| 578 | 582 | expect((await fetch(base + "/weapons/ak47")).status).toBe(404); |
| 579 | 583 | }); |
| 580 | 584 | |
| 585 | test("the engine's page answers on the floor, links the archive, and the example links it", async () => { | |
| 586 | const [game, page] = await Promise.all([fetch(base + "/"), fetch(base + ENGINE_HREF)]); | |
| 587 | expect(page.status).toBe(200); | |
| 588 | expect(floorOf(page)).toEqual(floorOf(game)); | |
| 589 | const body = await page.text(); | |
| 590 | expect(body).toContain("<h1>engine</h1>"); | |
| 591 | expect(body).toContain(`<b>${PACKAGE} ${VERSION}</b>`); | |
| 592 | expect(body).not.toContain("<script"); | |
| 593 | expect(page.headers.get("set-cookie")).toBeNull(); | |
| 594 | const head = await server.fetch(new Request(base + ENGINE_HREF, { method: "HEAD" })); | |
| 595 | expect(head.status).toBe(200); | |
| 596 | expect(await head.text()).toBe(""); | |
| 597 | expect(await (await fetch(base + "/example")).text()).toContain(`href="${ENGINE_HREF}"`); | |
| 598 | }); | |
| 599 | ||
| 600 | test("the archive route answers with the bytes this app wrote under FORGE_APP_DATA, their length, and a year", async () => { | |
| 601 | const body = await (await fetch(base + ENGINE_HREF)).text(); | |
| 602 | const href = new RegExp(`href="(/engine/${PACKAGE}-${VERSION.replaceAll(".", "\\.")}\\.[0-9a-f]{10}\\.tar\\.gz)"`).exec(body)?.[1]; | |
| 603 | expect(href).toBeDefined(); | |
| 604 | const file = join(archiveDir(), `${PACKAGE}-${VERSION}.tar.gz`); | |
| 605 | const onDisk = new Uint8Array(readFileSync(file)); | |
| 606 | // Asked for gzip or not, it is the file as written: it is gzip already. | |
| 607 | const response = await fetch(base + (href as string), { headers: { "accept-encoding": "gzip" }, decompress: false }); | |
| 608 | expect(response.status).toBe(200); | |
| 609 | expect(response.headers.get("content-type")).toBe("application/gzip"); | |
| 610 | expect(response.headers.get("content-encoding")).toBeNull(); | |
| 611 | expect(response.headers.get("content-length")).toBe(String(onDisk.byteLength)); | |
| 612 | expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); | |
| 613 | expect(response.headers.get("content-disposition")).toBe(`attachment; filename="${PACKAGE}-${VERSION}.tar.gz"`); | |
| 614 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 615 | expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); | |
| 616 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 617 | const sent = new Uint8Array(await response.arrayBuffer()); | |
| 618 | expect(sent).toEqual(onDisk); | |
| 619 | // The href's hash is the bytes' hash. | |
| 620 | const hex = new Bun.CryptoHasher("sha256").update(sent).digest("hex"); | |
| 621 | expect(href).toContain(`.${hex.slice(0, 10)}.tar.gz`); | |
| 622 | expect(body).toContain(hex); | |
| 623 | // And it unpacks to the engine, at its version. | |
| 624 | const pkg = untar(Bun.gunzipSync(sent)).find(({ name }) => name === `${PACKAGE}-${VERSION}/package.json`); | |
| 625 | expect(JSON.parse(new TextDecoder().decode(pkg?.data)).version).toBe(VERSION); | |
| 626 | ||
| 627 | const head = await server.fetch(new Request(base + (href as string), { method: "HEAD" })); | |
| 628 | expect(head.status).toBe(200); | |
| 629 | expect(head.headers.get("content-length")).toBe(String(onDisk.byteLength)); | |
| 630 | expect(await head.text()).toBe(""); | |
| 631 | }); | |
| 632 | ||
| 633 | test("no other name under /engine is a file: the one href answers and the rest is the 404", async () => { | |
| 634 | for (const path of [ | |
| 635 | `/engine/${PACKAGE}-${VERSION}.tar.gz`, `/engine/${PACKAGE}-${VERSION}/package.json`, | |
| 636 | "/engine/", "/engine/..%2f..%2fsrc%2fserver.ts", `/engine/${PACKAGE}-0.0.0.0000000000.tar.gz`, | |
| 637 | ]) expect((await fetch(base + path)).status).toBe(404); | |
| 638 | }); | |
| 639 | ||
| 581 | 640 | test("the shooter links the reference instead of carrying it", async () => { |
| 582 | 641 | const body = await (await fetch(base + "/")).text(); |
| 583 | 642 | expect(body).toContain(`href="/weapons"`); |
| @@ -651,7 +710,7 @@ test("the policy names each page's own style block, hashed from the bytes the pa | ||
| 651 | 710 | const seen = new Set<string>(); |
| 652 | 711 | // Every game, and the reference, which is not one: it is a read route on the |
| 653 | 712 | // same terms, so its block is hashed into its own policy like theirs. |
| 654 | for (const href of [...GAMES.map((game) => game.href), WEAPONS_HREF]) { | |
| 713 | for (const href of [...GAMES.map((game) => game.href), WEAPONS_HREF, ENGINE_HREF]) { | |
| 655 | 714 | const response = await fetch(base + href); |
| 656 | 715 | const block = styleBlock(await response.text()); |
| 657 | 716 | expect(block.length).toBeGreaterThan(500); |
| @@ -1178,8 +1237,15 @@ test("every file in static/ is gzipped once, at start-up, and held beside its by | ||
| 1178 | 1237 | * `/` is measured on this file's database, which by now holds every match the |
| 1179 | 1238 | * tests above posted, so it is the page at its heaviest rather than at its |
| 1180 | 1239 | * emptiest: 8,830 gzipped against 26,188 when this was written. |
| 1240 | * | |
| 1241 | * `/engine` is the exception in size and not in kind: it carries the whole | |
| 1242 | * guide, 78 kB of text, because reading it without cloning is the point of the | |
| 1243 | * page. Measured 2026-09-23 at 37,166 gzipped against 104,653 raw; the budget | |
| 1244 | * is that plus room for the guide to grow a section or two. | |
| 1181 | 1245 | */ |
| 1182 | const BUDGET: Record<string, number> = { "/": 10500, "/weapons": 5600, "/range": 7500, "/example": 5500 }; | |
| 1246 | const BUDGET: Record<string, number> = { | |
| 1247 | "/": 10500, "/weapons": 5600, "/range": 7500, "/example": 5500, "/engine": 40000, | |
| 1248 | }; | |
| 1183 | 1249 | |
| 1184 | 1250 | test("a first visit stays inside its byte budget, compressed", async () => { |
| 1185 | 1251 | for (const [path, budget] of Object.entries(BUDGET)) { |
src/server.ts +50 −1
| @@ -48,6 +48,8 @@ | ||
| 48 | 48 | import { existsSync, readdirSync, readFileSync } from "node:fs"; |
| 49 | 49 | import { join } from "node:path"; |
| 50 | 50 | import { assetOf, bundleOf } from "../scripts/build.ts"; |
| 51 | import { ENGINE } from "../scripts/pack.ts"; | |
| 52 | import { archiveDir, buildArchive } from "./archive.ts"; | |
| 51 | 53 | import { bind } from "./bind.ts"; |
| 52 | 54 | import { buildBundles, bundlesDir, buildSummary } from "./bundles.ts"; |
| 53 | 55 | import { kB } from "./bytes.ts"; |
| @@ -57,6 +59,8 @@ import { openDatabase, saveMatch, saveRun } from "./db.ts"; | ||
| 57 | 59 | import { admit, type Gate, playerOf, readBody, refused } from "./gate.ts"; |
| 58 | 60 | import { GAMES } from "./games.ts"; |
| 59 | 61 | import { createLimiter } from "./limit.ts"; |
| 62 | import { doorModules } from "./door.ts"; | |
| 63 | import { ENGINE_HREF, enginePage, STYLE as ENGINE_STYLE } from "./pages/engine.ts"; | |
| 60 | 64 | import { examplePage } from "./pages/example.ts"; |
| 61 | 65 | import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts"; |
| 62 | 66 | import { mapFrom } from "./mapname.ts"; |
| @@ -629,6 +633,50 @@ const REFERENCE: Ready = { | ||
| 629 | 633 | }; |
| 630 | 634 | READ.set(WEAPONS_HREF, () => REFERENCE); |
| 631 | 635 | |
| 636 | /** | |
| 637 | * The engine, as a package a visitor can take: `src/archive.ts` packs | |
| 638 | * `src/engine/` into `FORGE_APP_DATA/engine`, tars and gzips it, and hands back | |
| 639 | * the bytes it wrote — once, here, before this listens. It is a file copy and a | |
| 640 | * gzip of about 340 kB, measured at 30 ms on a laptop and printed in the boot | |
| 641 | * log; a request never builds an archive and never opens a file. | |
| 642 | * | |
| 643 | * Two exact paths answer for it and nothing else does: `/engine`, the page, | |
| 644 | * rendered and weighed once like `/weapons`, and the archive at the one href | |
| 645 | * `buildArchive` named — the version and a slice of the archive's own sha256 — | |
| 646 | * which is why it may be cached for a year. No part of either path is read | |
| 647 | * from the request: a name that is not that href is the 404. | |
| 648 | */ | |
| 649 | const archivedFrom = Bun.nanoseconds(); | |
| 650 | const ARCHIVE = buildArchive(archiveDir()); | |
| 651 | const archivedIn = (Bun.nanoseconds() - archivedFrom) / 1e6; | |
| 652 | const ENGINE_SECURITY = documentFloor(ENGINE_STYLE); | |
| 653 | const ENGINE_PAGE: Ready = { | |
| 654 | body: weighText( | |
| 655 | enginePage({ | |
| 656 | env, pull, commit, ssh, archive: ARCHIVE, cost: exampleCost, | |
| 657 | door: doorModules(readFileSync(join(ENGINE, "index.ts"), "utf8")), | |
| 658 | guide: readFileSync(join(ENGINE, "GUIDE.md"), "utf8"), | |
| 659 | }), | |
| 660 | HTML_TYPE, | |
| 661 | ), | |
| 662 | status: 200, | |
| 663 | headers: { "content-type": HTML_TYPE, ...ENGINE_SECURITY }, | |
| 664 | }; | |
| 665 | READ.set(ENGINE_HREF, () => ENGINE_PAGE); | |
| 666 | // Already gzip, so never gzipped again: `compressible` says no to this type, | |
| 667 | // and the bytes go out as they are, with their length and the name to save them as. | |
| 668 | const DOWNLOAD: Ready = { | |
| 669 | body: { raw: ARCHIVE.bytes, gzip: null }, | |
| 670 | status: 200, | |
| 671 | headers: { | |
| 672 | "content-type": "application/gzip", | |
| 673 | "content-length": String(ARCHIVE.bytes.byteLength), | |
| 674 | "content-disposition": `attachment; filename="${ARCHIVE.file}"`, | |
| 675 | ...IMMUTABLE, | |
| 676 | }, | |
| 677 | }; | |
| 678 | READ.set(ARCHIVE.href, () => DOWNLOAD); | |
| 679 | ||
| 632 | 680 | // The two pages no game owns, rendered through the same shell every game is |
| 633 | 681 | // served inside: `src/pages/miss.ts` writes both. A wrong path is a dead end |
| 634 | 682 | // unless it says where the games are, so the 404 names every one of them and |
| @@ -930,7 +978,8 @@ if (listen.unusable !== undefined) | ||
| 930 | 978 | |
| 931 | 979 | console.log( |
| 932 | 980 | `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${[...scripts.values()].join(", ")}` + |
| 933 | ` — ${buildSummary(built, builtIn, BUNDLES)} — ${staticSummary()} — up to ${ROOM_CAP} rooms on ${SOCKET_PATH}` + | |
| 981 | ` — ${buildSummary(built, builtIn, BUNDLES)} — ${staticSummary()}` + | |
| 982 | ` — ${ARCHIVE.file} packed, ${ARCHIVE.bytes.byteLength} B, in ${archivedIn.toFixed(1)} ms — up to ${ROOM_CAP} rooms on ${SOCKET_PATH}` + | |
| 934 | 983 | `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}` + |
| 935 | 984 | `${planner ? "" : " — no TYPESAFE_API_KEY: the bots plan their rounds locally"}`, |
| 936 | 985 | ); |
src/tar.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { BLOCK, checksum, header, splitName, tar, untar } from "./tar.ts"; | |
| 6 | ||
| 7 | const bytes = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const ascii = (b: Uint8Array): string => new TextDecoder().decode(b); | |
| 9 | ||
| 10 | test("a header is 512 bytes laid out the way ustar says, fields at their offsets", () => { | |
| 11 | const block = header("pkg/index.ts", 1234, 0o17); | |
| 12 | expect(block.length).toBe(BLOCK); | |
| 13 | expect(ascii(block.subarray(0, 12))).toBe("pkg/index.ts"); | |
| 14 | expect(block[12]).toBe(0); | |
| 15 | expect(ascii(block.subarray(100, 108))).toBe("0000644\0"); | |
| 16 | expect(ascii(block.subarray(108, 116))).toBe("0000000\0"); | |
| 17 | expect(ascii(block.subarray(116, 124))).toBe("0000000\0"); | |
| 18 | // 1234 is 2322 in octal, padded to eleven digits and a NUL. | |
| 19 | expect(ascii(block.subarray(124, 136))).toBe("00000002322\0"); | |
| 20 | expect(ascii(block.subarray(136, 148))).toBe("00000000017\0"); | |
| 21 | expect(String.fromCharCode(block[156] as number)).toBe("0"); | |
| 22 | expect(ascii(block.subarray(257, 263))).toBe("ustar\0"); | |
| 23 | expect(ascii(block.subarray(263, 265))).toBe("00"); | |
| 24 | expect(block.subarray(345, 512).every((b) => b === 0)).toBe(true); | |
| 25 | }); | |
| 26 | ||
| 27 | test("the checksum is the byte sum with its own field read as spaces, written as six octal digits, NUL, space", () => { | |
| 28 | const block = header("a.ts", 0); | |
| 29 | // By hand: every non-zero byte of the header, plus eight spaces for the field. | |
| 30 | let sum = 8 * 0x20; | |
| 31 | for (let i = 0; i < BLOCK; i++) if (i < 148 || i >= 156) sum += block[i] as number; | |
| 32 | expect(checksum(block)).toBe(sum); | |
| 33 | expect(ascii(block.subarray(148, 156))).toBe(`${sum.toString(8).padStart(6, "0")}\0 `); | |
| 34 | }); | |
| 35 | ||
| 36 | test("data is padded to whole blocks and the archive ends in two zero blocks", () => { | |
| 37 | const archive = tar([ | |
| 38 | { name: "one.txt", data: bytes("x") }, | |
| 39 | { name: "empty.txt", data: new Uint8Array(0) }, | |
| 40 | { name: "exact.bin", data: new Uint8Array(BLOCK).fill(7) }, | |
| 41 | ]); | |
| 42 | // one: header + 1 data block; empty: header only; exact: header + 1; end: 2. | |
| 43 | expect(archive.length).toBe(BLOCK * (2 + 1 + 2 + 2)); | |
| 44 | expect(archive.length % BLOCK).toBe(0); | |
| 45 | expect(archive.subarray(BLOCK + 1, 2 * BLOCK).every((b) => b === 0)).toBe(true); | |
| 46 | expect(archive.subarray(archive.length - 2 * BLOCK).every((b) => b === 0)).toBe(true); | |
| 47 | }); | |
| 48 | ||
| 49 | test("an archive reads back to the files that went in, with every checksum checked", () => { | |
| 50 | const long = `${"d".repeat(60)}/${"e".repeat(60)}/${"f".repeat(40)}.ts`; | |
| 51 | const entries = [ | |
| 52 | { name: "cs-engine-1.2.3/package.json", data: bytes('{"version":"1.2.3"}\n') }, | |
| 53 | { name: "cs-engine-1.2.3/sub/deep.ts", data: bytes("export const x = 1;\n".repeat(50)) }, | |
| 54 | { name: long, data: bytes("long") }, | |
| 55 | ]; | |
| 56 | const back = untar(tar(entries, 1_700_000_000)); | |
| 57 | expect(back.map(({ name }) => name)).toEqual(entries.map(({ name }) => name)); | |
| 58 | for (const [i, read] of back.entries()) { | |
| 59 | expect(read.data).toEqual((entries[i] as (typeof entries)[number]).data); | |
| 60 | expect(read.mode).toBe(0o644); | |
| 61 | expect(read.mtime).toBe(1_700_000_000); | |
| 62 | } | |
| 63 | // A flipped byte anywhere in a header is caught by its checksum. | |
| 64 | const broken = tar(entries); | |
| 65 | broken[3] = (broken[3] as number) ^ 1; | |
| 66 | expect(() => untar(broken)).toThrow(/checksum/); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a long path is split into prefix and name at a slash; one that cannot be is refused", () => { | |
| 70 | expect(splitName("a/b.ts")).toEqual({ name: "a/b.ts", prefix: "" }); | |
| 71 | const long = `${"p".repeat(120)}/b.ts`; | |
| 72 | expect(splitName(long)).toEqual({ name: "b.ts", prefix: "p".repeat(120) }); | |
| 73 | expect(() => splitName("x".repeat(101))).toThrow(/too long/); | |
| 74 | expect(() => splitName(`${"p".repeat(156)}/b.ts`)).toThrow(/too long/); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a path that could unpack outside where it is unpacked, or is not ASCII, is refused", () => { | |
| 78 | for (const bad of ["/etc/passwd", "../up.ts", "a/../../b", "a//b", "a/", "./a", "ünï.ts", "a\nb", ""]) | |
| 79 | expect(() => tar([{ name: bad, data: new Uint8Array(0) }])).toThrow(); | |
| 80 | expect(() => tar([{ name: "a", data: bytes("1") }, { name: "a", data: bytes("2") }])).toThrow(/twice/); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the same files make the same bytes: an archive is a function of its entries", () => { | |
| 84 | const entries = [{ name: "a.ts", data: bytes("a") }]; | |
| 85 | expect(tar(entries)).toEqual(tar(entries)); | |
| 86 | }); | |
| 87 | ||
| 88 | test("the system's tar, where there is one, unpacks it gzipped to the same files", () => { | |
| 89 | const system = Bun.which("tar"); | |
| 90 | if (system === null) return; | |
| 91 | const dir = mkdtempSync(join(tmpdir(), "tar-")); | |
| 92 | try { | |
| 93 | const long = `pkg/${"d".repeat(80)}/${"n".repeat(90)}.ts`; | |
| 94 | const archive = Bun.gzipSync(tar([ | |
| 95 | { name: "pkg/index.ts", data: bytes("export {};\n") }, | |
| 96 | { name: long, data: bytes("x".repeat(1000)) }, | |
| 97 | ])); | |
| 98 | writeFileSync(join(dir, "pkg.tar.gz"), archive); | |
| 99 | // Fixed arguments, no text from anywhere else: this is the reader a stranger uses. | |
| 100 | const run = Bun.spawnSync([system, "-xzf", "pkg.tar.gz"], { cwd: dir }); | |
| 101 | expect(run.stderr.toString()).toBe(""); | |
| 102 | expect(run.exitCode).toBe(0); | |
| 103 | expect(readFileSync(join(dir, "pkg", "index.ts"), "utf8")).toBe("export {};\n"); | |
| 104 | expect(readFileSync(join(dir, long), "utf8")).toBe("x".repeat(1000)); | |
| 105 | } finally { | |
| 106 | rmSync(dir, { recursive: true, force: true }); | |
| 107 | } | |
| 108 | }); |
src/tar.ts added +186
| @@ -0,0 +1,186 @@ | ||
| 1 | /** | |
| 2 | * A tar archive, written by hand: the one format `tar -xf` has read since | |
| 3 | * POSIX.1-1988 (ustar), for regular files and nothing else. | |
| 4 | * | |
| 5 | * It exists because `/engine` hands a stranger the package as one file, and a | |
| 6 | * tarball is the one file every machine with a shell can open. Bun gives the | |
| 7 | * gzip (`Bun.gzipSync`); the tar is a 512-byte header per file, the bytes, zero | |
| 8 | * padding to the next 512, and two zero blocks at the end. That is short enough | |
| 9 | * to own, and a dependency for it would be more code than this. | |
| 10 | * | |
| 11 | * The header, field by field (POSIX.1-2017, pax "ustar Interchange Format"): | |
| 12 | * | |
| 13 | * offset size field | |
| 14 | * 0 100 name — the path, or its last part when `prefix` is used | |
| 15 | * 100 8 mode — octal, NUL-terminated: 0000644 | |
| 16 | * 108 8 uid — octal: 0 | |
| 17 | * 116 8 gid — octal: 0 | |
| 18 | * 124 12 size — octal bytes of data, NUL-terminated | |
| 19 | * 136 12 mtime — octal seconds since the epoch | |
| 20 | * 148 8 chksum — octal sum of all 512 header bytes, this field read as spaces | |
| 21 | * 156 1 typeflag — '0', a regular file | |
| 22 | * 157 100 linkname — empty | |
| 23 | * 257 6 magic — "ustar\0" | |
| 24 | * 263 2 version — "00" | |
| 25 | * 265 32 uname, 297 32 gname — empty | |
| 26 | * 329 8 devmajor, 337 8 devminor — empty | |
| 27 | * 345 155 prefix — the leading directories of a name over 100 bytes | |
| 28 | * 500 12 (padding) | |
| 29 | * | |
| 30 | * What it refuses rather than guesses: a name that is not printable ASCII, has | |
| 31 | * a `..` or an absolute path, or does not fit the two fields; and a file of 8 GiB | |
| 32 | * or more, which is the 11-digit octal size field's ceiling. None of those is in | |
| 33 | * the engine, and each is a sentence rather than an archive that unpacks wrong. | |
| 34 | * | |
| 35 | * Every archive is a function of its entries and nothing else: the mtime is | |
| 36 | * one number handed in (0 unless said), owner and group are 0 and nameless. So | |
| 37 | * one tree always tars to the same bytes, and a hash of the archive is a hash of | |
| 38 | * the tree. | |
| 39 | * | |
| 40 | * `untar` is the reader, and it is here rather than in a test because the | |
| 41 | * format has two halves and the one that proves the other should sit beside it. | |
| 42 | * It checks every checksum and reads only regular files — it is not a general | |
| 43 | * tar reader, it is this writer's inverse. | |
| 44 | */ | |
| 45 | ||
| 46 | /** One file in an archive: its path inside it, and its bytes. */ | |
| 47 | export type Entry = { name: string; data: Uint8Array }; | |
| 48 | ||
| 49 | /** A file read back out of an archive, with the header fields that matter. */ | |
| 50 | export type Read = Entry & { mode: number; mtime: number; size: number }; | |
| 51 | ||
| 52 | /** Every block in a tar is this many bytes, header and data alike. */ | |
| 53 | export const BLOCK = 512; | |
| 54 | ||
| 55 | /** The largest size the 11-digit octal field can hold, plus one. */ | |
| 56 | const SIZE_LIMIT = 8 ** 11; | |
| 57 | ||
| 58 | const ascii = new TextEncoder(); | |
| 59 | const text = new TextDecoder(); | |
| 60 | ||
| 61 | /** A number as the header writes one: `digits` octal digits, zero-padded, then NUL. */ | |
| 62 | function octal(n: number, digits: number): string { | |
| 63 | const s = n.toString(8); | |
| 64 | if (s.length > digits) throw new Error(`${n} does not fit ${digits} octal digits`); | |
| 65 | return `${s.padStart(digits, "0")}\0`; | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * Split a path into ustar's two fields: `name` of at most 100 bytes and | |
| 70 | * `prefix` of at most 155, joined back by a `/` the archive does not store. | |
| 71 | */ | |
| 72 | export function splitName(path: string): { name: string; prefix: string } { | |
| 73 | if (!/^[\x21-\x7e][\x20-\x7e]*$/.test(path)) throw new Error(`${JSON.stringify(path)} is not a printable ASCII path`); | |
| 74 | if (path.startsWith("/") || path.split("/").some((part) => part === ".." || part === "" || part === ".")) | |
| 75 | throw new Error(`${JSON.stringify(path)} is not a relative path to a file`); | |
| 76 | if (path.length <= 100) return { name: path, prefix: "" }; | |
| 77 | // The last `/` that leaves a name of 100 bytes or fewer and a prefix of 155. | |
| 78 | for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) { | |
| 79 | const name = path.slice(at + 1); | |
| 80 | const prefix = path.slice(0, at); | |
| 81 | if (name.length > 100) break; | |
| 82 | if (prefix.length <= 155) return { name, prefix }; | |
| 83 | } | |
| 84 | throw new Error(`${JSON.stringify(path)} is too long for a ustar header`); | |
| 85 | } | |
| 86 | ||
| 87 | /** Write `value` into `block` at `offset`, never past `size` bytes. */ | |
| 88 | function put(block: Uint8Array, offset: number, size: number, value: string): void { | |
| 89 | const bytes = ascii.encode(value); | |
| 90 | if (bytes.length > size) throw new Error(`${JSON.stringify(value)} overflows a ${size}-byte field`); | |
| 91 | block.set(bytes, offset); | |
| 92 | } | |
| 93 | ||
| 94 | /** The sum of every header byte, with the checksum field counted as eight spaces. */ | |
| 95 | export function checksum(header: Uint8Array): number { | |
| 96 | let sum = 0; | |
| 97 | for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 0x20 : (header[i] as number); | |
| 98 | return sum; | |
| 99 | } | |
| 100 | ||
| 101 | /** One file's 512-byte header. */ | |
| 102 | export function header(path: string, size: number, mtime = 0): Uint8Array { | |
| 103 | if (!Number.isInteger(size) || size < 0 || size >= SIZE_LIMIT) throw new Error(`${size} is not a size a ustar header holds`); | |
| 104 | const { name, prefix } = splitName(path); | |
| 105 | const block = new Uint8Array(BLOCK); | |
| 106 | put(block, 0, 100, name); | |
| 107 | put(block, 100, 8, octal(0o644, 7)); | |
| 108 | put(block, 108, 8, octal(0, 7)); | |
| 109 | put(block, 116, 8, octal(0, 7)); | |
| 110 | put(block, 124, 12, octal(size, 11)); | |
| 111 | put(block, 136, 12, octal(mtime, 11)); | |
| 112 | block[156] = 0x30; // '0': a regular file | |
| 113 | put(block, 257, 6, "ustar\0"); | |
| 114 | put(block, 263, 2, "00"); | |
| 115 | put(block, 345, 155, prefix); | |
| 116 | // Six octal digits, a NUL and a space: the form every tar since V7 writes. | |
| 117 | put(block, 148, 8, `${checksum(block).toString(8).padStart(6, "0")}\0 `); | |
| 118 | return block; | |
| 119 | } | |
| 120 | ||
| 121 | /** How many bytes `size` takes once padded to whole blocks. */ | |
| 122 | const padded = (size: number): number => Math.ceil(size / BLOCK) * BLOCK; | |
| 123 | ||
| 124 | /** | |
| 125 | * The archive: each entry's header and its data padded to a block, then two | |
| 126 | * zero blocks. A name twice is refused — two files at one path unpack as one. | |
| 127 | */ | |
| 128 | export function tar(entries: readonly Entry[], mtime = 0): Uint8Array<ArrayBuffer> { | |
| 129 | const seen = new Set<string>(); | |
| 130 | for (const { name } of entries) { | |
| 131 | if (seen.has(name)) throw new Error(`${JSON.stringify(name)} is in the archive twice`); | |
| 132 | seen.add(name); | |
| 133 | } | |
| 134 | const total = entries.reduce((sum, { data }) => sum + BLOCK + padded(data.length), 0) + 2 * BLOCK; | |
| 135 | const out = new Uint8Array(total); | |
| 136 | let at = 0; | |
| 137 | for (const { name, data } of entries) { | |
| 138 | out.set(header(name, data.length, mtime), at); | |
| 139 | out.set(data, at + BLOCK); | |
| 140 | at += BLOCK + padded(data.length); | |
| 141 | } | |
| 142 | return out; | |
| 143 | } | |
| 144 | ||
| 145 | /** A NUL-terminated field, as text. */ | |
| 146 | const field = (block: Uint8Array, offset: number, size: number): string => { | |
| 147 | const bytes = block.subarray(offset, offset + size); | |
| 148 | const end = bytes.indexOf(0); | |
| 149 | return text.decode(end === -1 ? bytes : bytes.subarray(0, end)); | |
| 150 | }; | |
| 151 | ||
| 152 | /** An octal field, as a number; a field that is not octal is a broken archive. */ | |
| 153 | const number = (block: Uint8Array, offset: number, size: number): number => { | |
| 154 | const s = field(block, offset, size).trim(); | |
| 155 | if (!/^[0-7]+$/.test(s)) throw new Error(`the header field at ${offset} is ${JSON.stringify(s)}, not octal`); | |
| 156 | return parseInt(s, 8); | |
| 157 | }; | |
| 158 | ||
| 159 | /** | |
| 160 | * Read an archive this module wrote back into its files. Every header's | |
| 161 | * checksum is checked, the magic is checked, and it stops at the first zero | |
| 162 | * block — which must be followed by a second, as the format says. | |
| 163 | */ | |
| 164 | export function untar(archive: Uint8Array): Read[] { | |
| 165 | if (archive.length % BLOCK !== 0) throw new Error(`an archive is whole ${BLOCK}-byte blocks; this is ${archive.length} bytes`); | |
| 166 | const out: Read[] = []; | |
| 167 | for (let at = 0; at < archive.length; ) { | |
| 168 | const block = archive.subarray(at, at + BLOCK); | |
| 169 | if (block.every((b) => b === 0)) { | |
| 170 | const next = archive.subarray(at + BLOCK, at + 2 * BLOCK); | |
| 171 | if (next.length !== BLOCK || !next.every((b) => b === 0)) throw new Error("the archive ends with one zero block, not two"); | |
| 172 | return out; | |
| 173 | } | |
| 174 | if (number(block, 148, 8) !== checksum(block)) throw new Error(`the header at ${at} has a bad checksum`); | |
| 175 | if (field(block, 257, 6) !== "ustar") throw new Error(`the header at ${at} is not ustar`); | |
| 176 | if (block[156] !== 0x30) throw new Error(`the header at ${at} is not a regular file`); | |
| 177 | const prefix = field(block, 345, 155); | |
| 178 | const name = prefix === "" ? field(block, 0, 100) : `${prefix}/${field(block, 0, 100)}`; | |
| 179 | const size = number(block, 124, 12); | |
| 180 | const data = archive.slice(at + BLOCK, at + BLOCK + size); | |
| 181 | if (data.length !== size) throw new Error(`${name} is cut short`); | |
| 182 | out.push({ name, data, size, mode: number(block, 100, 8), mtime: number(block, 136, 12) }); | |
| 183 | at += BLOCK + padded(size); | |
| 184 | } | |
| 185 | throw new Error("the archive has no end-of-archive blocks"); | |
| 186 | } |