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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11378-the-forge-toolbar-stays-under-a-size-bud → main 2 commits fork 4 threads · 4 unresolved
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 | ||
| 229 | 229 | arrow keys walk it from corner to corner. The corner is kept in the browser's |
| 230 | 230 | `localStorage` for that app's host (bottom-right until then), and the panel opens |
| 231 | 231 | 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. | |
| 233 | 233 | |
| 234 | 234 | The forge pill on an app's pages says where you are: its accessible name reads |
| 235 | 235 | `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 | ||
| 6 | 6 | |
| 7 | 7 | 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. |
| 8 | 8 | |
| 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 | ||
| 9 | 11 | ## What is HTML |
| 10 | 12 | |
| 11 | 13 | 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", () => { | ||
| 214 | 214 | ); |
| 215 | 215 | expect(toolbarJs).not.toContain("document.cookie"); |
| 216 | 216 | 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. | |
| 219 | 218 | }); |
| 220 | 219 | }); |
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"; | |
| 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 | }); |
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.