Public: anyone can read this; the crew can change it.
Each page's client JavaScript and CSS has a byte ceiling the check enforces #12020
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/12018-each-page-s-client-javascript-and-css-ha → main 3 commits fork
6 files
changed +270
−1
CLAUDE.md +8
| @@ -234,6 +234,14 @@ serve stdio, an SSH channel and a headless test with no branch anywhere. And a | ||
| 234 | 234 | session acts as the account its **key** maps to and nothing else; the `--user` |
| 235 | 235 | flag exists only in the stdio dev runner and is never reachable over SSH. |
| 236 | 236 | |
| 237 | ## UI work: the three checks (#11932) | |
| 238 | ||
| 239 | Read this before touching `src/web/`. Principles live in `docs/DESIGN.md`; these are the mechanical rules builders and reviewers apply alike. | |
| 240 | ||
| 241 | 1. **A spec per component.** Each design-system component has one spec asserting the same list at 390x844 and 1280x800: operable by keyboard alone, a name and role on every control, visible focus, tap targets of 44 px or more, no horizontal page scroll, nothing closed covering the page, right in `dir="rtl"`, contrast in light and dark. A component without its spec fails the check (#12016 owns the helper). | |
| 242 | 2. **A lint over `src/web/`.** `bun scripts/design-lint.ts` before you push: off-token colours, spacing and z-index, and a full-viewport `position: fixed` box outside the overlay host are refused, each with the rule and the fix in one line. Build a control from its component, never by hand. | |
| 243 | 3. **A byte ceiling per page.** `docs/design/size-budget.json` holds each first page's client JS and CSS in gzip bytes (linked assets plus inline script and style, counted by `src/measure/client_size.ts`). `test/client-size.test.ts` names the page and the bytes over. Lower a ceiling with `FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts`; never raise one without saying why in the pull. | |
| 244 | ||
| 237 | 245 | ## Recipes |
| 238 | 246 | |
| 239 | 247 | - **Add a mutation:** write it once as an action in `actions/<concept>.ts` — |
docs/design/size-budget.json added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | { | |
| 2 | "/": { | |
| 3 | "js": 20824, | |
| 4 | "css": 37990 | |
| 5 | }, | |
| 6 | "/explore": { | |
| 7 | "js": 20539, | |
| 8 | "css": 37990 | |
| 9 | }, | |
| 10 | "/map": { | |
| 11 | "js": 25949, | |
| 12 | "css": 39440 | |
| 13 | }, | |
| 14 | "/:owner/:name": { | |
| 15 | "js": 33905, | |
| 16 | "css": 37990 | |
| 17 | }, | |
| 18 | "/changelog": { | |
| 19 | "js": 25280, | |
| 20 | "css": 37990 | |
| 21 | }, | |
| 22 | "/pricing": { | |
| 23 | "js": 20539, | |
| 24 | "css": 37990 | |
| 25 | }, | |
| 26 | "/forges/new": { | |
| 27 | "js": 20770, | |
| 28 | "css": 37990 | |
| 29 | }, | |
| 30 | "/claim": { | |
| 31 | "js": 22943, | |
| 32 | "css": 37990 | |
| 33 | } | |
| 34 | } |
scripts/client-size.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * Reads the client bytes (gzip JS and CSS) of each first page of a running forge and | |
| 3 | * judges them against docs/design/size-budget.json (#12018): | |
| 4 | * bun scripts/client-size.ts https://forge.example [cookie] | |
| 5 | * The cookie ("name=value") signs the walk in; signed-out pages are walked without it. | |
| 6 | * Exits 1 naming each page and the bytes over. | |
| 7 | */ | |
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { clientSize, sizeFindings, type Ceilings } from "../src/measure/client_size.ts"; | |
| 10 | import { PAGE_BUDGETS, pagePath } from "../src/perf/page_budget.ts"; | |
| 11 | ||
| 12 | const [base, cookie] = process.argv.slice(2); | |
| 13 | if (base === undefined || !/^https?:\/\//.test(base)) { | |
| 14 | console.error("usage: bun scripts/client-size.ts <https://forge> [cookie]"); | |
| 15 | process.exit(2); | |
| 16 | } | |
| 17 | const ceilings = JSON.parse(readFileSync(new URL("../docs/design/size-budget.json", import.meta.url), "utf8")) as Ceilings; | |
| 18 | const get = (path: string, signedIn: boolean): Promise<Response> => | |
| 19 | fetch(new URL(path, base), { headers: { "accept-encoding": "identity", ...(signedIn && cookie ? { cookie } : {}) } }); | |
| 20 | ||
| 21 | const findings: string[] = []; | |
| 22 | for (const b of PAGE_BUDGETS) { | |
| 23 | const res = await get(pagePath(b, "alice", "notes"), b.signedOut !== true); | |
| 24 | if (res.status !== 200) { | |
| 25 | findings.push(`${b.pattern} (${b.page}): answered ${res.status}, not 200`); | |
| 26 | continue; | |
| 27 | } | |
| 28 | const size = await clientSize(await res.text(), async (path) => (await get(path, false)).text()); | |
| 29 | console.log(`${b.pattern}\tjs ${size.js}\tcss ${size.css}`); | |
| 30 | findings.push(...sizeFindings(`${b.pattern} (${b.page})`, size, ceilings[b.pattern])); | |
| 31 | } | |
| 32 | for (const f of findings) console.error(f); | |
| 33 | process.exit(findings.length === 0 ? 0 : 1); |
scripts/unreached-allow.json −1
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | [ |
| 2 | "src/perf/page_budget.ts", | |
| 3 | 2 | "src/perf/page_budget_read.ts", |
| 4 | 3 | "src/spawn/pod.ts" |
| 5 | 4 | ] |
src/measure/client_size.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * The client bytes of one page (#12018): the JavaScript and CSS a browser | |
| 3 | * must fetch or parse to draw it, as gzip bytes. Counted from the page's own | |
| 4 | * HTML: each `<script src>` and `<link rel=stylesheet>` it names (the bodies | |
| 5 | * served at those URLs, compressed with gzip level default) plus every | |
| 6 | * inline `<script>` and `<style>` body. Data blocks (`type=application/json`, | |
| 7 | * speculation rules) are not code and are left out. | |
| 8 | * The ceilings live in docs/design/size-budget.json; a pass may lower one | |
| 9 | * (FORGE_SIZE_WRITE=1 rewrites the file), nothing may raise one without a | |
| 10 | * note in the pull. | |
| 11 | */ | |
| 12 | ||
| 13 | export interface ClientSize { | |
| 14 | readonly js: number; | |
| 15 | readonly css: number; | |
| 16 | } | |
| 17 | ||
| 18 | export type Ceilings = Record<string, ClientSize>; | |
| 19 | ||
| 20 | const gz = (text: string): number => Bun.gzipSync(new TextEncoder().encode(text)).length; | |
| 21 | ||
| 22 | /** Each `src` of a script and `href` of a stylesheet, same-origin paths only, in page order. */ | |
| 23 | function linkedAssets(html: string): { scripts: string[]; styles: string[] } { | |
| 24 | const scripts: string[] = []; | |
| 25 | const styles: string[] = []; | |
| 26 | for (const m of html.matchAll(/<script\b([^>]*)>/gi)) { | |
| 27 | const src = /\ssrc="([^"]+)"/i.exec(m[1]!)?.[1]; | |
| 28 | if (src !== undefined && src.startsWith("/")) scripts.push(src); | |
| 29 | } | |
| 30 | for (const m of html.matchAll(/<link\b([^>]*)>/gi)) { | |
| 31 | if (!/\srel="?stylesheet/i.test(m[1]!)) continue; | |
| 32 | const href = /\shref="([^"]+)"/i.exec(m[1]!)?.[1]; | |
| 33 | if (href !== undefined && href.startsWith("/")) styles.push(href); | |
| 34 | } | |
| 35 | return { scripts, styles }; | |
| 36 | } | |
| 37 | ||
| 38 | /** The bodies of inline scripts (code only) and styles. */ | |
| 39 | function inlineCode(html: string): { scripts: string[]; styles: string[] } { | |
| 40 | const scripts: string[] = []; | |
| 41 | const styles: string[] = []; | |
| 42 | for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) { | |
| 43 | const attrs = m[1]!; | |
| 44 | if (/\ssrc=/i.test(attrs)) continue; | |
| 45 | const type = /\stype="([^"]*)"/i.exec(attrs)?.[1]?.toLowerCase(); | |
| 46 | if (type !== undefined && type !== "module" && type !== "text/javascript") continue; | |
| 47 | if (m[2]!.trim() !== "") scripts.push(m[2]!); | |
| 48 | } | |
| 49 | for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) if (m[1]!.trim() !== "") styles.push(m[1]!); | |
| 50 | return { scripts, styles }; | |
| 51 | } | |
| 52 | ||
| 53 | /** The page's client bytes; `fetchText` answers an asset path with its plain body. */ | |
| 54 | export async function clientSize(html: string, fetchText: (path: string) => Promise<string>): Promise<ClientSize> { | |
| 55 | const linked = linkedAssets(html); | |
| 56 | const inline = inlineCode(html); | |
| 57 | const sum = async (paths: string[], bodies: string[]): Promise<number> => { | |
| 58 | let n = 0; | |
| 59 | for (const p of [...new Set(paths)]) n += gz(await fetchText(p)); | |
| 60 | for (const b of bodies) n += gz(b); | |
| 61 | return n; | |
| 62 | }; | |
| 63 | return { js: await sum(linked.scripts, inline.scripts), css: await sum(linked.styles, inline.styles) }; | |
| 64 | } | |
| 65 | ||
| 66 | /** The finding for one page over its ceiling, naming the page and the bytes over; none within. */ | |
| 67 | export function sizeFindings(page: string, size: ClientSize, ceiling: ClientSize | undefined): string[] { | |
| 68 | if (ceiling === undefined) return [`${page}: no ceiling in docs/design/size-budget.json (js ${size.js} B, css ${size.css} B gzip)`]; | |
| 69 | const out: string[] = []; | |
| 70 | for (const kind of ["js", "css"] as const) | |
| 71 | if (size[kind] > ceiling[kind]) | |
| 72 | out.push(`${page}: ${kind} ${size[kind]} B gzip, ${size[kind] - ceiling[kind]} B over its ceiling of ${ceiling[kind]} B`); | |
| 73 | return out; | |
| 74 | } |
test/client-size.test.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** | |
| 2 | * Each first page's client JavaScript and CSS, gzip bytes, stay under the | |
| 3 | * ceiling in docs/design/size-budget.json (#12018). Same pages as the | |
| 4 | * page-load budget. FORGE_SIZE_WRITE=1 rewrites the file with today's numbers | |
| 5 | * (only ever to lower them; raising one needs a note in the pull). | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createOrg } from "../src/core/orgs.ts"; | |
| 14 | import { getRepo } from "../src/core/repos.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { clientSize, sizeFindings, type Ceilings } from "../src/measure/client_size.ts"; | |
| 18 | import { PAGE_BUDGETS, pagePath } from "../src/perf/page_budget.ts"; | |
| 19 | import { mintClaim } from "../src/spawn/claim.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { promote } from "./helpers/members.ts"; | |
| 22 | import { proveAll } from "./helpers/verified.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const FILE = "docs/design/size-budget.json"; | |
| 27 | let root = ""; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let cookie = ""; | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | root = mkdtempSync(join(tmpdir(), "forge-size-")); | |
| 34 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 35 | const u = await createUser(app.db, { | |
| 36 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 37 | password: "a-good-passphrase", adminIfFirst: true, | |
| 38 | }); | |
| 39 | if (!u.ok) throw new Error(u.error); | |
| 40 | promote(app.db, u.value); | |
| 41 | proveAll(app); | |
| 42 | for (const name of ["forge", "notes"]) { | |
| 43 | const made = await createRepo(app, u.value, { name, visibility: "public" }); | |
| 44 | if (!made.ok) throw new Error(String(made.error)); | |
| 45 | } | |
| 46 | const notes = getRepo(app.db, "alice", "notes")!; | |
| 47 | const files = await commitToBranch(app, u.value, notes, { | |
| 48 | message: "an app", | |
| 49 | files: [ | |
| 50 | { path: "README.md", content: "# notes\n" }, | |
| 51 | { path: "forge.json", content: JSON.stringify({ run: "bun run main.ts" }) }, | |
| 52 | { path: "main.ts", content: "Bun.serve({ fetch: () => new Response('ok') });\n" }, | |
| 53 | ], | |
| 54 | }); | |
| 55 | if (!files.ok) throw new Error(JSON.stringify(files.error)); | |
| 56 | const kin = createOrg(app.db, "kin", u.value.id); | |
| 57 | if (!kin.ok) throw new Error(kin.error); | |
| 58 | await Bun.write(join(root, "parent.json"), JSON.stringify({ org: "kin" })); | |
| 59 | mintClaim(app.db, kin.value.id); | |
| 60 | servers = await serve(app); | |
| 61 | const signin = await fetch(`${servers.url}/signin`, { | |
| 62 | method: "POST", headers: { origin: servers.url }, | |
| 63 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual", | |
| 64 | }); | |
| 65 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 66 | if (!cookie) throw new Error("alice could not sign in"); | |
| 67 | }, 60_000); | |
| 68 | ||
| 69 | afterAll(() => { | |
| 70 | servers?.stop(); | |
| 71 | app?.close(); | |
| 72 | rmSync(root, { recursive: true, force: true }); | |
| 73 | }); | |
| 74 | ||
| 75 | async function measure(): Promise<Ceilings> { | |
| 76 | const out: Ceilings = {}; | |
| 77 | for (const b of PAGE_BUDGETS) { | |
| 78 | const res = await fetch(`${servers.url}${pagePath(b, "alice", "notes")}`, { | |
| 79 | headers: b.signedOut ? { "accept-encoding": "identity" } : { cookie, "accept-encoding": "identity" }, | |
| 80 | }); | |
| 81 | if (res.status !== 200) throw new Error(`${b.pattern} answered ${res.status}`); | |
| 82 | out[b.pattern] = await clientSize(await res.text(), async (path) => { | |
| 83 | const asset = await fetch(`${servers.url}${path}`, { headers: { "accept-encoding": "identity" }, redirect: "follow" }); | |
| 84 | if (asset.status !== 200) throw new Error(`${path} answered ${asset.status}`); | |
| 85 | return asset.text(); | |
| 86 | }); | |
| 87 | } | |
| 88 | return out; | |
| 89 | } | |
| 90 | ||
| 91 | test("no first page's client JavaScript or CSS passes its ceiling", async () => { | |
| 92 | const sizes = await measure(); | |
| 93 | if (process.env["FORGE_SIZE_WRITE"] === "1") { | |
| 94 | writeFileSync(FILE, `${JSON.stringify(sizes, null, 2)}\n`); | |
| 95 | return; | |
| 96 | } | |
| 97 | const ceilings = JSON.parse(readFileSync(FILE, "utf8")) as Ceilings; | |
| 98 | const findings = PAGE_BUDGETS.flatMap((b) => sizeFindings(`${b.pattern} (${b.page})`, sizes[b.pattern]!, ceilings[b.pattern])); | |
| 99 | console.log(`client bytes, gzip:\n${PAGE_BUDGETS.map((b) => `${b.pattern}\tjs ${sizes[b.pattern]!.js}\tcss ${sizes[b.pattern]!.css}`).join("\n")}`); | |
| 100 | expect(findings).toEqual([]); | |
| 101 | }, 60_000); | |
| 102 | ||
| 103 | test("the ceiling file names exactly the budgeted pages", () => { | |
| 104 | const ceilings = JSON.parse(readFileSync(FILE, "utf8")) as Ceilings; | |
| 105 | expect(Object.keys(ceilings).sort()).toEqual(PAGE_BUDGETS.map((b) => b.pattern).sort()); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a page over its ceiling is named with the bytes over", () => { | |
| 109 | expect(sizeFindings("/explore (explore)", { js: 1500, css: 900 }, { js: 1000, css: 900 })).toEqual([ | |
| 110 | "/explore (explore): js 1500 B gzip, 500 B over its ceiling of 1000 B", | |
| 111 | ]); | |
| 112 | expect(sizeFindings("/x (x)", { js: 1, css: 1 }, undefined)[0]).toContain("no ceiling"); | |
| 113 | }); | |
| 114 | ||
| 115 | test("the count is gzip of linked and inline code, and leaves data blocks out", async () => { | |
| 116 | const html = `<link rel="stylesheet" href="/assets/a.css"><script src="/assets/a.js" defer></script> | |
| 117 | <script nonce="n">let a=1;</script><script type="application/json">{"a":1}</script><style>b{c:d}</style>`; | |
| 118 | const gz = (s: string): number => Bun.gzipSync(new TextEncoder().encode(s)).length; | |
| 119 | const size = await clientSize(html, async (p) => (p.endsWith(".js") ? "x".repeat(1000) : "y{z:1}".repeat(100))); | |
| 120 | expect(size).toEqual({ js: gz("x".repeat(1000)) + gz("let a=1;"), css: gz("y{z:1}".repeat(100)) + gz("b{c:d}") }); | |
| 121 | }); |
new above
offline