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
Check · Review · Land · Live done — Landed and live in production.
quill/drift:quill/28-the-link-you-send-a-friend-a-shared-worl → main 3 commits fork
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 | ||
| 41 | 41 | the page, the JSON API, the MCP server and the terminal all call it and |
| 42 | 42 | decide nothing themselves. `AGENTS.md` says how to add to it. |
| 43 | 43 | |
| 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. | |
| 60 | 86 | |
| 61 | 87 | ## Who you are |
| 62 | 88 |
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 { | ||
| 273 | 273 | } |
| 274 | 274 | return `/static/${name}?v=${v}`; |
| 275 | 275 | } |
| 276 | const cacheFor = (versioned: boolean): string => | |
| 277 | versioned ? "public, max-age=31536000, immutable" : "no-cache"; | |
| 278 | ||
| 276 | 279 | export function staticFile(name: string, versioned: boolean): Response { |
| 277 | 280 | const path = join(STATIC, name); |
| 278 | 281 | if (name.includes("/") || name.includes("..") || !existsSync(path)) |
| 279 | 282 | return text("not found", 404); |
| 280 | 283 | 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, { | |
| 281 | 295 | headers: { |
| 282 | "cache-control": versioned | |
| 283 | ? "public, max-age=31536000, immutable" | |
| 284 | : "no-cache", | |
| 296 | "content-type": type, | |
| 297 | "cache-control": cacheFor(versioned), | |
| 285 | 298 | ...SECURITY, |
| 286 | 299 | }, |
| 287 | 300 | }); |
| 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 { | ||
| 18 | 18 | } from "./concept.ts"; |
| 19 | 19 | import { commit, description, env, ssh, title, where } from "./env.ts"; |
| 20 | 20 | import { asset, escape } from "./http.ts"; |
| 21 | import { social } from "./meta.ts"; | |
| 21 | 22 | import type { Who } from "./who.ts"; |
| 22 | 23 | |
| 23 | 24 | export function layout(body: string, who: Who): string { |
| @@ -31,11 +32,11 @@ export function layout(body: string, who: Who): string { | ||
| 31 | 32 | <meta charset="utf-8"> |
| 32 | 33 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 33 | 34 | <meta name="color-scheme" content="light dark"> |
| 34 | <link rel="icon" href="data:,"> | |
| 35 | 35 | <title>${escape(title)}</title> |
| 36 | 36 | <meta name="description" content="${escape(description)}"> |
| 37 | 37 | <link rel="stylesheet" href="${asset("app.css")}"> |
| 38 | 38 | <script type="module" src="${asset("live.js")}"></script> |
| 39 | ${social("/")} | |
| 39 | 40 | <body> |
| 40 | 41 | <div class="page"> |
| 41 | 42 | <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 @@ | ||
| 3 | 3 | * calls an action, and shapes the answer for its door — HTML for a |
| 4 | 4 | * browser, JSON for a script — and that is all a route does. |
| 5 | 5 | * |
| 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. | |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | import { card, touchIcon } from "./card.ts"; | |
| 12 | 13 | import { |
| 13 | 14 | bucketOf, |
| 15 | bytes, | |
| 14 | 16 | empty, |
| 15 | 17 | html, |
| 16 | 18 | json, |
| @@ -21,6 +23,7 @@ import { | ||
| 21 | 23 | } from "./http.ts"; |
| 22 | 24 | import { events } from "./live.ts"; |
| 23 | 25 | import { rpc } from "./mcp.ts"; |
| 26 | import { robots } from "./meta.ts"; | |
| 24 | 27 | import { home } from "./page.ts"; |
| 25 | 28 | import type { Who } from "./who.ts"; |
| 26 | 29 | |
| @@ -82,6 +85,22 @@ export const routes: readonly Route[] = [ | ||
| 82 | 85 | handle: () => text("this endpoint speaks JSON-RPC over POST", 405), |
| 83 | 86 | }, |
| 84 | 87 | |
| 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 | ||
| 85 | 104 | // Files under ./static, cached for a year when linked by content (`asset`). |
| 86 | 105 | { |
| 87 | 106 | method: "GET", |
src/server.test.ts +98
| @@ -233,6 +233,104 @@ test("refuses a cross-site post", async () => { | ||
| 233 | 233 | expect(posted.status).toBe(403); |
| 234 | 234 | }); |
| 235 | 235 | |
| 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 | ||
| 236 | 334 | test("static files are cached for a year when linked by content", async () => { |
| 237 | 335 | const page = await (await fetch(base + "/")).text(); |
| 238 | 336 | const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1]; |
src/server.ts +8
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * it serves what the forge said; a test calls `serve` with its own. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { card, touchIcon } from "./card.ts"; | |
| 9 | 10 | import { openDb } from "./db.ts"; |
| 10 | 11 | import { dataDir, identityKey, title, where } from "./env.ts"; |
| 11 | 12 | import { allow, bucketOf, html, sameOrigin, text } from "./http.ts"; |
| @@ -21,6 +22,13 @@ export interface ServeOptions { | ||
| 21 | 22 | |
| 22 | 23 | export function serve(options: ServeOptions) { |
| 23 | 24 | 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); | |
| 24 | 32 | return Bun.serve({ |
| 25 | 33 | port: options.port, |
| 26 | 34 | 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> |