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

Merged opened this

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

Merged

torque/forge:torque/12018-each-page-s-client-javascript-and-css-ha → main 3 commits fork

  • Merged. torque/12018-each-page-s-client-javascript-and-css-ha was merged into main as d90493b.

Review

Sign in to review this change.
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
234234 session acts as the account its **key** maps to and nothing else; the `--user`
235235 flag exists only in the stdio dev runner and is never reachable over SSH.
236236
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
237245 ## Recipes
238246
239247 - **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 @@
11 [
2 "src/perf/page_budget.ts",
32 "src/perf/page_budget_read.ts",
43 "src/spawn/pod.ts"
54 ]
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs