Public: anyone can read this; the crew can change it.
The client-size check gives the same verdict on every run: ceilings keep headroom and a page measures the same bytes twice #12042
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/12040-the-client-size-check-gives-the-same-ver → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +153
−41
CLAUDE.md +1 −1
| @@ -240,7 +240,7 @@ Read this before touching `src/web/`. Principles live in `docs/DESIGN.md`; these | ||
| 240 | 240 | |
| 241 | 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 | 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. | |
| 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` renders each page twice and refuses counted inline code that differs between renders (a per-request value goes in a data block or an attribute), then names the page, the bytes over and the command that raises the ceiling. `FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts` writes every ceiling as the measure plus 2 %, rounded up to the next 256 B; a pull that grows a page past its ceiling runs it in the same push and says why. | |
| 244 | 244 | |
| 245 | 245 | ## Recipes |
| 246 | 246 |
docs/design/size-budget.json +16 −16
| @@ -1,34 +1,34 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "/": { |
| 3 | "js": 20824, | |
| 4 | "css": 37990 | |
| 3 | "js": 21248, | |
| 4 | "css": 38912 | |
| 5 | 5 | }, |
| 6 | 6 | "/explore": { |
| 7 | "js": 20539, | |
| 8 | "css": 37990 | |
| 7 | "js": 20992, | |
| 8 | "css": 38912 | |
| 9 | 9 | }, |
| 10 | 10 | "/map": { |
| 11 | "js": 25949, | |
| 12 | "css": 39440 | |
| 11 | "js": 26624, | |
| 12 | "css": 40448 | |
| 13 | 13 | }, |
| 14 | 14 | "/:owner/:name": { |
| 15 | "js": 33905, | |
| 16 | "css": 37990 | |
| 15 | "js": 34816, | |
| 16 | "css": 38912 | |
| 17 | 17 | }, |
| 18 | 18 | "/changelog": { |
| 19 | "js": 25280, | |
| 20 | "css": 37990 | |
| 19 | "js": 25856, | |
| 20 | "css": 38912 | |
| 21 | 21 | }, |
| 22 | 22 | "/pricing": { |
| 23 | "js": 20539, | |
| 24 | "css": 37990 | |
| 23 | "js": 20992, | |
| 24 | "css": 38912 | |
| 25 | 25 | }, |
| 26 | 26 | "/forges/new": { |
| 27 | "js": 20770, | |
| 28 | "css": 37990 | |
| 27 | "js": 21248, | |
| 28 | "css": 38912 | |
| 29 | 29 | }, |
| 30 | 30 | "/claim": { |
| 31 | "js": 22943, | |
| 32 | "css": 37990 | |
| 31 | "js": 23552, | |
| 32 | "css": 38912 | |
| 33 | 33 | } |
| 34 | 34 | } |
scripts/client-size.ts +10 −2
| @@ -3,10 +3,12 @@ | ||
| 3 | 3 | * judges them against docs/design/size-budget.json (#12018): |
| 4 | 4 | * bun scripts/client-size.ts https://forge.example [cookie] |
| 5 | 5 | * The cookie ("name=value") signs the walk in; signed-out pages are walked without it. |
| 6 | * Each page is fetched twice; counted inline code that differs between the two is a | |
| 7 | * finding (#12040). Prints the ceilings `FORGE_SIZE_WRITE=1` would write from this walk. | |
| 6 | 8 | * Exits 1 naming each page and the bytes over. |
| 7 | 9 | */ |
| 8 | 10 | import { readFileSync } from "node:fs"; |
| 9 | import { clientSize, sizeFindings, type Ceilings } from "../src/measure/client_size.ts"; | |
| 11 | import { ceilingsFor, clientSize, inlineDrift, sizeFindings, type Ceilings } from "../src/measure/client_size.ts"; | |
| 10 | 12 | import { PAGE_BUDGETS, pagePath } from "../src/perf/page_budget.ts"; |
| 11 | 13 | |
| 12 | 14 | const [base, cookie] = process.argv.slice(2); |
| @@ -19,15 +21,21 @@ const get = (path: string, signedIn: boolean): Promise<Response> => | ||
| 19 | 21 | fetch(new URL(path, base), { headers: { "accept-encoding": "identity", ...(signedIn && cookie ? { cookie } : {}) } }); |
| 20 | 22 | |
| 21 | 23 | const findings: string[] = []; |
| 24 | const sizes: Ceilings = {}; | |
| 22 | 25 | for (const b of PAGE_BUDGETS) { |
| 23 | 26 | const res = await get(pagePath(b, "alice", "notes"), b.signedOut !== true); |
| 24 | 27 | if (res.status !== 200) { |
| 25 | 28 | findings.push(`${b.pattern} (${b.page}): answered ${res.status}, not 200`); |
| 26 | 29 | continue; |
| 27 | 30 | } |
| 28 | const size = await clientSize(await res.text(), async (path) => (await get(path, false)).text()); | |
| 31 | const html = await res.text(); | |
| 32 | const again = await get(pagePath(b, "alice", "notes"), b.signedOut !== true); | |
| 33 | if (again.status === 200) findings.push(...inlineDrift(`${b.pattern} (${b.page})`, html, await again.text())); | |
| 34 | const size = await clientSize(html, async (path) => (await get(path, false)).text()); | |
| 35 | sizes[b.pattern] = size; | |
| 29 | 36 | console.log(`${b.pattern}\tjs ${size.js}\tcss ${size.css}`); |
| 30 | 37 | findings.push(...sizeFindings(`${b.pattern} (${b.page})`, size, ceilings[b.pattern])); |
| 31 | 38 | } |
| 39 | console.log(`ceilings with headroom from this walk:\n${JSON.stringify(ceilingsFor(sizes), null, 2)}`); | |
| 32 | 40 | for (const f of findings) console.error(f); |
| 33 | 41 | process.exit(findings.length === 0 ? 0 : 1); |
src/measure/client_size.ts +66 −6
| @@ -5,9 +5,11 @@ | ||
| 5 | 5 | * served at those URLs, compressed with gzip level default) plus every |
| 6 | 6 | * inline `<script>` and `<style>` body. Data blocks (`type=application/json`, |
| 7 | 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. | |
| 8 | * The ceilings live in docs/design/size-budget.json, written by | |
| 9 | * `FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts` as the measured | |
| 10 | * bytes plus headroom (#12040, `ceilingFor`), so one landed byte does not red | |
| 11 | * main; a pull that grows a page past its ceiling raises it in the same push | |
| 12 | * and says why. | |
| 11 | 13 | */ |
| 12 | 14 | |
| 13 | 15 | export interface ClientSize { |
| @@ -35,6 +37,31 @@ function linkedAssets(html: string): { scripts: string[]; styles: string[] } { | ||
| 35 | 37 | return { scripts, styles }; |
| 36 | 38 | } |
| 37 | 39 | |
| 40 | /** The command that rewrites the ceilings from today's measure. */ | |
| 41 | export const SIZE_WRITE_COMMAND = "FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts"; | |
| 42 | ||
| 43 | /** Headroom over the measured bytes a written ceiling keeps: 2 %, then up to a whole block. */ | |
| 44 | const HEADROOM_FRACTION = 0.02; | |
| 45 | const HEADROOM_BLOCK = 256; | |
| 46 | ||
| 47 | /** | |
| 48 | * The ceiling written for `bytes` measured (#12040): bytes × 1.02, rounded up to | |
| 49 | * the next multiple of 256 B. Integer arithmetic (bytes·102/100, ceiling) so no | |
| 50 | * float rounding moves a boundary: 1000 → 1020 → 1024; 1004 → 1024.08 → 1280. | |
| 51 | */ | |
| 52 | export function ceilingFor(bytes: number): number { | |
| 53 | if (!Number.isInteger(bytes) || bytes < 0) throw new Error(`bytes must be a non-negative integer, got ${bytes}`); | |
| 54 | const grown = Math.ceil((bytes * (100 + HEADROOM_FRACTION * 100)) / 100); | |
| 55 | return Math.ceil(grown / HEADROOM_BLOCK) * HEADROOM_BLOCK; | |
| 56 | } | |
| 57 | ||
| 58 | /** Every page's ceilings with headroom over its measured size. */ | |
| 59 | export function ceilingsFor(sizes: Ceilings): Ceilings { | |
| 60 | const out: Ceilings = {}; | |
| 61 | for (const [page, s] of Object.entries(sizes)) out[page] = { js: ceilingFor(s.js), css: ceilingFor(s.css) }; | |
| 62 | return out; | |
| 63 | } | |
| 64 | ||
| 38 | 65 | /** The bodies of inline scripts (code only) and styles. */ |
| 39 | 66 | function inlineCode(html: string): { scripts: string[]; styles: string[] } { |
| 40 | 67 | const scripts: string[] = []; |
| @@ -50,6 +77,35 @@ function inlineCode(html: string): { scripts: string[]; styles: string[] } { | ||
| 50 | 77 | return { scripts, styles }; |
| 51 | 78 | } |
| 52 | 79 | |
| 80 | /** Each counted inline block, labelled by kind and order (`script #2`), to compare renders. */ | |
| 81 | function inlineBlocks(html: string): { label: string; body: string }[] { | |
| 82 | const inline = inlineCode(html); | |
| 83 | return [ | |
| 84 | ...inline.scripts.map((body, i) => ({ label: `script #${i + 1}`, body })), | |
| 85 | ...inline.styles.map((body, i) => ({ label: `style #${i + 1}`, body })), | |
| 86 | ]; | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * What differs in counted code between two renders of one page; empty when the | |
| 91 | * same. A per-request value (a nonce, a time, an id) inside an inline block makes | |
| 92 | * the count vary run to run; it belongs in a data block or an attribute. | |
| 93 | */ | |
| 94 | export function inlineDrift(page: string, first: string, second: string): string[] { | |
| 95 | const a = inlineBlocks(first); | |
| 96 | const b = inlineBlocks(second); | |
| 97 | if (a.length !== b.length) return [`${page}: ${a.length} counted inline blocks on one render, ${b.length} on the next`]; | |
| 98 | const out: string[] = []; | |
| 99 | for (let i = 0; i < a.length; i++) { | |
| 100 | if (a[i]!.body === b[i]!.body) continue; | |
| 101 | let at = 0; | |
| 102 | while (a[i]!.body[at] === b[i]!.body[at]) at++; | |
| 103 | const near = (s: string): string => JSON.stringify(s.slice(Math.max(0, at - 40), at + 40)); | |
| 104 | out.push(`${page}: inline ${a[i]!.label} differs between two renders at char ${at}: ${near(a[i]!.body)} vs ${near(b[i]!.body)}`); | |
| 105 | } | |
| 106 | return out; | |
| 107 | } | |
| 108 | ||
| 53 | 109 | /** The page's client bytes; `fetchText` answers an asset path with its plain body. */ |
| 54 | 110 | export async function clientSize(html: string, fetchText: (path: string) => Promise<string>): Promise<ClientSize> { |
| 55 | 111 | const linked = linkedAssets(html); |
| @@ -63,12 +119,16 @@ export async function clientSize(html: string, fetchText: (path: string) => Prom | ||
| 63 | 119 | return { js: await sum(linked.scripts, inline.scripts), css: await sum(linked.styles, inline.styles) }; |
| 64 | 120 | } |
| 65 | 121 | |
| 66 | /** The finding for one page over its ceiling, naming the page and the bytes over; none within. */ | |
| 122 | /** | |
| 123 | * The finding for one page over its ceiling, naming the page, the bytes over and the | |
| 124 | * command that raises it; none within. | |
| 125 | */ | |
| 67 | 126 | 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)`]; | |
| 127 | if (ceiling === undefined) | |
| 128 | return [`${page}: no ceiling in docs/design/size-budget.json (js ${size.js} B, css ${size.css} B gzip); write one with \`${SIZE_WRITE_COMMAND}\``]; | |
| 69 | 129 | const out: string[] = []; |
| 70 | 130 | for (const kind of ["js", "css"] as const) |
| 71 | 131 | 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`); | |
| 132 | out.push(`${page}: ${kind} ${size[kind]} B gzip, ${size[kind] - ceiling[kind]} B over its ceiling of ${ceiling[kind]} B; raise it in this pull with \`${SIZE_WRITE_COMMAND}\` and say why`); | |
| 73 | 133 | return out; |
| 74 | 134 | } |
test/client-size.test.ts +60 −16
| @@ -2,7 +2,9 @@ | ||
| 2 | 2 | * Each first page's client JavaScript and CSS, gzip bytes, stay under the |
| 3 | 3 | * ceiling in docs/design/size-budget.json (#12018). Same pages as the |
| 4 | 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). | |
| 5 | * plus headroom (2 %, up to the next 256 B: `ceilingFor`, #12040); raising one | |
| 6 | * needs a note in the pull. Each page is rendered twice and its counted inline | |
| 7 | * code must be identical, so the verdict is the same on every run. | |
| 6 | 8 | */ |
| 7 | 9 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 8 | 10 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| @@ -14,7 +16,7 @@ import { createOrg } from "../src/core/orgs.ts"; | ||
| 14 | 16 | import { getRepo } from "../src/core/repos.ts"; |
| 15 | 17 | import { createUser } from "../src/core/users.ts"; |
| 16 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | import { clientSize, sizeFindings, type Ceilings } from "../src/measure/client_size.ts"; | |
| 19 | import { ceilingFor, ceilingsFor, clientSize, inlineDrift, sizeFindings, SIZE_WRITE_COMMAND, type Ceilings } from "../src/measure/client_size.ts"; | |
| 18 | 20 | import { PAGE_BUDGETS, pagePath } from "../src/perf/page_budget.ts"; |
| 19 | 21 | import { mintClaim } from "../src/spawn/claim.ts"; |
| 20 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -72,26 +74,34 @@ afterAll(() => { | ||
| 72 | 74 | rmSync(root, { recursive: true, force: true }); |
| 73 | 75 | }); |
| 74 | 76 | |
| 75 | async function measure(): Promise<Ceilings> { | |
| 76 | const out: Ceilings = {}; | |
| 77 | async function render(b: (typeof PAGE_BUDGETS)[number]): Promise<string> { | |
| 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 | return res.text(); | |
| 83 | } | |
| 84 | ||
| 85 | async function measure(): Promise<{ sizes: Ceilings; drift: string[] }> { | |
| 86 | const sizes: Ceilings = {}; | |
| 87 | const drift: string[] = []; | |
| 77 | 88 | 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) => { | |
| 89 | const html = await render(b); | |
| 90 | drift.push(...inlineDrift(`${b.pattern} (${b.page})`, html, await render(b))); | |
| 91 | sizes[b.pattern] = await clientSize(html, async (path) => { | |
| 83 | 92 | const asset = await fetch(`${servers.url}${path}`, { headers: { "accept-encoding": "identity" }, redirect: "follow" }); |
| 84 | 93 | if (asset.status !== 200) throw new Error(`${path} answered ${asset.status}`); |
| 85 | 94 | return asset.text(); |
| 86 | 95 | }); |
| 87 | 96 | } |
| 88 | return out; | |
| 97 | return { sizes, drift }; | |
| 89 | 98 | } |
| 90 | 99 | |
| 91 | test("no first page's client JavaScript or CSS passes its ceiling", async () => { | |
| 92 | const sizes = await measure(); | |
| 100 | test("no first page's client JavaScript or CSS passes its ceiling, and each page measures the same twice", async () => { | |
| 101 | const { sizes, drift } = await measure(); | |
| 102 | expect(drift).toEqual([]); | |
| 93 | 103 | if (process.env["FORGE_SIZE_WRITE"] === "1") { |
| 94 | writeFileSync(FILE, `${JSON.stringify(sizes, null, 2)}\n`); | |
| 104 | writeFileSync(FILE, `${JSON.stringify(ceilingsFor(sizes), null, 2)}\n`); | |
| 95 | 105 | return; |
| 96 | 106 | } |
| 97 | 107 | const ceilings = JSON.parse(readFileSync(FILE, "utf8")) as Ceilings; |
| @@ -105,11 +115,45 @@ test("the ceiling file names exactly the budgeted pages", () => { | ||
| 105 | 115 | expect(Object.keys(ceilings).sort()).toEqual(PAGE_BUDGETS.map((b) => b.pattern).sort()); |
| 106 | 116 | }); |
| 107 | 117 | |
| 108 | test("a page over its ceiling is named with the bytes over", () => { | |
| 118 | test("a page over its ceiling is named with the bytes over and the command that raises it", () => { | |
| 109 | 119 | 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", | |
| 120 | "/explore (explore): js 1500 B gzip, 500 B over its ceiling of 1000 B; raise it in this pull with `FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts` and say why", | |
| 111 | 121 | ]); |
| 112 | expect(sizeFindings("/x (x)", { js: 1, css: 1 }, undefined)[0]).toContain("no ceiling"); | |
| 122 | expect(SIZE_WRITE_COMMAND).toBe("FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts"); | |
| 123 | const none = sizeFindings("/x (x)", { js: 1, css: 1 }, undefined)[0]!; | |
| 124 | expect(none).toContain("no ceiling"); | |
| 125 | expect(none).toContain(SIZE_WRITE_COMMAND); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a written ceiling is the measure plus 2 %, rounded up to the next 256 B", () => { | |
| 129 | // by hand: 1000 × 1.02 = 1020 → 1024; 1004 × 1.02 = 1024.08 → 1280; | |
| 130 | // 0 → 0; 251 × 1.02 = 256.02 → 512; 250 × 1.02 = 255 → 256; | |
| 131 | // 20824 × 1.02 = 21240.48 → 21248 (83 × 256). | |
| 132 | expect(ceilingFor(1000)).toBe(1024); | |
| 133 | expect(ceilingFor(1004)).toBe(1280); | |
| 134 | expect(ceilingFor(0)).toBe(0); | |
| 135 | expect(ceilingFor(250)).toBe(256); | |
| 136 | expect(ceilingFor(251)).toBe(512); | |
| 137 | expect(ceilingFor(20824)).toBe(21248); | |
| 138 | expect(() => ceilingFor(-1)).toThrow(); | |
| 139 | expect(() => ceilingFor(1.5)).toThrow(); | |
| 140 | expect(ceilingsFor({ "/": { js: 1000, css: 1004 } })).toEqual({ "/": { js: 1024, css: 1280 } }); | |
| 141 | for (let n = 0; n < 50_000; n += 7) { | |
| 142 | const c = ceilingFor(n); | |
| 143 | expect(c % 256).toBe(0); | |
| 144 | expect(c).toBeGreaterThanOrEqual(n * 1.02); | |
| 145 | expect(c - 256).toBeLessThan(n * 1.02); | |
| 146 | } | |
| 147 | }); | |
| 148 | ||
| 149 | test("two renders that differ in counted inline code are named by page and block; data blocks may differ", () => { | |
| 150 | const page = (nonce: string, t: string) => | |
| 151 | `<script nonce="${nonce}">let a=1;</script><script type="application/json">{"t":"${t}"}</script><style>b{c:d}</style><script>let t="${t}";</script>`; | |
| 152 | expect(inlineDrift("/ (home)", page("n1", "x"), page("n2", "x"))).toEqual([]); | |
| 153 | const d = inlineDrift("/ (home)", page("n1", "10:00"), page("n1", "10:01")); | |
| 154 | expect(d).toHaveLength(1); | |
| 155 | expect(d[0]).toStartWith("/ (home): inline script #2 differs"); | |
| 156 | expect(inlineDrift("/ (home)", "<style>a{}</style>", "")).toEqual(["/ (home): 1 counted inline blocks on one render, 0 on the next"]); | |
| 113 | 157 | }); |
| 114 | 158 | |
| 115 | 159 | test("the count is gzip of linked and inline code, and leaves data blocks out", async () => { |
new above
offline
Use an integer percent constant (102) instead of 100 + 0.02*100; it is integer by construction rather than by luck of float multiplication.