Public: anyone can read this; the crew can change it.
A pushed style change is on the page at once: the stylesheet is content-addressed #547
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/113-a-pushed-style-change-is-on-the-page-at → main 5 commits fork
6 files
changed +118
−33
src/http/server.ts +17 −19
| @@ -27,8 +27,13 @@ import { rmSync } from "node:fs"; | ||
| 27 | 27 | import { join } from "node:path"; |
| 28 | 28 | import type { App } from "../app.ts"; |
| 29 | 29 | import { sha256 } from "../kernel/hash.ts"; |
| 30 | import { css } from "../web/style.ts"; | |
| 31 | import { assetResponse, highlightAsset } from "../web/asset.ts"; | |
| 30 | import { | |
| 31 | assetResponse, | |
| 32 | highlightAsset, | |
| 33 | stylesheetAsset, | |
| 34 | stylesheetRedirect, | |
| 35 | stylesheetResponse, | |
| 36 | } from "../web/asset.ts"; | |
| 32 | 37 | import { isThemeName } from "../design/themes.ts"; |
| 33 | 38 | import { |
| 34 | 39 | home, |
| @@ -384,27 +389,20 @@ const gitChallenge = () => | ||
| 384 | 389 | |
| 385 | 390 | // ---- the public server ---- |
| 386 | 391 | |
| 387 | const CSS_ETAG = `"${sha256(css).slice(0, 16)}"`; | |
| 388 | ||
| 389 | function stylesheet(ctx: Ctx): Response { | |
| 390 | if (ctx.request.headers.get("if-none-match") === CSS_ETAG) { | |
| 391 | return new Response(null, { status: 304, headers: { etag: CSS_ETAG } }); | |
| 392 | } | |
| 393 | return new Response(css, { | |
| 394 | headers: { | |
| 395 | "content-type": "text/css; charset=utf-8", | |
| 396 | etag: CSS_ETAG, | |
| 397 | "cache-control": "public, max-age=300", | |
| 398 | }, | |
| 399 | }); | |
| 400 | } | |
| 401 | ||
| 402 | 392 | export function buildRouter(): Router<Handler> { |
| 403 | 393 | const router = new Router<Handler>(); |
| 404 | 394 | |
| 405 | 395 | // Literal-prefix routes come before parameterized repo routes; the router |
| 406 | // matches in insertion order. | |
| 407 | router.add("GET", "/assets/forge.css", stylesheet); | |
| 396 | // matches in insertion order. The stylesheet and the highlighter bundle are | |
| 397 | // both content-addressed: a stale hash is a 404 rather than a redirect, the | |
| 398 | // page that asked for it names the hash it was built with, so a miss means | |
| 399 | // an old tab, and an old tab should reload. The unhashed name stays only as | |
| 400 | // a short-lived redirect for pages rendered before the hash existed. | |
| 401 | router.add("GET", "/assets/:name.css", (ctx) => { | |
| 402 | if (ctx.params.name === "forge") return stylesheetRedirect(); | |
| 403 | if (ctx.params.name !== stylesheetAsset().name) return notFound(); | |
| 404 | return stylesheetResponse(); | |
| 405 | }); | |
| 408 | 406 | // What the open web expects of a site, from the forge's own facts. |
| 409 | 407 | router.add("GET", "/.well-known/security.txt", securityTxt); |
| 410 | 408 | router.add("GET", "/.well-known/change-password", changePassword); |
src/web/asset.ts +42
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | |
| 19 | 19 | import { sha256 } from "../kernel/hash.ts"; |
| 20 | 20 | import { createLog } from "../kernel/log.ts"; |
| 21 | import { css } from "./style.ts"; | |
| 21 | 22 | |
| 22 | 23 | const log = createLog("web"); |
| 23 | 24 | |
| @@ -69,3 +70,44 @@ export function assetResponse(asset: BuiltAsset): Response { | ||
| 69 | 70 | }, |
| 70 | 71 | }); |
| 71 | 72 | } |
| 73 | ||
| 74 | /** The stylesheet, content-addressed the same way. Hashed once per process. */ | |
| 75 | export interface CssAsset { | |
| 76 | /** The URL path it is served from, content-hashed. */ | |
| 77 | path: string; | |
| 78 | /** The `:name` the route matches, i.e. the path's basename without `.css`. */ | |
| 79 | name: string; | |
| 80 | } | |
| 81 | ||
| 82 | let cssAsset: CssAsset | null = null; | |
| 83 | ||
| 84 | export function stylesheetAsset(): CssAsset { | |
| 85 | if (cssAsset === null) { | |
| 86 | const name = `forge-${sha256(css).slice(0, 16)}`; | |
| 87 | cssAsset = { path: `/assets/${name}.css`, name }; | |
| 88 | } | |
| 89 | return cssAsset; | |
| 90 | } | |
| 91 | ||
| 92 | /** The response for a hit on the stylesheet: immutable, because it is. */ | |
| 93 | export function stylesheetResponse(): Response { | |
| 94 | return new Response(css, { | |
| 95 | headers: { | |
| 96 | "content-type": "text/css; charset=utf-8", | |
| 97 | "cache-control": "public, max-age=31536000, immutable", | |
| 98 | "x-content-type-options": "nosniff", | |
| 99 | }, | |
| 100 | }); | |
| 101 | } | |
| 102 | ||
| 103 | /** `/assets/forge.css` for pages rendered before the hash: send them to the current sheet, never cached long. */ | |
| 104 | export function stylesheetRedirect(): Response { | |
| 105 | return new Response(null, { | |
| 106 | status: 302, | |
| 107 | headers: { | |
| 108 | location: stylesheetAsset().path, | |
| 109 | "cache-control": "no-cache", | |
| 110 | "x-content-type-options": "nosniff", | |
| 111 | }, | |
| 112 | }); | |
| 113 | } |
src/web/layout.ts +5 −1
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | THEMES, |
| 22 | 22 | type ThemeName, |
| 23 | 23 | } from "../design/themes.ts"; |
| 24 | import { stylesheetAsset } from "./asset.ts"; | |
| 24 | 25 | import { shellJs } from "./client.ts"; |
| 25 | 26 | import { |
| 26 | 27 | clearFlashCookie, |
| @@ -596,7 +597,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 596 | 597 | rel="icon" |
| 597 | 598 | href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%232563eb'/%3E%3Cstop offset='1' stop-color='%237c3aed'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect x='3' y='3' width='26' height='26' rx='8' fill='url(%23g)'/%3E%3C/svg%3E" |
| 598 | 599 | /> |
| 599 | <link rel="stylesheet" href="/assets/forge.css" /> | |
| 600 | <link rel="stylesheet" href="${stylesheetAsset().path}" /> | |
| 600 | 601 | ${when( |
| 601 | 602 | options.scriptAsset, |
| 602 | 603 | (src) => html`<script type="module" src="${src}"></script>`, |
| @@ -619,6 +620,9 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 619 | 620 | "content-security-policy": `default-src 'none'; style-src 'self' 'nonce-${nonce}'; script-src 'nonce-${nonce}'${options.scriptAsset === undefined ? "" : " 'self'"}; connect-src 'self'; worker-src 'self'; manifest-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`, |
| 620 | 621 | "referrer-policy": "same-origin", |
| 621 | 622 | "x-content-type-options": "nosniff", |
| 623 | // Markup is revalidated every time; only the hashed assets it names are | |
| 624 | // cached, so no cache can pair new HTML with an old stylesheet. | |
| 625 | "cache-control": "no-cache", | |
| 622 | 626 | }); |
| 623 | 627 | // A flash is shown exactly once: rendering it is also what clears it. |
| 624 | 628 | if (flash) headers.append("set-cookie", clearFlashCookie(ctx.url)); |
test/board.test.ts +4 −1
| @@ -1,3 +1,4 @@ | ||
| 1 | import { stylesheetAsset } from "../src/web/asset.ts"; | |
| 1 | 2 | /** |
| 2 | 3 | * The board: a live wall of the crews at work. Signed in, four live |
| 3 | 4 | * regions; a run in flight and a merged pull on it; `?apps=` narrows it; |
| @@ -143,7 +144,9 @@ describe("the board", () => { | ||
| 143 | 144 | // A wall left open all day fetches its 13 KB of rules once, with the rest |
| 144 | 145 | // of the design, instead of carrying them inline on every reload. |
| 145 | 146 | expect(html).not.toContain("<style"); |
| 146 | expect(html).toContain('<link rel="stylesheet" href="/assets/forge.css"'); | |
| 147 | expect(html).toContain( | |
| 148 | `<link rel="stylesheet" href="${stylesheetAsset().path}"`, | |
| 149 | ); | |
| 147 | 150 | // A viewer who chose a theme keeps it. |
| 148 | 151 | const paper = await ( |
| 149 | 152 | await get("/board", { cookie: `${cookie}; forge_theme=paper` }) |
test/studio.test.ts +5 −2
| @@ -1,3 +1,4 @@ | ||
| 1 | import { stylesheetAsset } from "../src/web/asset.ts"; | |
| 1 | 2 | /** |
| 2 | 3 | * The studio: apps as the unit, three verbs, and a switch to dev. The same |
| 3 | 4 | * URLs answer in both modes; the cookie decides which face they show. |
| @@ -302,11 +303,13 @@ describe("the studio", () => { | ||
| 302 | 303 | // for this request (label colors) is allowed one. |
| 303 | 304 | for (const path of ["/", "/alice/notes", "/apps"]) { |
| 304 | 305 | const html = await (await get(path)).text(); |
| 305 | expect(html).toContain('<link rel="stylesheet" href="/assets/forge.css"'); | |
| 306 | expect(html).toContain( | |
| 307 | `<link rel="stylesheet" href="${stylesheetAsset().path}"`, | |
| 308 | ); | |
| 306 | 309 | expect(html).not.toContain("<style"); |
| 307 | 310 | } |
| 308 | 311 | |
| 309 | const sheet = await fetch(`${base}/assets/forge.css`); | |
| 312 | const sheet = await fetch(`${base}${stylesheetAsset().path}`); | |
| 310 | 313 | expect(sheet.headers.get("content-type")).toContain("text/css"); |
| 311 | 314 | const css = await sheet.text(); |
| 312 | 315 | expect(css).toContain(".studio-tell textarea"); |
test/web.test.ts +45 −10
| @@ -5,6 +5,7 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { health } from "../src/http/health.ts"; |
| 7 | 7 | import { serve, type Servers } from "../src/http/server.ts"; |
| 8 | import { stylesheetAsset, stylesheetRedirect } from "../src/web/asset.ts"; | |
| 8 | 9 | import { escapeHtml, html, raw, when } from "../src/web/html.ts"; |
| 9 | 10 | import { |
| 10 | 11 | icon, |
| @@ -16,6 +17,25 @@ import { renderMarkdown } from "../src/web/markdown.ts"; | ||
| 16 | 17 | import { highlightAsset } from "../src/web/asset.ts"; |
| 17 | 18 | import { securityFloor } from "../src/http/respond.ts"; |
| 18 | 19 | import { THEME_NAMES } from "../src/design/themes.ts"; |
| 20 | import { sha256 } from "../src/kernel/hash.ts"; | |
| 21 | import { css } from "../src/web/style.ts"; | |
| 22 | ||
| 23 | describe("stylesheet asset", () => { | |
| 24 | test("its path is a hash over the css, so changing the css moves it", () => { | |
| 25 | const expected = `/assets/forge-${sha256(css).slice(0, 16)}.css`; | |
| 26 | expect(stylesheetAsset().path).toBe(expected); | |
| 27 | expect(stylesheetAsset().path).not.toBe( | |
| 28 | `/assets/forge-${sha256(css + " ").slice(0, 16)}.css`, | |
| 29 | ); | |
| 30 | }); | |
| 31 | ||
| 32 | test("the old unhashed path redirects to the current sheet", async () => { | |
| 33 | const res = stylesheetRedirect(); | |
| 34 | expect(res.status).toBe(302); | |
| 35 | expect(res.headers.get("location")).toBe(stylesheetAsset().path); | |
| 36 | expect(res.headers.get("cache-control")).toBe("no-cache"); | |
| 37 | }); | |
| 38 | }); | |
| 19 | 39 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 20 | 40 | |
| 21 | 41 | describe("html template", () => { |
| @@ -362,14 +382,26 @@ describe("web pages end to end", () => { | ||
| 362 | 382 | expect(text).toContain("function openModal("); |
| 363 | 383 | }); |
| 364 | 384 | |
| 365 | test("stylesheet is served with an etag", async () => { | |
| 366 | const first = await fetch(`${base}/assets/forge.css`); | |
| 367 | expect(first.headers.get("content-type")).toContain("text/css"); | |
| 368 | const etag = first.headers.get("etag")!; | |
| 369 | const second = await fetch(`${base}/assets/forge.css`, { | |
| 370 | headers: { "if-none-match": etag }, | |
| 371 | }); | |
| 372 | expect(second.status).toBe(304); | |
| 385 | test("stylesheet is content-addressed and immutable, at the path the page names", async () => { | |
| 386 | const homeText = await (await fetch(base, { headers: { cookie } })).text(); | |
| 387 | const match = homeText.match(/href="(\/assets\/forge-[0-9a-f]+\.css)"/); | |
| 388 | expect(match).not.toBeNull(); | |
| 389 | const path = match![1]!; | |
| 390 | expect( | |
| 391 | (await fetch(base, { headers: { cookie } })).headers.get("cache-control"), | |
| 392 | ).toBe("no-cache"); | |
| 393 | ||
| 394 | const response = await fetch(`${base}${path}`); | |
| 395 | expect(response.status).toBe(200); | |
| 396 | expect(response.headers.get("content-type")).toContain("text/css"); | |
| 397 | expect(response.headers.get("cache-control")).toBe( | |
| 398 | "public, max-age=31536000, immutable", | |
| 399 | ); | |
| 400 | ||
| 401 | // A stale hash — an old tab's cached URL after a deploy — is a 404, not | |
| 402 | // a redirect, the same rule the highlighter bundle already follows. | |
| 403 | const stale = await fetch(`${base}/assets/forge-0000000000000000.css`); | |
| 404 | expect(stale.status).toBe(404); | |
| 373 | 405 | }); |
| 374 | 406 | |
| 375 | 407 | test("new repo via the form, empty state renders", async () => { |
| @@ -532,7 +564,9 @@ describe("themes", () => { | ||
| 532 | 564 | }); |
| 533 | 565 | |
| 534 | 566 | test("stylesheet carries every premade theme", async () => { |
| 535 | const css = await (await fetch(`${base}/assets/forge.css`)).text(); | |
| 567 | const css = await ( | |
| 568 | await fetch(`${base}${stylesheetAsset().path}`) | |
| 569 | ).text(); | |
| 536 | 570 | for (const name of THEME_NAMES) { |
| 537 | 571 | expect(css).toContain(`:root[data-theme="${name}"]`); |
| 538 | 572 | expect(css).toContain(`.swatch[data-t="${name}"]`); |
| @@ -766,8 +800,9 @@ describe("the security floor is on every response", () => { | ||
| 766 | 800 | expect(((await typo.json()) as { error: string }).error).toBe("no_route"); |
| 767 | 801 | expectFloor("no_route json", typo); |
| 768 | 802 | |
| 769 | const stylesheet = await fetch(`${base}/assets/forge.css`); | |
| 803 | const stylesheet = await fetch(`${base}${stylesheetAsset().path}`); | |
| 770 | 804 | expect(stylesheet.status).toBe(200); |
| 805 | expect(stylesheet.headers.get("cache-control")).toContain("immutable"); | |
| 771 | 806 | expectFloor("stylesheet", stylesheet); |
| 772 | 807 | |
| 773 | 808 | const built = await fetch(`${base}${asset!.path}`); |
new above
offline