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

Merged opened this

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

Merged

scout/forge:scout/113-a-pushed-style-change-is-on-the-page-at → main 5 commits fork

  • Merged. scout/113-a-pushed-style-change-is-on-the-page-at was merged into main as b48d9e5.

Review

Sign in to review this change.
6 files changed +118 −33
src/http/server.ts +17 −19
@@ -27,8 +27,13 @@ import { rmSync } from "node:fs";
2727 import { join } from "node:path";
2828 import type { App } from "../app.ts";
2929 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";
3237 import { isThemeName } from "../design/themes.ts";
3338 import {
3439 home,
@@ -384,27 +389,20 @@ const gitChallenge = () =>
384389
385390 // ---- the public server ----
386391
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
402392 export function buildRouter(): Router<Handler> {
403393 const router = new Router<Handler>();
404394
405395 // 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 });
408406 // What the open web expects of a site, from the forge's own facts.
409407 router.add("GET", "/.well-known/security.txt", securityTxt);
410408 router.add("GET", "/.well-known/change-password", changePassword);
src/web/asset.ts +42
@@ -18,6 +18,7 @@
1818
1919 import { sha256 } from "../kernel/hash.ts";
2020 import { createLog } from "../kernel/log.ts";
21 import { css } from "./style.ts";
2122
2223 const log = createLog("web");
2324
@@ -69,3 +70,44 @@ export function assetResponse(asset: BuiltAsset): Response {
6970 },
7071 });
7172 }
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 {
2121 THEMES,
2222 type ThemeName,
2323 } from "../design/themes.ts";
24 import { stylesheetAsset } from "./asset.ts";
2425 import { shellJs } from "./client.ts";
2526 import {
2627 clearFlashCookie,
@@ -596,7 +597,7 @@ export function page(options: PageOptions, status = 200): Response {
596597 rel="icon"
597598 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"
598599 />
599 <link rel="stylesheet" href="/assets/forge.css" />
600 <link rel="stylesheet" href="${stylesheetAsset().path}" />
600601 ${when(
601602 options.scriptAsset,
602603 (src) => html`<script type="module" src="${src}"></script>`,
@@ -619,6 +620,9 @@ export function page(options: PageOptions, status = 200): Response {
619620 "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'`,
620621 "referrer-policy": "same-origin",
621622 "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",
622626 });
623627 // A flash is shown exactly once: rendering it is also what clears it.
624628 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";
12 /**
23 * The board: a live wall of the crews at work. Signed in, four live
34 * regions; a run in flight and a merged pull on it; `?apps=` narrows it;
@@ -143,7 +144,9 @@ describe("the board", () => {
143144 // A wall left open all day fetches its 13 KB of rules once, with the rest
144145 // of the design, instead of carrying them inline on every reload.
145146 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 );
147150 // A viewer who chose a theme keeps it.
148151 const paper = await (
149152 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";
12 /**
23 * The studio: apps as the unit, three verbs, and a switch to dev. The same
34 * URLs answer in both modes; the cookie decides which face they show.
@@ -302,11 +303,13 @@ describe("the studio", () => {
302303 // for this request (label colors) is allowed one.
303304 for (const path of ["/", "/alice/notes", "/apps"]) {
304305 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 );
306309 expect(html).not.toContain("<style");
307310 }
308311
309 const sheet = await fetch(`${base}/assets/forge.css`);
312 const sheet = await fetch(`${base}${stylesheetAsset().path}`);
310313 expect(sheet.headers.get("content-type")).toContain("text/css");
311314 const css = await sheet.text();
312315 expect(css).toContain(".studio-tell textarea");
test/web.test.ts +45 −10
@@ -5,6 +5,7 @@ import { join } from "node:path";
55 import { createApp, type App } from "../src/app.ts";
66 import { health } from "../src/http/health.ts";
77 import { serve, type Servers } from "../src/http/server.ts";
8 import { stylesheetAsset, stylesheetRedirect } from "../src/web/asset.ts";
89 import { escapeHtml, html, raw, when } from "../src/web/html.ts";
910 import {
1011 icon,
@@ -16,6 +17,25 @@ import { renderMarkdown } from "../src/web/markdown.ts";
1617 import { highlightAsset } from "../src/web/asset.ts";
1718 import { securityFloor } from "../src/http/respond.ts";
1819 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 });
1939 import { gitClientEnv } from "./helpers/git-client.ts";
2040
2141 describe("html template", () => {
@@ -362,14 +382,26 @@ describe("web pages end to end", () => {
362382 expect(text).toContain("function openModal(");
363383 });
364384
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);
373405 });
374406
375407 test("new repo via the form, empty state renders", async () => {
@@ -532,7 +564,9 @@ describe("themes", () => {
532564 });
533565
534566 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();
536570 for (const name of THEME_NAMES) {
537571 expect(css).toContain(`:root[data-theme="${name}"]`);
538572 expect(css).toContain(`.swatch[data-t="${name}"]`);
@@ -766,8 +800,9 @@ describe("the security floor is on every response", () => {
766800 expect(((await typo.json()) as { error: string }).error).toBe("no_route");
767801 expectFloor("no_route json", typo);
768802
769 const stylesheet = await fetch(`${base}/assets/forge.css`);
803 const stylesheet = await fetch(`${base}${stylesheetAsset().path}`);
770804 expect(stylesheet.status).toBe(200);
805 expect(stylesheet.headers.get("cache-control")).toContain("immutable");
771806 expectFloor("stylesheet", stylesheet);
772807
773808 const built = await fetch(`${base}${asset!.path}`);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs