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

The forge toolbar stays under a size budget and causes no layout shift, pinned by a test #11382

Merged opened this

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

Merged

scout/forge:scout/11378-the-forge-toolbar-stays-under-a-size-bud → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/11378-the-forge-toolbar-stays-under-a-size-bud was merged into main as 14e01df.

Review

Sign in to review this change.
4 files changed +103 −3
docs/APPS.md +1 −1
@@ -229,7 +229,7 @@ touch) and it snaps to the nearest corner on release; with the pill focused, the
229229 arrow keys walk it from corner to corner. The corner is kept in the browser's
230230 `localStorage` for that app's host (bottom-right until then), and the panel opens
231231 toward the inside of the screen, bounded to it at phone width. The script stays
232 under a 10 KB budget (a test holds it), since it loads on every app page.
232 under 24 KB (`test/toolbar-budget.test.ts` holds it), since it loads on every app page.
233233
234234 The forge pill on an app's pages says where you are: its accessible name reads
235235 `notes, production` or `notes, preview of pull #7`, and pressing it opens a
docs/web.md +2
@@ -6,6 +6,8 @@ Short rules. `test/web-budget.test.ts` counts the bytes and fails when one is br
66
77 Anything that is the same bytes for everyone and changes only when the code does: the stylesheet, the shell and beacon scripts, each page's own script, the icon sprite (`/assets/i-<hash>.svg`), the speculation rules. Each is named by a hash of its content (`web/asset.ts`) and sent `public, max-age=31536000, immutable`. Nothing inline: a page never carries a drawing or a script. Runtime data reaches a page script through `data-*` attributes. A page script is also kept under `<dataDir>/page-scripts/` the first time a process renders it, so after a restart an edge copy or a cached page that names it is still answered; the body read back must hash to its name, and a file nothing has rendered for 30 days is pruned.
88
9 The forge toolbar script stays under **24 KB** (24,576 bytes) because it loads on every app page; `test/toolbar-budget.test.ts` holds it.
10
911 ## What is HTML
1012
1113 Markup only, and small. It names its assets; it never carries them. Budget: **home under 40 KB raw**, no inline `<script>` (a `type="application/json"` or `application/ld+json` data block is data), no inline `<symbol>`. The two inline `<style>` blocks (the boot theme and a page's own) are admitted by hash in the policy, not by a nonce, so a page is the same bytes for every visitor and can be cached.
test/apps-crew-pill.test.ts +1 −2
@@ -214,7 +214,6 @@ describe("the crew line", () => {
214214 );
215215 expect(toolbarJs).not.toContain("document.cookie");
216216 expect(toolbarJs).toContain('aria-live", "polite"');
217 // 24 KB: the script keeps its comments, and the filed number and links (#10560) added about 0.8 KB.
218 expect(toolbarJs.length).toBeLessThan(24 * 1024);
217 // The byte budget lives in test/toolbar-budget.test.ts.
219218 });
220219 });
test/toolbar-budget.test.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * The toolbar every app and preview carries costs almost nothing (#11378):
3 * its script stays under a byte budget, its host is fixed so nothing is
4 * inserted above the page's content, and the script and its tag carry nothing
5 * a strict CSP would block.
6 */
7 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
outdated

Duplicates test/apps-crew-pill.test.ts:218 (< 24*1024) with a looser number, and docs/APPS.md:232 says 10 KB. Keep a single budget; this one can never fail before the existing one.

12 import { newApp } from "../src/actions/apps.ts";
13 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
14 import { TOOLBAR_ASSET_PATH, TOOLBAR_PATH, toolbarJs, toolbarTag } from "../src/apps/toolbar.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 /** The one budget (it loads on every app page): 24 KB, the script keeping the filed number and links of #10560. Raising it is a decision, not a fix. */
22 export const TOOLBAR_BYTE_BUDGET = 24 * 1024;
23
24 test("the toolbar script stays under its byte budget", () => {
25 expect(Buffer.byteLength(toolbarJs)).toBeLessThan(TOOLBAR_BYTE_BUDGET);
26 });
outdated

Source regexes only: they pass for a toolbar that shifts content some other way. Assert the fixed placement of the visible parts, or retitle as a source check.

27
28 test("the toolbar tag is one small deferred script", () => {
29 const tag = toolbarTag("https://forge.test", { app: "notes", repo: "a/b", env: "preview", pull: 7 }, "proof", "n0nce");
30 expect(Buffer.byteLength(tag)).toBeLessThan(600);
31 expect(tag).toContain(`src="${TOOLBAR_ASSET_PATH}"`);
32 expect(tag).toContain(" defer");
33 });
34
35 test("source check: the toolbar host is fixed, so it has no in-flow size, and nothing is inserted above content", () => {
36 expect(toolbarJs).toContain('host.style.cssText = "all:initial;position:fixed;z-index:2147483647"');
37 // It is appended to the end of the page, never placed before existing content.
38 expect(toolbarJs).not.toMatch(/insertBefore|prepend\(|\.before\(|insertAdjacentElement\(\s*["']before/i);
39 expect(toolbarJs).not.toMatch(/firstChild|firstElementChild/);
40 expect(toolbarJs).toMatch(/\.append(Child)?\(host\)/);
41 });
42
43 test("nothing in the script or its tag is blocked by a strict CSP", () => {
44 const tag = toolbarTag("https://forge.test", { app: "notes", repo: "a/b", env: "preview", title: `"><img onerror=x>` }, undefined, "n");
outdated

This applies securityFloor by hand, so the test passes even if the served route never applies it. Request the asset through the real server/proxy handlers and assert on that response.

45 const names = [...tag.matchAll(/\s([a-z-]+)(?==")/g)].map((m) => m[1]!);
46 for (const n of names) expect(n === "src" || n === "nonce" || n.startsWith("data-")).toBe(true);
47 expect(tag).not.toContain("<img");
48 for (const banned of [/\beval\(/, /new Function/, /\.onclick\s*=\s*["']/, /setAttribute\(\s*["']on/i, /<style/i, /\sstyle="/i, /javascript:/i])
49 expect(toolbarJs).not.toMatch(banned);
50 });
51
52 const DOMAIN = "apps.test";
53 let dataDir: string;
54 let app: App;
55 let servers: Servers;
56 let runtime: AppsRuntime;
57 let backend: ReturnType<typeof Bun.serve>;
58 let status: ReturnType<typeof spyOn>;
59
60 beforeAll(async () => {
61 backend = Bun.serve({ port: 0, fetch: () => new Response("the app") });
62 dataDir = await mkdtemp(join(tmpdir(), "forge-toolbar-budget-"));
63 app = createApp({ dataDir, port: 0, host: "forge.test" });
64 servers = await serve(app);
65 runtime = new AppsRuntime(app, { domain: DOMAIN, scheme: "http", publicPort: servers.port, forgeUrl: servers.url, concurrency: 1 });
66 await runtime.start();
67 status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation(
68 () => ({ env: "production", state: "running", port: backend.port, commitOid: "a".repeat(40) }) as EnvStatus,
69 );
70 const a = await createUser(app.db, {
71 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
72 password: "a-good-passphrase", adminIfFirst: true,
73 });
74 if (!a.ok) throw new Error(a.error);
75 const made = await newApp(app, a.value, { name: "notes", description: "A place for words", visibility: "public" });
76 if (!made.ok) throw new Error(String(made.error));
77 }, 120_000);
78
79 afterAll(async () => {
80 status.mockRestore();
81 await runtime.stop();
82 servers.stop();
83 backend.stop(true);
84 app.close();
85 await rm(dataDir, { recursive: true, force: true });
86 });
87
88 test("the forge's route and the app host's route both serve the script immutable with the security floor", async () => {
89 const forge = await fetch(`${servers.url}${TOOLBAR_PATH}`, { redirect: "manual" });
90 const own = await fetch(`${servers.url}${TOOLBAR_ASSET_PATH}`, { headers: { host: `notes.${DOMAIN}` }, redirect: "manual" });
91 for (const res of [forge, own]) {
92 expect(res.status).toBe(200);
93 expect(await res.text()).toBe(toolbarJs);
94 expect(res.headers.get("cache-control")).toContain("immutable");
95 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
96 expect(res.headers.get("referrer-policy")).toBeTruthy();
97 expect(res.headers.get("content-security-policy")).toBeTruthy();
98 }
99 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs