Public: anyone can read this; the crew can change it.
Static assets answer 304 to a matching ETag and immutable ones are cached for a year #2727
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2718-static-assets-answer-304-to-a-matching-e → main 2 commits fork 3 threads · 3 unresolved
4 files
changed +84
−36
src/apps/toolbar.ts +5 −9
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * `style-src` does not block. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { staticResponse } from "../web/asset.ts"; | |
| 13 | 14 | import { sha256 } from "../kernel/hash.ts"; |
| 14 | 15 | |
| 15 | 16 | /** The client, as plain source: no `eval`, no inline handlers. */ |
| @@ -143,21 +144,16 @@ export const toolbarJs = `(function () { | ||
| 143 | 144 | })(); |
| 144 | 145 | `; |
| 145 | 146 | |
| 146 | const name = `toolbar-${sha256(toolbarJs).slice(0, 16)}`; | |
| 147 | const hash = sha256(toolbarJs); | |
| 148 | const name = `toolbar-${hash.slice(0, 16)}`; | |
| 147 | 149 | |
| 148 | 150 | /** The path the script is served from on the forge, content-addressed. */ |
| 149 | 151 | export const TOOLBAR_PATH = `/assets/${name}.js`; |
| 150 | 152 | export const TOOLBAR_NAME = name; |
| 151 | 153 | |
| 152 | 154 | /** The forge's answer for the script: immutable, because its name is its hash. */ |
| 153 | export function toolbarResponse(): Response { | |
| 154 | return new Response(toolbarJs, { | |
| 155 | headers: { | |
| 156 | "content-type": "text/javascript; charset=utf-8", | |
| 157 | "cache-control": "public, max-age=31536000, immutable", | |
| 158 | "x-content-type-options": "nosniff", | |
| 159 | }, | |
| 160 | }); | |
| 155 | export function toolbarResponse(request: Request): Response { | |
| 156 | return staticResponse(request, toolbarJs, "text/javascript; charset=utf-8", `"${hash}"`); | |
| 161 | 157 | } |
| 162 | 158 | |
| 163 | 159 | /** The largest page the proxy will hold in memory to add the tag to. */ |
src/http/server.ts +4 −4
| @@ -616,7 +616,7 @@ export function buildRouter(): Router<Handler> { | ||
| 616 | 616 | router.add("GET", "/assets/:name.css", (ctx) => { |
| 617 | 617 | if (ctx.params.name === "forge") return stylesheetRedirect(); |
| 618 | 618 | if (ctx.params.name !== stylesheetAsset().name) return notFound(); |
| 619 | return stylesheetResponse(); | |
| 619 | return stylesheetResponse(ctx.request); | |
| 620 | 620 | }); |
| 621 | 621 | // What the open web expects of a site, from the forge's own facts. |
| 622 | 622 | router.add("GET", "/.well-known/security.txt", securityTxt); |
| @@ -632,11 +632,11 @@ export function buildRouter(): Router<Handler> { | ||
| 632 | 632 | // with, so a miss means an old tab, and an old tab should reload. |
| 633 | 633 | router.add("GET", "/assets/:name.js", async (ctx) => { |
| 634 | 634 | const shell = shellAsset(); |
| 635 | if (ctx.params.name === shell.name) return assetResponse(shell); | |
| 636 | if (ctx.params.name === TOOLBAR_NAME) return toolbarResponse(); | |
| 635 | if (ctx.params.name === shell.name) return assetResponse(ctx.request, shell); | |
| 636 | if (ctx.params.name === TOOLBAR_NAME) return toolbarResponse(ctx.request); | |
| 637 | 637 | const asset = await highlightAsset(); |
| 638 | 638 | if (asset === null || ctx.params.name !== asset.name) return notFound(); |
| 639 | return assetResponse(asset); | |
| 639 | return assetResponse(ctx.request, asset); | |
| 640 | 640 | }); |
| 641 | 641 | // The service worker: root scope needs a root path, and it must never be |
| 642 | 642 | // cached by the browser's HTTP cache or an update would wait a day. |
src/web/asset.ts +42 −22
| @@ -29,6 +29,8 @@ export interface BuiltAsset { | ||
| 29 | 29 | /** The `:name` the route matches, i.e. the path's basename without `.js`. */ |
| 30 | 30 | name: string; |
| 31 | 31 | code: string; |
| 32 | /** The strong ETag, hashed once with the name. */ | |
| 33 | etag: string; | |
| 32 | 34 | } |
| 33 | 35 | |
| 34 | 36 | let pending: Promise<BuiltAsset | null> | null = null; |
| @@ -53,23 +55,43 @@ async function build(): Promise<BuiltAsset | null> { | ||
| 53 | 55 | return null; |
| 54 | 56 | } |
| 55 | 57 | const code = await first.text(); |
| 56 | const name = `highlight-${sha256(code).slice(0, 16)}`; | |
| 57 | return { path: `/assets/${name}.js`, name, code }; | |
| 58 | const hash = sha256(code); | |
| 59 | const name = `highlight-${hash.slice(0, 16)}`; | |
| 60 | return { path: `/assets/${name}.js`, name, code, etag: `"${hash}"` }; | |
| 58 | 61 | } catch (error) { |
| 59 | 62 | log.warn("web.highlight_asset_failed", { error: String(error) }); |
| 60 | 63 | return null; |
| 61 | 64 | } |
| 62 | 65 | } |
| 63 | 66 | |
| 67 | /** | |
| 68 | * A content-hashed body, answered conditionally. The ETag is the body's own | |
| 69 | * hash, strong, computed once by the caller; a matching `If-None-Match` (or `*`) gets a 304 with no body | |
| 70 | * and the same validators, and the security floor is added to it like any | |
| 71 | * other response. Only hashed assets come through here, so only they are | |
| 72 | * immutable. | |
| 73 | */ | |
| 74 | export function staticResponse(request: Request, body: string, type: string, etag: string): Response { | |
| 75 | const headers = { | |
| 76 | "content-type": type, | |
| 77 | "cache-control": "public, max-age=31536000, immutable", | |
| 78 | "x-content-type-options": "nosniff", | |
| 79 | etag, | |
| 80 | }; | |
| 81 | if (matches(request.headers.get("if-none-match"), etag)) | |
| 82 | return new Response(null, { status: 304, headers }); | |
| 83 | return new Response(body, { headers }); | |
| 84 | } | |
| 85 | ||
| 86 | function matches(header: string | null, etag: string): boolean { | |
| 87 | if (header === null) return false; | |
| 88 | if (header.trim() === "*") return true; | |
| 89 | return header.split(",").some((tag) => tag.trim() === etag); | |
Nit: RFC 9110 uses weak comparison for If-None-Match, so stripping a leading |
||
| 90 | } | |
| 91 | ||
| 64 | 92 | /** The response for a hit on the built asset: immutable, because it is. */ |
| 65 | export function assetResponse(asset: BuiltAsset): Response { | |
| 66 | return new Response(asset.code, { | |
| 67 | headers: { | |
| 68 | "content-type": "text/javascript; charset=utf-8", | |
| 69 | "cache-control": "public, max-age=31536000, immutable", | |
| 70 | "x-content-type-options": "nosniff", | |
| 71 | }, | |
| 72 | }); | |
| 93 | export function assetResponse(request: Request, asset: BuiltAsset): Response { | |
| 94 | return staticResponse(request, asset.code, "text/javascript; charset=utf-8", asset.etag); | |
| 73 | 95 | } |
| 74 | 96 | |
| 75 | 97 | /** The stylesheet, content-addressed the same way. Hashed once per process. */ |
| @@ -78,27 +100,24 @@ export interface CssAsset { | ||
| 78 | 100 | path: string; |
| 79 | 101 | /** The `:name` the route matches, i.e. the path's basename without `.css`. */ |
| 80 | 102 | name: string; |
| 103 | /** The strong ETag, hashed once with the name. */ | |
| 104 | etag: string; | |
| 81 | 105 | } |
| 82 | 106 | |
| 83 | 107 | let cssAsset: CssAsset | null = null; |
| 84 | 108 | |
| 85 | 109 | export function stylesheetAsset(): CssAsset { |
| 86 | 110 | if (cssAsset === null) { |
| 87 | const name = `forge-${sha256(css).slice(0, 16)}`; | |
| 88 | cssAsset = { path: `/assets/${name}.css`, name }; | |
| 111 | const hash = sha256(css); | |
| 112 | const name = `forge-${hash.slice(0, 16)}`; | |
| 113 | cssAsset = { path: `/assets/${name}.css`, name, etag: `"${hash}"` }; | |
| 89 | 114 | } |
| 90 | 115 | return cssAsset; |
| 91 | 116 | } |
| 92 | 117 | |
| 93 | 118 | /** The response for a hit on the stylesheet: immutable, because it is. */ |
| 94 | export function stylesheetResponse(): Response { | |
| 95 | return new Response(css, { | |
| 96 | headers: { | |
| 97 | "content-type": "text/css; charset=utf-8", | |
| 98 | "cache-control": "public, max-age=31536000, immutable", | |
| 99 | "x-content-type-options": "nosniff", | |
| 100 | }, | |
| 101 | }); | |
| 119 | export function stylesheetResponse(request: Request): Response { | |
| 120 | return staticResponse(request, css, "text/css; charset=utf-8", stylesheetAsset().etag); | |
| 102 | 121 | } |
| 103 | 122 | |
| 104 | 123 | /** `/assets/forge.css` for pages rendered before the hash: send them to the current sheet, never cached long. */ |
| @@ -123,8 +142,9 @@ let shell: BuiltAsset | null = null; | ||
| 123 | 142 | */ |
| 124 | 143 | export function shellAsset(): BuiltAsset { |
| 125 | 144 | if (shell === null) { |
| 126 | const name = `shell-${sha256(shellJs).slice(0, 16)}`; | |
| 127 | shell = { path: `/assets/${name}.js`, name, code: shellJs }; | |
| 145 | const hash = sha256(shellJs); | |
| 146 | const name = `shell-${hash.slice(0, 16)}`; | |
| 147 | shell = { path: `/assets/${name}.js`, name, code: shellJs, etag: `"${hash}"` }; | |
| 128 | 148 | } |
| 129 | 149 | return shell; |
| 130 | 150 | } |
test/web-editor.test.ts +33 −1
| @@ -38,7 +38,8 @@ import { | ||
| 38 | 38 | mirrorFor, |
| 39 | 39 | suggestBranch, |
| 40 | 40 | } from "../src/web/pages/edit.ts"; |
| 41 | import { highlightAsset } from "../src/web/asset.ts"; | |
| 41 | import { TOOLBAR_PATH } from "../src/apps/toolbar.ts"; | |
| 42 | import { highlightAsset, stylesheetAsset, shellAsset } from "../src/web/asset.ts"; | |
| 42 | 43 | import { highlightToHtml } from "../src/web/highlight-html.ts"; |
| 43 | 44 | import { fixtureSlot } from "./helpers/slot.ts"; |
| 44 | 45 | import { seedAdmin } from "./helpers/admin.ts"; |
| @@ -668,6 +669,37 @@ describe("editing files on the web", () => { | ||
| 668 | 669 | expect((await get("/assets/../src/main.js")).status).toBe(404); |
| 669 | 670 | }); |
| 670 | 671 | |
| 672 | test("hashed assets carry a strong ETag and answer 304 to a match, floor intact", async () => { | |
| 673 | const asset = await highlightAsset(); | |
| 674 | if (asset === null) throw new Error("the bundle did not build"); | |
outdated
High (missing test): the toolbar script was moved onto |
||
| 675 | for (const path of [asset.path, stylesheetAsset().path, shellAsset().path, TOOLBAR_PATH]) { | |
| 676 | const first = await get(path); | |
| 677 | const etag = first.headers.get("etag") ?? ""; | |
| 678 | expect(etag).toMatch(/^"[0-9a-f]{64}"$/); | |
| 679 | await first.text(); | |
| 680 | for (const match of [etag, `"nope", ${etag}`, "*"]) { | |
| 681 | const res = await fetch(`${base}${path}`, { headers: { "if-none-match": match } }); | |
| 682 | expect(res.status).toBe(304); | |
| 683 | expect(await res.text()).toBe(""); | |
| 684 | expect(res.headers.get("etag")).toBe(etag); | |
| 685 | expect(res.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); | |
| 686 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 687 | expect(res.headers.get("referrer-policy")).not.toBeNull(); | |
| 688 | expect(res.headers.get("content-security-policy")).not.toBeNull(); | |
| 689 | } | |
| 690 | const miss = await fetch(`${base}${path}`, { headers: { "if-none-match": '"other"' } }); | |
| 691 | expect(miss.status).toBe(200); | |
| 692 | } | |
| 693 | }); | |
| 694 | ||
| 695 | test("an HTML page is never immutable and has no ETag from the asset path", async () => { | |
| 696 | const res = await get("/"); | |
| 697 | expect(res.headers.get("cache-control") ?? "").not.toContain("immutable"); | |
| 698 | await res.text(); | |
| 699 | const redirect = await get("/assets/forge.css"); | |
| 700 | expect(redirect.headers.get("cache-control")).toBe("no-cache"); | |
| 701 | }); | |
| 702 | ||
| 671 | 703 | test("the served bundle renders exactly what the server renders", async () => { |
| 672 | 704 | const res = await get((await highlightAsset())!.path); |
| 673 | 705 | const dir = await mkdtemp(join(tmpdir(), "forge-hl-bundle-")); |
new above
offline
Low (performance): this hashes the full body on every request, including 304s and the bundle. The assets already hash once per process to get their names, so keep the ETag there and pass it in instead of recomputing it here.