Public: anyone can read this; the crew can change it.

The link you send a friend: a shared world is shared by pasting a URL #35

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

quill/drift:quill/28-the-link-you-send-a-friend-a-shared-worl → main 3 commits fork

  • Merged. quill/28-the-link-you-send-a-friend-a-shared-worl was merged into main as 4280fbc.

Review

Sign in to review this change.
11 files changed +827 −24
README.md +42 −16
@@ -41,22 +41,48 @@ plain function — `(db, who, input)` in, data or a named failure out — and
4141 the page, the JSON API, the MCP server and the terminal all call it and
4242 decide nothing themselves. `AGENTS.md` says how to add to it.
4343
44 | file | what it is |
45 | ---------------------------------- | -------------------------------------------------------------- |
46 | `src/actions.ts` | every operation, and who may — empty until #DATA |
47 | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized |
48 | `src/who.ts` | who is asking, from the forge's signed headers |
49 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
50 | `src/live.ts` | the in-process bus and `GET /events` |
51 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` — none yet |
52 | `src/routes.ts` | every path, in one table |
53 | `src/concept.ts` | the design record as data: the rule, the round, the invariants |
54 | `src/page.ts` | the HTML of the arena, rendered on the server |
55 | `src/http.ts` | the floor: headers, body cap, origins, rate limit, files |
56 | `src/server.ts` | boot: open the database, serve |
57 | `src/tty.ts` | the terminal — a placeholder until the world runs |
58 | `src/env.ts` | what the forge told this process |
59 | `static/app.css`, `static/live.js` | the look, and the regions that stay current |
44 | file | what it is |
45 | ---------------------------------- | ----------------------------------------------------------------- |
46 | `src/actions.ts` | every operation, and who may — empty until #DATA |
47 | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized |
48 | `src/who.ts` | who is asking, from the forge's signed headers |
49 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
50 | `src/live.ts` | the in-process bus and `GET /events` |
51 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` — none yet |
52 | `src/routes.ts` | every path, in one table |
53 | `src/concept.ts` | the design record as data: the rule, the round, the invariants |
54 | `src/page.ts` | the HTML of the arena, rendered on the server |
55 | `src/meta.ts` | what a pasted link and a crawler see: the card tags, `robots.txt` |
56 | `src/card.ts` | the card and the touch icon, drawn by the app |
57 | `src/png.ts` | pixels to a PNG, in about a hundred lines |
58 | `src/http.ts` | the floor: headers, body cap, origins, rate limit, files |
59 | `src/server.ts` | boot: open the database, serve |
60 | `src/tty.ts` | the terminal — a placeholder until the world runs |
61 | `src/env.ts` | what the forge told this process |
62 | `static/app.css`, `static/live.js` | the look, and the regions that stay current |
63 | `static/icon.svg` | the icon: a wake, a mote and the bloom it carries |
64
65 ## The link you send a friend
66
67 A game whose whole mechanic is other people spreads by one person pasting
68 the address to another, so the head says what drift is and the picture shows
69 it. `GET /card.png` is 1200×630, drawn by `src/card.ts` at boot — the arena,
70 three wakes braided into one lane, the bloom going through the gate — and
71 `GET /icon-180.png` is the same sea for a phone's home screen. Both are
72 addressed by the hash of their own bytes (`/card.png?v=0f2c8dd2`) and cached
73 for a year at that address; neither can carry a player's name, because the
74 drawing has no idea who asked.
75
76 Every address in the head — canonical, `og:url`, `og:image` — is built from
77 `FORGE_APP_URL` (`publicOrigin`) and never from `Host`, `X-Forwarded-Host`
78 or anything else a client sent; a test in `src/server.test.ts` holds it
79 there. `GET /robots.txt` leaves the page crawlable and closes `/events`,
80 `/mcp` and `/__forge/`: a crawler has no business holding a stream open, and
81 joining a round is a socket event so that no fetch can ever take a seat.
82
83 The PNGs are encoded by `src/png.ts` — signature, IHDR, one deflated IDAT,
84 adaptive row filters — using `node:zlib`, which Bun ships. No dependency was
85 added to draw them.
6086
6187 ## Who you are
6288
src/card.ts added +328
@@ -0,0 +1,328 @@
1 /**
2 * The picture a pasted link shows: the arena, three wakes braided into one
3 * lane, and the bloom going through the gate — the whole rule of the game as
4 * geometry (`docs/wake.md`). It is drawn here rather than by hand so it can
5 * never carry a player's name: a link pasted into a chat must not say who was
6 * playing, and a picture with nobody in it cannot.
7 *
8 * Two images come out of the same painter — the social card (1200×630, the
9 * size every unfurler crops to) and the touch icon a phone home screen keeps.
10 * Each is drawn once, cached in memory, and addressed by the hash of its own
11 * bytes, so `/card.png?v=…` is immutable for a year and a redraw is a new
12 * address. PNG and not SVG because most unfurlers will not render an SVG.
13 */
14
15 import { createHash } from "node:crypto";
16 import { encodePng } from "./png.ts";
17
18 // ── The palette. Deep water, cold wakes, one warm body being carried. ──
19 const SEA_TOP = rgb("#0a1020");
20 const SEA_BOTTOM = rgb("#04060d");
21 const WAKE = rgb("#6f8cff");
22 const WAKE_COOL = rgb("#4fc3ff");
23 const MOTE = rgb("#dbe6ff");
24 const BLOOM = rgb("#ffc266");
25 const BLOOM_CORE = rgb("#fff3dc");
26 const GATE = rgb("#7ff0dc");
27
28 type Colour = readonly [number, number, number];
29
30 function rgb(hex: string): Colour {
31 const n = Number.parseInt(hex.slice(1), 16);
32 return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
33 }
34
35 interface Picture {
36 readonly w: number;
37 readonly h: number;
38 readonly px: Float32Array; // linear-ish rgb, 0..1, additive
39 }
40
41 const picture = (w: number, h: number): Picture => ({
42 w,
43 h,
44 px: new Float32Array(w * h * 3),
45 });
46
47 /** Light added in a soft round falloff — every mote, wake and bloom is this. */
48 function glow(
49 p: Picture,
50 x: number,
51 y: number,
52 radius: number,
53 colour: Colour,
54 strength: number,
55 ): void {
56 const x0 = Math.max(0, Math.floor(x - radius));
57 const x1 = Math.min(p.w - 1, Math.ceil(x + radius));
58 const y0 = Math.max(0, Math.floor(y - radius));
59 const y1 = Math.min(p.h - 1, Math.ceil(y + radius));
60 const r2 = radius * radius;
61 for (let py = y0; py <= y1; py++) {
62 const dy = py + 0.5 - y;
63 for (let px = x0; px <= x1; px++) {
64 const dx = px + 0.5 - x;
65 const d2 = dx * dx + dy * dy;
66 if (d2 >= r2) continue;
67 // Smooth to zero at the edge, squared so the core stays hot.
68 const fall = (1 - d2 / r2) ** 2 * strength;
69 const at = (py * p.w + px) * 3;
70 p.px[at] += colour[0] * fall;
71 p.px[at + 1] += colour[1] * fall;
72 p.px[at + 2] += colour[2] * fall;
73 }
74 }
75 }
76
77 /** A quadratic curve's point at `t` — the shape a wake takes. */
78 const bezier = (
79 a: readonly [number, number],
80 b: readonly [number, number],
81 c: readonly [number, number],
82 t: number,
83 ): [number, number] => {
84 const u = 1 - t;
85 return [
86 u * u * a[0] + 2 * u * t * b[0] + t * t * c[0],
87 u * u * a[1] + 2 * u * t * b[1] + t * t * c[1],
88 ];
89 };
90
91 /**
92 * One swimmer's wake: faint and wide where it is old, tight and bright at the
93 * mote itself. `t` runs from the tail to the head.
94 */
95 function wake(
96 p: Picture,
97 path: readonly [
98 readonly [number, number],
99 readonly [number, number],
100 readonly [number, number],
101 ],
102 colour: Colour,
103 scale: number,
104 steps = 260,
105 boost = 1,
106 ): void {
107 for (let i = 0; i <= steps; i++) {
108 const t = i / steps;
109 const [x, y] = bezier(path[0], path[1], path[2], t);
110 const age = t ** 2.4; // the field decays: old is faint (invariant 5)
111 glow(p, x, y, (16 + 9 * t) * scale, colour, (0.011 + 0.05 * age) * boost);
112 }
113 const [hx, hy] = bezier(path[0], path[1], path[2], 1);
114 glow(p, hx, hy, 26 * scale, colour, 0.5 * boost);
115 glow(p, hx, hy, 8 * scale, MOTE, 1.1);
116 }
117
118 /** Deterministic scatter: the same card every time, so its hash is stable. */
119 function sprinkle(p: Picture, seed: number, count: number) {
120 let state = seed;
121 const next = () => {
122 state = (state * 1664525 + 1013904223) >>> 0;
123 return state / 4294967296;
124 };
125 for (let i = 0; i < count; i++) {
126 const x = next() * p.w;
127 const y = next() * p.h;
128 const strength = 0.06 + next() * 0.16;
129 glow(p, x, y, 1.6 + next() * 2.2, MOTE, strength);
130 }
131 }
132
133 /** The sea itself: a vertical gradient with the light pooled at the middle. */
134 function sea(p: Picture): void {
135 for (let y = 0; y < p.h; y++) {
136 const v = y / (p.h - 1);
137 const dy = (v - 0.5) * 1.5;
138 const dy2 = dy * dy;
139 // The gradient is constant across a row; only the pool moves with x.
140 const r = SEA_BOTTOM[0] + (SEA_TOP[0] - SEA_BOTTOM[0]) * (1 - v);
141 const g = SEA_BOTTOM[1] + (SEA_TOP[1] - SEA_BOTTOM[1]) * (1 - v);
142 const b = SEA_BOTTOM[2] + (SEA_TOP[2] - SEA_BOTTOM[2]) * (1 - v);
143 let at = y * p.w * 3;
144 for (let x = 0; x < p.w; x++) {
145 // A soft pool of light where the round is happening, dark at the corners.
146 const dx = (x / (p.w - 1) - 0.5) * 1.9;
147 const near = 1 - (dx * dx + dy2);
148 const pool = near > 0 ? near * near * 0.05 : 0;
149 p.px[at] = r + pool;
150 p.px[at + 1] = g + pool;
151 p.px[at + 2] = b + pool;
152 at += 3;
153 }
154 }
155 }
156
157 /**
158 * The arena's rim, and the gate cut into its right edge: the distance to the
159 * square in one pass, a hot thin core and a narrow halo, brightened and
160 * warmed along the gate's span. The gate's own light is round, not square, so
161 * it is thrown separately (`glow`) rather than smeared along this edge.
162 */
163 function rim(
164 p: Picture,
165 box: { x: number; y: number; size: number },
166 gate: { centre: number; half: number },
167 ): void {
168 const { x, y, size } = box;
169 const cx = x + size / 2;
170 const cy = y + size / 2;
171 const half = size / 2;
172 const pad = 60; // past the halo, so nothing ends in a straight cut
173 for (
174 let py = Math.max(0, y - pad);
175 py <= Math.min(p.h - 1, y + size + pad);
176 py++
177 ) {
178 const qy = Math.abs(py + 0.5 - cy) - half;
179 // How much of this row is gate: within the arc on the right edge, falling
180 // off smoothly at its ends.
181 const fromGate = Math.abs(py + 0.5 - gate.centre) / gate.half;
182 const inGate = fromGate < 1 ? 1 - fromGate ** 4 : 0;
183 for (
184 let px = Math.max(0, x - pad);
185 px <= Math.min(p.w - 1, x + size + pad);
186 px++
187 ) {
188 // Signed distance to the square, then its absolute value: the edge.
189 const qx = Math.abs(px + 0.5 - cx) - half;
190 if (qx < -pad && qy < -pad) continue; // deep inside: nothing to draw
191 const d =
192 qx > 0 && qy > 0
193 ? Math.sqrt(qx * qx + qy * qy)
194 : Math.abs(Math.max(qx, qy));
195 if (d > pad) continue;
196
197 // The gate is a span of the right edge; nowhere else on the rim opens.
198 const g = px + 0.5 > cx + half - 3 ? inGate : 0;
199
200 const core = Math.exp(-((d / (1.5 + g * 1.4)) ** 2));
201 const halo = Math.exp(-((d / 14) ** 2));
202 const strength = core * (0.5 + g * 0.8) + halo * 0.07;
203 if (strength < 0.002) continue;
204 const colour: Colour = [
205 WAKE[0] + (GATE[0] - WAKE[0]) * g,
206 WAKE[1] + (GATE[1] - WAKE[1]) * g,
207 WAKE[2] + (GATE[2] - WAKE[2]) * g,
208 ];
209 const at = (py * p.w + px) * 3;
210 for (let c = 0; c < 3; c++) p.px[at + c] += colour[c]! * strength * 0.75;
211 }
212 }
213 }
214
215 // Ordered dither, a fraction of one step: a 1200px gradient banded at 8 bits
216 // is the one thing that makes a card look cheap.
217 const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
218
219 function toBytes(p: Picture): Uint8Array {
220 const out = new Uint8Array(p.w * p.h * 3);
221 for (let y = 0; y < p.h; y++)
222 for (let x = 0; x < p.w; x++) {
223 const noise = (BAYER[(y % 4) * 4 + (x % 4)]! / 16 - 0.5) / 255;
224 const at = (y * p.w + x) * 3;
225 for (let c = 0; c < 3; c++)
226 out[at + c] = Math.max(
227 0,
228 Math.min(255, Math.round((p.px[at + c]! + noise) * 255)),
229 );
230 }
231 return out;
232 }
233
234 export const CARD_WIDTH = 1200;
235 export const CARD_HEIGHT = 630;
236
237 /** What the card shows, for a reader who cannot see it. */
238 export const CARD_ALT =
239 "The arena: three wakes braided into one bright lane, carrying the bloom through the gate on the rim.";
240
241 function drawCard(): Uint8Array {
242 const p = picture(CARD_WIDTH, CARD_HEIGHT);
243 const size = 520;
244 const box = { x: (CARD_WIDTH - size) / 2, y: (CARD_HEIGHT - size) / 2, size };
245 const at = (u: number, v: number): [number, number] => [
246 box.x + u * size,
247 box.y + v * size,
248 ];
249 sea(p);
250 sprinkle(p, 20240918, 90);
251
252 // Three lanes leaving the rim behind them, braiding as they converge on the
253 // gate: nobody's own thruster took them there (invariants 2 and 4).
254 wake(p, [at(0.03, 0.93), at(0.5, 0.74), at(0.86, 0.42)], WAKE, 1);
255 wake(p, [at(0.05, 0.56), at(0.5, 0.55), at(0.8, 0.39)], WAKE_COOL, 0.9);
256 wake(p, [at(0.22, 0.08), at(0.62, 0.34), at(0.83, 0.33)], WAKE, 0.8);
257
258 // The braid itself: where the three wakes share a corridor the field is
259 // stronger than any one of them raised, and that is the whole game.
260 for (let i = 0; i <= 120; i++) {
261 const t = i / 120;
262 const [lx, ly] = bezier(at(0.45, 0.52), at(0.72, 0.42), at(1.0, 0.31), t);
263 glow(p, lx, ly, 30 - 10 * t, WAKE_COOL, 0.014 + 0.03 * t ** 2);
264 }
265
266 // The gate: a span of the rim, lit and open, with its light round.
267 const gate = { centre: box.y + size * 0.31, half: size * 0.14 };
268 const gx = box.x + size;
269 for (let i = -1; i <= 1; i += 0.25)
270 glow(p, gx, gate.centre + i * gate.half * 0.9, 58, GATE, 0.055);
271
272 // The bloom, fifty times a mote's mass, going through on a lane no single
273 // swimmer could have raised (invariants 3 and 4).
274 const [bx, by] = at(1.0, 0.31);
275 glow(p, bx, by, 170, BLOOM, 0.09);
276 glow(p, bx, by, 56, BLOOM, 0.45);
277 glow(p, bx, by, 34, BLOOM_CORE, 0.9);
278
279 rim(p, box, gate);
280 return toBytes(p);
281 }
282
283 function drawIcon(side: number): Uint8Array {
284 const p = picture(side, side);
285 const s = side / 180; // the drawing is written at 180 and scales
286 sea(p);
287 const at = (u: number, v: number): [number, number] => [u * side, v * side];
288 // One wake and the bloom it carries, drawn hot: a home screen shrinks this
289 // to a thumbnail, and a thumbnail holds two shapes and no subtlety.
290 const lane = [at(0.04, 0.99), at(0.52, 0.86), at(0.84, 0.2)] as const;
291 wake(p, lane, WAKE, 0.3 * s, 140, 2.6);
292 wake(p, lane, WAKE_COOL, 0.16 * s, 140, 2.2);
293 const [bx, by] = at(0.4, 0.62);
294 glow(p, bx, by, 46 * s, BLOOM, 0.5);
295 glow(p, bx, by, 20 * s, BLOOM_CORE, 1.1);
296 return toBytes(p);
297 }
298
299 /** A drawing, drawn once, addressed by the hash of what came out. */
300 function image(name: string, draw: () => Uint8Array, w: number, h: number) {
301 let bytes: Uint8Array | undefined;
302 let version: string | undefined;
303 const make = () => {
304 if (bytes === undefined) {
305 bytes = encodePng(w, h, draw());
306 version = createHash("sha256").update(bytes).digest("hex").slice(0, 8);
307 }
308 return bytes;
309 };
310 return {
311 name,
312 bytes: make,
313 /** `/card.png?v=3f2a9c1e` — a new drawing is a new address. */
314 href: () => {
315 make();
316 return `/${name}?v=${version}`;
317 },
318 };
319 }
320
321 export const card = image("card.png", drawCard, CARD_WIDTH, CARD_HEIGHT);
322 export const APPLE_TOUCH = 180;
323 export const touchIcon = image(
324 "icon-180.png",
325 () => drawIcon(APPLE_TOUCH),
326 APPLE_TOUCH,
327 APPLE_TOUCH,
328 );
src/http.ts +16 −4
@@ -273,16 +273,28 @@ export function asset(name: string): string {
273273 }
274274 return `/static/${name}?v=${v}`;
275275 }
276 const cacheFor = (versioned: boolean): string =>
277 versioned ? "public, max-age=31536000, immutable" : "no-cache";
278
276279 export function staticFile(name: string, versioned: boolean): Response {
277280 const path = join(STATIC, name);
278281 if (name.includes("/") || name.includes("..") || !existsSync(path))
279282 return text("not found", 404);
280283 return new Response(Bun.file(path), {
284 headers: { "cache-control": cacheFor(versioned), ...SECURITY },
285 });
286 }
287
288 /** Bytes the app made itself, cached like a file when the address is versioned. */
289 export const bytes = (
290 body: Uint8Array,
291 type: string,
292 versioned: boolean,
293 ): Response =>
294 new Response(body, {
281295 headers: {
282 "cache-control": versioned
283 ? "public, max-age=31536000, immutable"
284 : "no-cache",
296 "content-type": type,
297 "cache-control": cacheFor(versioned),
285298 ...SECURITY,
286299 },
287300 });
288 }
src/meta.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * What a pasted link and a crawler see. A shared world spreads by one person
3 * sending the address to another, so the head has to say what drift is, and
4 * the card has to show it.
5 *
6 * Every address here is built from `publicOrigin` — the forge's word on where
7 * this app is served — and never from `Host`, `X-Forwarded-Host` or any other
8 * header a client chose. A canonical URL taken from a request header is a way
9 * to make an app advertise somebody else's address, and `src/server.test.ts`
10 * holds this file to that.
11 */
12
13 import { CARD_ALT, CARD_HEIGHT, CARD_WIDTH, card, touchIcon } from "./card.ts";
14 import { description, publicOrigin, title } from "./env.ts";
15 import { asset, escape } from "./http.ts";
16
17 /** An absolute address on this app, from the origin the forge gave us. */
18 export function absolute(path: string): string {
19 // A path, not a URL: `//elsewhere` and `https://elsewhere` are not paths,
20 // and neither is anything that would leave this origin.
21 if (!path.startsWith("/") || path.startsWith("//"))
22 throw new Error(`meta: not a path on this app: ${path}`);
23 return publicOrigin + path;
24 }
25
26 const tag = (attr: "property" | "name", key: string, value: string): string =>
27 `<meta ${attr}="${key}" content="${escape(value)}">`;
28
29 /**
30 * The head's social half for one page: the icons, the canonical address and
31 * the card an unfurler shows. Small on purpose — nothing here blocks the
32 * first paint, and there is one tag per thing that has to be said.
33 */
34 export function social(path = "/"): string {
35 const url = absolute(path);
36 return [
37 `<link rel="icon" href="${asset("icon.svg")}" type="image/svg+xml">`,
38 `<link rel="apple-touch-icon" href="${escape(touchIcon.href())}">`,
39 `<link rel="canonical" href="${escape(url)}">`,
40 tag("property", "og:type", "website"),
41 tag("property", "og:site_name", title),
42 tag("property", "og:title", title),
43 tag("property", "og:description", description),
44 tag("property", "og:url", url),
45 tag("property", "og:image", absolute(card.href())),
46 tag("property", "og:image:type", "image/png"),
47 tag("property", "og:image:width", String(CARD_WIDTH)),
48 tag("property", "og:image:height", String(CARD_HEIGHT)),
49 tag("property", "og:image:alt", CARD_ALT),
50 // Twitter reads the Open Graph tags for everything else; all it needs of
51 // its own is the shape of the card.
52 tag("name", "twitter:card", "summary_large_image"),
53 ].join("\n");
54 }
55
56 /**
57 * The page is crawlable; the doors are not pages. `/events` is a stream that
58 * never ends and `/mcp` speaks JSON-RPC — a crawler that pulls on either
59 * gains nothing and holds a connection open. Joining a round is a socket
60 * event (#11) precisely so that no crawler can ever take a seat by fetching.
61 */
62 export const robots = (): string =>
63 [
64 "# drift — one shared world. The page is for reading; the doors are not.",
65 "User-agent: *",
66 "Disallow: /events",
67 "Disallow: /mcp",
68 "Disallow: /__forge/",
69 "Allow: /",
70 "",
71 ].join("\n");
src/page.ts +2 −1
@@ -18,6 +18,7 @@ import {
1818 } from "./concept.ts";
1919 import { commit, description, env, ssh, title, where } from "./env.ts";
2020 import { asset, escape } from "./http.ts";
21 import { social } from "./meta.ts";
2122 import type { Who } from "./who.ts";
2223
2324 export function layout(body: string, who: Who): string {
@@ -31,11 +32,11 @@ export function layout(body: string, who: Who): string {
3132 <meta charset="utf-8">
3233 <meta name="viewport" content="width=device-width, initial-scale=1">
3334 <meta name="color-scheme" content="light dark">
34 <link rel="icon" href="data:,">
3535 <title>${escape(title)}</title>
3636 <meta name="description" content="${escape(description)}">
3737 <link rel="stylesheet" href="${asset("app.css")}">
3838 <script type="module" src="${asset("live.js")}"></script>
39 ${social("/")}
3940 <body>
4041 <div class="page">
4142 <header class="head">
src/png.test.ts added +105
@@ -0,0 +1,105 @@
1 import { expect, test } from "bun:test";
2 import { inflateSync } from "node:zlib";
3 import { encodePng } from "./png.ts";
4
5 const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
6
7 /** Walk the chunks a PNG is made of: length, type, data, and its own CRC. */
8 function chunks(png: Uint8Array) {
9 const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
10 const found: {
11 type: string;
12 data: Uint8Array;
13 crc: number;
14 body: Uint8Array;
15 }[] = [];
16 let at = 8;
17 while (at < png.length) {
18 const length = view.getUint32(at);
19 const type = new TextDecoder().decode(png.subarray(at + 4, at + 8));
20 found.push({
21 type,
22 data: png.subarray(at + 8, at + 8 + length),
23 // What the CRC covers — the type and the data — and what was stored.
24 body: png.subarray(at + 4, at + 8 + length),
25 crc: view.getUint32(at + 8 + length),
26 });
27 at += 12 + length;
28 }
29 return found;
30 }
31
32 /** Undo the per-row filter, which is the only part a decoder has to guess. */
33 function pixels(png: Uint8Array, width: number, height: number): Uint8Array {
34 const idat = chunks(png).filter((c) => c.type === "IDAT");
35 const raw = new Uint8Array(
36 inflateSync(Buffer.concat(idat.map((c) => Buffer.from(c.data)))),
37 );
38 const stride = width * 3;
39 const out = new Uint8Array(stride * height);
40 for (let y = 0; y < height; y++) {
41 const filter = raw[y * (stride + 1)]!;
42 for (let i = 0; i < stride; i++) {
43 const value = raw[y * (stride + 1) + 1 + i]!;
44 const left = i >= 3 ? out[y * stride + i - 3]! : 0;
45 const up = y > 0 ? out[(y - 1) * stride + i]! : 0;
46 const base = filter === 1 ? left : filter === 2 ? up : 0;
47 expect(filter).toBeLessThan(3);
48 out[y * stride + i] = (value + base) & 0xff;
49 }
50 }
51 return out;
52 }
53
54 const gradient = (w: number, h: number): Uint8Array => {
55 const rgb = new Uint8Array(w * h * 3);
56 for (let y = 0; y < h; y++)
57 for (let x = 0; x < w; x++) {
58 const at = (y * w + x) * 3;
59 rgb[at] = (x * 3) & 0xff;
60 rgb[at + 1] = (y * 5) & 0xff;
61 rgb[at + 2] = (x ^ y) & 0xff;
62 }
63 return rgb;
64 };
65
66 test("a PNG says what it is: signature, one IHDR, one IDAT, an end", () => {
67 const png = encodePng(9, 7, gradient(9, 7));
68 expect([...png.slice(0, 8)]).toEqual(SIGNATURE);
69 const found = chunks(png);
70 expect(found.map((c) => c.type)).toEqual(["IHDR", "IDAT", "IEND"]);
71 const header = new DataView(
72 found[0]!.data.buffer,
73 found[0]!.data.byteOffset,
74 13,
75 );
76 expect(header.getUint32(0)).toBe(9);
77 expect(header.getUint32(4)).toBe(7);
78 expect(found[0]!.data[8]).toBe(8); // eight bits a channel
79 expect(found[0]!.data[9]).toBe(2); // truecolour, no alpha
80 expect(found[0]!.data[12]).toBe(0); // not interlaced
81 });
82
83 test("every pixel survives the round trip, whatever filter each row chose", () => {
84 for (const [w, h] of [
85 [1, 1],
86 [3, 4],
87 [64, 40],
88 ] as const) {
89 const source = gradient(w, h);
90 expect(pixels(encodePng(w, h, source), w, h)).toEqual(source);
91 }
92 });
93
94 test("every chunk carries the CRC a decoder will check it against", () => {
95 // Bun's own CRC-32, not ours: a decoder computes this independently, and a
96 // chunk whose CRC disagrees is thrown away by every real one.
97 for (const chunk of chunks(encodePng(20, 12, gradient(20, 12))))
98 expect(chunk.crc).toBe(Bun.hash.crc32(chunk.body));
99 });
100
101 test("pixels that do not match the size are refused", () => {
102 expect(() => encodePng(4, 4, new Uint8Array(10))).toThrow(
103 "pixels do not match",
104 );
105 });
src/png.ts added +116
@@ -0,0 +1,116 @@
1 /**
2 * A PNG, written by hand: pixels in, bytes out. Eight-bit opaque RGB is all
3 * this app needs, so that is all this encodes — the signature, one IHDR, one
4 * deflated IDAT and the end. Scanlines are filtered adaptively (none, sub or
5 * up, whichever leaves the smallest residue) because the pictures we draw are
6 * gradients, and an unfiltered gradient compresses badly.
7 *
8 * The deflate comes from `node:zlib`, which Bun ships; nothing else is needed,
9 * and no dependency is added to draw an image.
10 */
11
12 import { deflateSync } from "node:zlib";
13
14 const SIGNATURE = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
15 const BYTES_PER_PIXEL = 3;
16
17 const CRC_TABLE = (() => {
18 const table = new Uint32Array(256);
19 for (let n = 0; n < 256; n++) {
20 let c = n;
21 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
22 table[n] = c >>> 0;
23 }
24 return table;
25 })();
26
27 const crc32 = (data: Uint8Array): number => {
28 let c = 0xffffffff;
29 for (const byte of data) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8);
30 return (c ^ 0xffffffff) >>> 0;
31 };
32
33 function chunk(type: string, data: Uint8Array): Uint8Array {
34 const out = new Uint8Array(12 + data.length);
35 const view = new DataView(out.buffer);
36 view.setUint32(0, data.length);
37 for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
38 out.set(data, 8);
39 view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
40 return out;
41 }
42
43 // The residue a filter leaves, read as a signed byte: the smaller the sum
44 // over a row, the less there is for deflate to carry. This is the heuristic
45 // the PNG spec suggests, and it is enough.
46 const weight = (byte: number): number => (byte < 128 ? byte : 256 - byte);
47
48 /** An opaque 8-bit RGB image: `rgb` is `width * height * 3` bytes, row-major. */
49 export function encodePng(
50 width: number,
51 height: number,
52 rgb: Uint8Array,
53 ): Uint8Array {
54 const stride = width * BYTES_PER_PIXEL;
55 if (rgb.length !== stride * height)
56 throw new Error("png: pixels do not match the size given");
57
58 const header = new Uint8Array(13);
59 const head = new DataView(header.buffer);
60 head.setUint32(0, width);
61 head.setUint32(4, height);
62 header[8] = 8; // bit depth
63 header[9] = 2; // colour type: truecolour, no alpha
64 // 10..12 stay zero: deflate, adaptive filtering, no interlace.
65
66 const raw = new Uint8Array((stride + 1) * height);
67 const sub = new Uint8Array(stride);
68 const up = new Uint8Array(stride);
69 for (let y = 0; y < height; y++) {
70 const row = y * stride;
71 const above = row - stride;
72 let costNone = 0;
73 let costSub = 0;
74 let costUp = 0;
75 for (let i = 0; i < stride; i++) {
76 const here = rgb[row + i]!;
77 const left = i >= BYTES_PER_PIXEL ? rgb[row + i - BYTES_PER_PIXEL]! : 0;
78 const over = y === 0 ? 0 : rgb[above + i]!;
79 const s = (here - left) & 0xff;
80 const u = (here - over) & 0xff;
81 sub[i] = s;
82 up[i] = u;
83 costNone += weight(here);
84 costSub += weight(s);
85 costUp += weight(u);
86 }
87 const out = y * (stride + 1);
88 if (costNone <= costSub && costNone <= costUp) {
89 raw[out] = 0;
90 raw.set(rgb.subarray(row, row + stride), out + 1);
91 } else if (costSub <= costUp) {
92 raw[out] = 1;
93 raw.set(sub, out + 1);
94 } else {
95 raw[out] = 2;
96 raw.set(up, out + 1);
97 }
98 }
99
100 // Level 6, not 9: on a dithered gradient the last nine percent of size
101 // costs thirteen times the seconds, and these images are drawn at boot.
102 const idat = new Uint8Array(deflateSync(raw, { level: 6 }));
103 const parts = [
104 SIGNATURE,
105 chunk("IHDR", header),
106 chunk("IDAT", idat),
107 chunk("IEND", new Uint8Array()),
108 ];
109 const png = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
110 let at = 0;
111 for (const part of parts) {
112 png.set(part, at);
113 at += part.length;
114 }
115 return png;
116 }
src/routes.ts +22 −3
@@ -3,14 +3,16 @@
33 * calls an action, and shapes the answer for its door — HTML for a
44 * browser, JSON for a script — and that is all a route does.
55 *
6 * Today that is the page, the event stream, the MCP endpoint and the
7 * files: the scaffold's routes left with #PAGE, and the round's JSON
8 * doors arrive with #DOORS.
6 * Today that is the page, the event stream, the MCP endpoint, the two
7 * pictures a pasted link shows, `robots.txt` and the files: the scaffold's
8 * routes left with #PAGE, and the round's JSON doors arrive with #DOORS.
99 */
1010
1111 import type { Database } from "bun:sqlite";
12 import { card, touchIcon } from "./card.ts";
1213 import {
1314 bucketOf,
15 bytes,
1416 empty,
1517 html,
1618 json,
@@ -21,6 +23,7 @@ import {
2123 } from "./http.ts";
2224 import { events } from "./live.ts";
2325 import { rpc } from "./mcp.ts";
26 import { robots } from "./meta.ts";
2427 import { home } from "./page.ts";
2528 import type { Who } from "./who.ts";
2629
@@ -82,6 +85,22 @@ export const routes: readonly Route[] = [
8285 handle: () => text("this endpoint speaks JSON-RPC over POST", 405),
8386 },
8487
88 // What a pasted link shows, and what a crawler is allowed to pull on. The
89 // card is drawn by the app (`card.ts`) and never carries a player's name.
90 {
91 method: "GET",
92 path: "/card.png",
93 handle: (c) =>
94 bytes(card.bytes(), "image/png", c.url.searchParams.has("v")),
95 },
96 {
97 method: "GET",
98 path: "/icon-180.png",
99 handle: (c) =>
100 bytes(touchIcon.bytes(), "image/png", c.url.searchParams.has("v")),
101 },
102 { method: "GET", path: "/robots.txt", handle: () => text(robots()) },
103
85104 // Files under ./static, cached for a year when linked by content (`asset`).
86105 {
87106 method: "GET",
src/server.test.ts +98
@@ -233,6 +233,104 @@ test("refuses a cross-site post", async () => {
233233 expect(posted.status).toBe(403);
234234 });
235235
236 test("a pasted link previews as the sea: card tags, icons and a canonical URL", async () => {
237 const page = await (await fetch(base + "/")).text();
238 // The empty icon is gone; a real one is linked by content, and a phone
239 // home screen gets a sized bitmap.
240 expect(page).not.toContain('href="data:,"');
241 expect(page).toMatch(
242 /<link rel="icon" href="\/static\/icon\.svg\?v=[0-9a-f]{8}" type="image\/svg\+xml">/,
243 );
244 expect(page).toMatch(
245 /<link rel="apple-touch-icon" href="\/icon-180\.png\?v=[0-9a-f]{8}">/,
246 );
247 // The card an unfurler shows, with the size it will crop to.
248 expect(page).toMatch(
249 /<meta property="og:image" content="[^"]+\/card\.png\?v=[0-9a-f]{8}">/,
250 );
251 expect(page).toContain('<meta property="og:image:width" content="1200">');
252 expect(page).toContain('<meta property="og:image:height" content="630">');
253 expect(page).toContain(
254 '<meta name="twitter:card" content="summary_large_image">',
255 );
256 for (const tag of ["og:title", "og:description", "og:type", "og:url"])
257 expect(page).toContain(`<meta property="${tag}" content="`);
258
259 const icon = await fetch(base + "/static/icon.svg");
260 expect(icon.headers.get("content-type")).toContain("image/svg+xml");
261 expect(await icon.text()).not.toContain("<script");
262 });
263
264 test("the canonical URL and the card come from publicOrigin, never a header", async () => {
265 const canonical = `<link rel="canonical" href="${publicOrigin}/">`;
266 expect(await (await fetch(base + "/")).text()).toContain(canonical);
267 // A client can say anything about the host it thinks it reached. None of it
268 // may end up in the address this app advertises.
269 for (const spoof of [
270 { host: "evil.example" },
271 { "x-forwarded-host": "evil.example" },
272 { "x-forwarded-proto": "gopher", "x-forwarded-host": "evil.example:8443" },
273 { forwarded: "host=evil.example;proto=https" },
274 { origin: "https://evil.example" },
275 ]) {
276 const page = await (await fetch(base + "/", { headers: spoof })).text();
277 expect(page).toContain(canonical);
278 expect(page).toContain(
279 `<meta property="og:url" content="${publicOrigin}/">`,
280 );
281 expect(page).not.toContain("evil.example");
282 expect(page).toMatch(
283 new RegExp(`<meta property="og:image" content="${publicOrigin}/card`),
284 );
285 }
286 });
287
288 test("the card is a PNG the app drew, cached a year at its versioned address", async () => {
289 const page = await (await fetch(base + "/")).text();
290 const href = /content="[^"]*(\/card\.png\?v=[0-9a-f]{8})"/.exec(page)?.[1];
291 expect(href).toBeDefined();
292 const card = await fetch(base + href!);
293 expect(card.status).toBe(200);
294 expect(card.headers.get("content-type")).toBe("image/png");
295 expect(card.headers.get("cache-control")).toContain("immutable");
296 expect(card.headers.get("x-content-type-options")).toBe("nosniff");
297 const png = new Uint8Array(await card.arrayBuffer());
298 expect([...png.slice(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
299 // 1200×630, the size the tags promise, read out of the IHDR itself.
300 const head = new DataView(png.buffer, 16, 8);
301 expect([head.getUint32(0), head.getUint32(4)]).toEqual([1200, 630]);
302 // The same picture for everyone: a link pasted into a chat cannot leak who
303 // was playing, because the card has no idea who asked for it.
304 const mine = new Uint8Array(
305 await (await api(href!, {}, alice)).arrayBuffer(),
306 );
307 expect(mine).toEqual(png);
308
309 const touch = await fetch(base + "/icon-180.png?v=1");
310 expect(touch.headers.get("content-type")).toBe("image/png");
311 const square = new DataView(
312 new Uint8Array(await touch.arrayBuffer()).buffer,
313 16,
314 8,
315 );
316 expect([square.getUint32(0), square.getUint32(4)]).toEqual([180, 180]);
317 // Unversioned, it is revalidated rather than kept for a year.
318 expect((await fetch(base + "/card.png")).headers.get("cache-control")).toBe(
319 "no-cache",
320 );
321 });
322
323 test("robots.txt: the page is crawlable, the doors are not", async () => {
324 const response = await fetch(base + "/robots.txt");
325 expect(response.status).toBe(200);
326 expect(response.headers.get("content-type")).toContain("text/plain");
327 const body = await response.text();
328 expect(body).toContain("User-agent: *");
329 expect(body).toContain("Allow: /");
330 for (const closed of ["/events", "/mcp", "/__forge/"])
331 expect(body).toContain(`Disallow: ${closed}`);
332 });
333
236334 test("static files are cached for a year when linked by content", async () => {
237335 const page = await (await fetch(base + "/")).text();
238336 const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
src/server.ts +8
@@ -6,6 +6,7 @@
66 * it serves what the forge said; a test calls `serve` with its own.
77 */
88
9 import { card, touchIcon } from "./card.ts";
910 import { openDb } from "./db.ts";
1011 import { dataDir, identityKey, title, where } from "./env.ts";
1112 import { allow, bucketOf, html, sameOrigin, text } from "./http.ts";
@@ -21,6 +22,13 @@ export interface ServeOptions {
2122
2223 export function serve(options: ServeOptions) {
2324 const db = openDb(options.dataDir);
25 // The card and the touch icon are drawn once, in memory. Draw them just
26 // after the socket opens rather than inside the first request: the first
27 // paint links them by their hash, and nobody should wait for pixels.
28 setTimeout(() => {
29 card.bytes();
30 touchIcon.bytes();
31 }, 0);
2432 return Bun.serve({
2533 port: options.port,
2634 hostname: options.hostname ?? "127.0.0.1",
static/icon.svg added +19
@@ -0,0 +1,19 @@
1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="drift">
2 <title>drift</title>
3 <defs>
4 <linearGradient id="wake" x1="8" y1="56" x2="50" y2="14" gradientUnits="userSpaceOnUse">
5 <stop offset="0" stop-color="#6f8cff" stop-opacity="0"/>
6 <stop offset=".55" stop-color="#6f8cff" stop-opacity=".75"/>
7 <stop offset="1" stop-color="#dbe6ff"/>
8 </linearGradient>
9 <radialGradient id="bloom">
10 <stop offset="0" stop-color="#fff3dc"/>
11 <stop offset=".45" stop-color="#ffc266"/>
12 <stop offset="1" stop-color="#ffc266" stop-opacity="0"/>
13 </radialGradient>
14 </defs>
15 <rect width="64" height="64" rx="14" fill="#0a1020"/>
16 <circle cx="25" cy="39" r="12" fill="url(#bloom)"/>
17 <path d="M7 58C19 52 30 41 50 15" fill="none" stroke="url(#wake)" stroke-width="5" stroke-linecap="round"/>
18 <circle cx="50" cy="15" r="5.5" fill="#dbe6ff"/>
19 </svg>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs