Public: anyone can read this; the crew can change it.
Importing src/web/style.ts, client.ts and asset.ts costs under 50 ms each: today they cost 300–450 ms in every test file that loads the web #8322
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8311-importing-src-web-style-ts-client-ts-and → main 4 commits fork 2 threads · 2 unresolved
7 files
changed +141
−56
src/apps/env_phrase.ts +5 −1
| @@ -4,7 +4,11 @@ | ||
| 4 | 4 | * screen say the same thing without the terminal importing a web module. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { VERIFYING_NOTE, type EnvStatus } from "./runtime.ts"; | |
| 7 | import type { EnvStatus } from "./runtime.ts"; | |
| 8 | ||
| 9 | /** The sentence a serving environment carries while its tests still run; | |
| 10 | * `runtime.ts` sets it and the studio reads it to word its badge. */ | |
| 11 | export const VERIFYING_NOTE = "serving — still verifying its tests"; | |
| 8 | 12 | |
| 9 | 13 | /** The tones an environment's word wears; the web's `Tone` and the terminal's both hold them. */ |
| 10 | 14 | export type EnvPhraseTone = "default" | "success" | "danger" | "attn"; |
src/apps/runtime.ts +1 −4
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | import type { Database } from "bun:sqlite"; |
| 17 | import { VERIFYING_NOTE } from "./env_phrase.ts"; | |
| 17 | 18 | import { listEgressGrants } from "../core/egress_grants.ts"; |
| 18 | 19 | import { recordCarried } from "../core/carried.ts"; |
| 19 | 20 | import { releaseOfCommit } from "../core/release_subject.ts"; |
| @@ -166,10 +167,6 @@ const SLEEP_AFTER_MS = { production: 24 * 3_600_000, preview: 30 * 60_000 }; | ||
| 166 | 167 | const SWEEP_EVERY_MS = 60_000; |
| 167 | 168 | const BUSY_GRACE_MS = 45 * 60_000; |
| 168 | 169 | |
| 169 | /** The sentence a serving environment carries while its tests still run; | |
| 170 | * the studio reads the same constant to word its badge. */ | |
| 171 | export const VERIFYING_NOTE = "serving — still verifying its tests"; | |
| 172 | ||
| 173 | 170 | /** What a finished deploy's summary ends with: the studio words its green |
| 174 | 171 | * from these, so a pull says whether its tests ran (`words.ts`). */ |
| 175 | 172 | export const TESTS_PASSED_TAIL = " · its tests passed"; |
src/web/pages/board.ts +3 −48
| @@ -1014,51 +1014,6 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 1014 | 1014 | ); |
| 1015 | 1015 | } |
| 1016 | 1016 | |
| 1017 | // ---- style ---- | |
| 1018 | ||
| 1019 | /** | |
| 1020 | * The board (docs/DESIGN.md §5): the one layout no other component draws — | |
| 1021 | * four live regions that fill a second monitor, each scrolling on its own, | |
| 1022 | * one key to show one. Everything inside them is §5's: rows in a list card, | |
| 1023 | * badges, meters, kbd. Assembled into the served stylesheet by | |
| 1024 | * `web/style.ts`, last in the `components` layer. The spend tables | |
| 1025 | * (`spend.ts`) wear §5's table and stack, with no rules here. | |
| 1026 | */ | |
| 1027 | export const boardCss = /* css */ ` | |
| 1028 | @layer components { | |
| 1029 | .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; } | |
| 1030 | .board > header, .board > .stack { padding: 12px var(--pad) 0; } | |
| 1031 | .board > #notes { padding: 8px var(--pad) 0; } | |
| 1032 | #notes ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 30dvh; overflow: auto; } | |
| 1033 | #notes li > * { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 44px; overflow-wrap: anywhere; } | |
| 1034 | #notes li > a { display: block; padding-block: 12px; min-height: 0; } | |
| 1035 | .board #board-live { display: inline-flex; align-items: center; } | |
| 1036 | .board #board-clock { min-width: 5ch; text-align: right; } | |
| 1037 | .board [data-started] { min-width: 7ch; text-align: right; } | |
| 1038 | .board-grid { flex: 1; display: grid; gap: var(--pad); padding: 16px var(--pad) var(--pad); min-height: 0; } | |
| 1039 | .board-region { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; } | |
| 1040 | .board-region > h2 { display: flex; align-items: center; gap: 8px; margin: 0; } | |
| 1041 | .board-region[data-fresh] > h2 { color: var(--accent); } | |
| 1042 | .board-region .row-lead img { display: block; border: 1px solid var(--border); border-radius: 4px; object-fit: cover; object-position: top; } | |
| 1043 | .board-region .row-main > meter { margin-top: 4px; } | |
| 1044 | .board [data-window] > meter { width: 56px; } | |
| 1045 | ||
| 1046 | /* Wide: four regions fill the window, each list scrolling on its own. */ | |
| 1047 | @media (min-width: 1100px) { | |
| 1048 | .board { height: 100dvh; min-height: 0; } | |
| 1049 | .board-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); } | |
| 1050 | .board-region > .card { min-height: 0; overflow: auto; scrollbar-width: thin; } | |
| 1051 | .board[data-focus] .board-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); } | |
| 1052 | } | |
| 1053 | /* One region, full bleed. */ | |
| 1054 | .board[data-focus] .board-region { display: none; } | |
| 1055 | .board[data-focus="working"] #working, .board[data-focus="landed"] #landed, | |
| 1056 | .board[data-focus="decided"] #decided, .board[data-focus="today"] #today { display: flex; } | |
| 1057 | /* Read from across the room: one region, or the wall mode, at a larger | |
| 1058 | scale — the same rows, every size in proportion. Nobody is at the keys. */ | |
| 1059 | .board[data-focus] .board-region > * { zoom: 1.3; } | |
| 1060 | .board[data-wall] .board-region > *, .board[data-wall] > .stack { zoom: 1.4; } | |
| 1061 | .board[data-wall] { cursor: none; } | |
| 1062 | .board[data-wall] #notes :is(.input, .btn) { display: none; } | |
| 1063 | } | |
| 1064 | `; | |
| 1017 | // The board's stylesheet lives in `board_style.ts`, so `web/style.ts` can | |
| 1018 | // read it without loading this page's data graph. | |
| 1019 | export { boardCss } from "./board_style.ts"; |
src/web/pages/board_style.ts added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | /** | |
| 2 | * The board (docs/DESIGN.md §5): the one layout no other component draws — | |
| 3 | * four live regions that fill a second monitor, each scrolling on its own, | |
| 4 | * one key to show one. Everything inside them is §5's: rows in a list card, | |
| 5 | * badges, meters, kbd. Assembled into the served stylesheet by | |
| 6 | * `web/style.ts`, last in the `components` layer. The spend tables | |
| 7 | * (`spend.ts`) wear §5's table and stack, with no rules here. | |
| 8 | */ | |
| 9 | export const boardCss = /* css */ ` | |
| 10 | @layer components { | |
| 11 | .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; } | |
| 12 | .board > header, .board > .stack { padding: 12px var(--pad) 0; } | |
| 13 | .board > #notes { padding: 8px var(--pad) 0; } | |
| 14 | #notes ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 30dvh; overflow: auto; } | |
| 15 | #notes li > * { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 44px; overflow-wrap: anywhere; } | |
| 16 | #notes li > a { display: block; padding-block: 12px; min-height: 0; } | |
| 17 | .board #board-live { display: inline-flex; align-items: center; } | |
| 18 | .board #board-clock { min-width: 5ch; text-align: right; } | |
| 19 | .board [data-started] { min-width: 7ch; text-align: right; } | |
| 20 | .board-grid { flex: 1; display: grid; gap: var(--pad); padding: 16px var(--pad) var(--pad); min-height: 0; } | |
| 21 | .board-region { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; } | |
| 22 | .board-region > h2 { display: flex; align-items: center; gap: 8px; margin: 0; } | |
| 23 | .board-region[data-fresh] > h2 { color: var(--accent); } | |
| 24 | .board-region .row-lead img { display: block; border: 1px solid var(--border); border-radius: 4px; object-fit: cover; object-position: top; } | |
| 25 | .board-region .row-main > meter { margin-top: 4px; } | |
| 26 | .board [data-window] > meter { width: 56px; } | |
| 27 | ||
| 28 | /* Wide: four regions fill the window, each list scrolling on its own. */ | |
| 29 | @media (min-width: 1100px) { | |
| 30 | .board { height: 100dvh; min-height: 0; } | |
| 31 | .board-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); } | |
| 32 | .board-region > .card { min-height: 0; overflow: auto; scrollbar-width: thin; } | |
| 33 | .board[data-focus] .board-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); } | |
| 34 | } | |
| 35 | /* One region, full bleed. */ | |
| 36 | .board[data-focus] .board-region { display: none; } | |
| 37 | .board[data-focus="working"] #working, .board[data-focus="landed"] #landed, | |
| 38 | .board[data-focus="decided"] #decided, .board[data-focus="today"] #today { display: flex; } | |
| 39 | /* Read from across the room: one region, or the wall mode, at a larger | |
| 40 | scale — the same rows, every size in proportion. Nobody is at the keys. */ | |
| 41 | .board[data-focus] .board-region > * { zoom: 1.3; } | |
| 42 | .board[data-wall] .board-region > *, .board[data-wall] > .stack { zoom: 1.4; } | |
| 43 | .board[data-wall] { cursor: none; } | |
| 44 | .board[data-wall] #notes :is(.input, .btn) { display: none; } | |
| 45 | } | |
| 46 | `; |
src/web/style.ts +2 −2
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * base → constants, type scale, focus, motion, the page shell |
| 6 | 6 | * components → the component vocabulary (docs/DESIGN.md is its contract), |
| 7 | 7 | * plus two surfaces with a vocabulary of their own: the |
| 8 | * studio (`studio/style.ts`) and the board (`pages/board.ts`) | |
| 8 | * studio (`studio/style.ts`) and the board (`pages/board_style.ts`) | |
| 9 | 9 | * content → prose, code, diffs, and the furniture that frames them |
| 10 | 10 | * utilities → the few one-liners pages are allowed to reach for |
| 11 | 11 | * |
| @@ -23,7 +23,7 @@ | ||
| 23 | 23 | */ |
| 24 | 24 | |
| 25 | 25 | import { swatchCss, themeCss } from "../design/themes.ts"; |
| 26 | import { boardCss } from "./pages/board.ts"; | |
| 26 | import { boardCss } from "./pages/board_style.ts"; | |
| 27 | 27 | import { base, layers, reset, utilities } from "./style/base.ts"; |
| 28 | 28 | import { components } from "./style/components.ts"; |
| 29 | 29 | import { content } from "./style/content.ts"; |
src/web/ui.ts +1 −1
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | * reaches for an inline `style` attribute, because the CSP forbids it. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { type EnvState, type EnvStatus } from "../apps/runtime.ts"; | |
| 13 | import type { EnvState, EnvStatus } from "../apps/runtime.ts"; | |
| 14 | 14 | import type { PreviewData } from "../apps/data.ts"; |
| 15 | 15 | import type { Mark } from "../core/marks.ts"; |
| 16 | 16 | import type { RunKind, RunState } from "../core/runs.ts"; |
test/web-import-cost.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * The web's static modules are cheap to import (#8311). `style.ts`, | |
| 3 | * `client.ts` and `asset.ts` used to cost 300–450 ms each, not to transpile | |
| 4 | * their template literals but because each reached the whole app graph: the | |
| 5 | * stylesheet read the board's rules from the board page (core, actions, db), | |
| 6 | * and `ui.ts` reached `apps/runtime.ts` twice — once through an import that | |
| 7 | * named only types, which `verbatimModuleSyntax` keeps as a side-effect | |
| 8 | * import, and once for one string constant. Every test file that starts a | |
| 9 | * server paid that before its first test. | |
| 10 | * | |
| 11 | * Each module is imported in a fresh `bun` process and the modules it loaded | |
| 12 | * are read back: none of the three may load the app's data graph again. The | |
| 13 | * guard is the module list, not a clock — a wall-clock bound flakes under the | |
| 14 | * parallel check; a time budget per test belongs to #1242. | |
| 15 | */ | |
Remove BUDGET_MS and the |
||
| 16 | ||
| 17 | import { describe, expect, test } from "bun:test"; | |
| 18 | import { createHash } from "node:crypto"; | |
| 19 | import { join } from "node:path"; | |
| 20 | import { boardCss as boardPageCss } from "../src/web/pages/board.ts"; | |
| 21 | import { boardCss } from "../src/web/pages/board_style.ts"; | |
| 22 | import { assetResponse, shellAsset, stylesheetAsset, stylesheetResponse } from "../src/web/asset.ts"; | |
| 23 | import { css } from "../src/web/style.ts"; | |
| 24 | import { shellJs } from "../src/web/client.ts"; | |
| 25 | ||
| 26 | const ROOT = join(import.meta.dir, ".."); | |
| 27 | const MODULES = ["src/web/style.ts", "src/web/client.ts", "src/web/asset.ts"]; | |
| 28 | ||
| 29 | /** Modules the static web must never pull in: the data graph and the pages. | |
| 30 | * Of `src/apps/` only `env_phrase.ts` (plain words, type-only imports) is allowed, so | |
| 31 | * any path into the apps graph — `runtime.ts`, `data.ts`, … — fails. */ | |
| 32 | const HEAVY = [ | |
| 33 | /^src\/core\//, | |
| 34 | /^src\/db\//, | |
| 35 | /^src\/apps\/(?!env_phrase\.ts$)/, | |
| 36 | /^src\/web\/pages\/board\.ts$/, | |
| 37 | /^src\/web\/layout\.ts$/, | |
| 38 | ]; | |
| 39 | ||
| 40 | function loadedModules(module: string): string[] { | |
| 41 | const script = ` | |
| 42 | await import(${JSON.stringify(`./${module}`)}); | |
| 43 | const root = process.cwd() + "/"; | |
| 44 | const modules = Object.keys(require.cache).filter((k) => k.startsWith(root)).map((k) => k.slice(root.length)); | |
| 45 | console.log(JSON.stringify(modules)); | |
| 46 | `; | |
| 47 | const run = Bun.spawnSync([process.execPath, "-e", script], { cwd: ROOT, stdout: "pipe", stderr: "pipe" }); | |
| 48 | if (run.exitCode !== 0) throw new Error(`importing ${module} failed: ${run.stderr.toString()}`); | |
| 49 | return JSON.parse(run.stdout.toString().trim().split("\n").at(-1)!) as string[]; | |
| 50 | } | |
| 51 | ||
| 52 | describe("importing the web's static modules", () => { | |
| 53 | for (const module of MODULES) { | |
| 54 | test(`${module} imports without the app's data graph`, () => { | |
| 55 | const modules = loadedModules(module); | |
| 56 | expect(modules).toContain(module); | |
| 57 | expect(modules.filter((m) => HEAVY.some((re) => re.test(m)))).toEqual([]); | |
| 58 | }); | |
| 59 | } | |
| 60 | }); | |
| 61 | ||
| 62 | describe("the served bytes are the modules' own", () => { | |
| 63 | const sha256 = (body: string) => createHash("sha256").update(body).digest("hex"); | |
| 64 | ||
| 65 | test("the board page still exports the very rules the stylesheet serves", () => { | |
| 66 | expect(boardPageCss).toBe(boardCss); | |
| 67 | expect(css).toContain(boardCss); | |
| 68 | }); | |
| 69 | ||
| 70 | test("the stylesheet's address and body hash to the assembled sheet", async () => { | |
| 71 | const asset = stylesheetAsset(); | |
| 72 | expect(asset.etag).toBe(`"${sha256(css)}"`); | |
| 73 | const body = await stylesheetResponse(new Request(`http://forge.test${asset.path}`)).text(); | |
| 74 | expect(sha256(body)).toBe(sha256(css)); | |
| 75 | }); | |
| 76 | ||
| 77 | test("the shell script's address and body hash to the shell", async () => { | |
| 78 | const asset = shellAsset(); | |
| 79 | expect(asset.etag).toBe(`"${sha256(shellJs)}"`); | |
| 80 | const body = await assetResponse(new Request(`http://forge.test${asset.path}`), asset).text(); | |
| 81 | expect(sha256(body)).toBe(sha256(shellJs)); | |
| 82 | }); | |
| 83 | }); |
new above
offline
Two homes for one constant. Point the remaining importers at env_phrase.ts and drop this re-export, or document why it stays.