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

Merged opened this

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

Merged

scout/forge:scout/2718-static-assets-answer-304-to-a-matching-e → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/2718-static-assets-answer-304-to-a-matching-e was merged into main as e192afd.

Review

Sign in to review this change.
4 files changed +84 −36
src/apps/toolbar.ts +5 −9
@@ -10,6 +10,7 @@
1010 * `style-src` does not block.
1111 */
1212
13 import { staticResponse } from "../web/asset.ts";
1314 import { sha256 } from "../kernel/hash.ts";
1415
1516 /** The client, as plain source: no `eval`, no inline handlers. */
@@ -143,21 +144,16 @@ export const toolbarJs = `(function () {
143144 })();
144145 `;
145146
146 const name = `toolbar-${sha256(toolbarJs).slice(0, 16)}`;
147 const hash = sha256(toolbarJs);
148 const name = `toolbar-${hash.slice(0, 16)}`;
147149
148150 /** The path the script is served from on the forge, content-addressed. */
149151 export const TOOLBAR_PATH = `/assets/${name}.js`;
150152 export const TOOLBAR_NAME = name;
151153
152154 /** 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}"`);
161157 }
162158
163159 /** 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> {
616616 router.add("GET", "/assets/:name.css", (ctx) => {
617617 if (ctx.params.name === "forge") return stylesheetRedirect();
618618 if (ctx.params.name !== stylesheetAsset().name) return notFound();
619 return stylesheetResponse();
619 return stylesheetResponse(ctx.request);
620620 });
621621 // What the open web expects of a site, from the forge's own facts.
622622 router.add("GET", "/.well-known/security.txt", securityTxt);
@@ -632,11 +632,11 @@ export function buildRouter(): Router<Handler> {
632632 // with, so a miss means an old tab, and an old tab should reload.
633633 router.add("GET", "/assets/:name.js", async (ctx) => {
634634 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);
637637 const asset = await highlightAsset();
638638 if (asset === null || ctx.params.name !== asset.name) return notFound();
639 return assetResponse(asset);
639 return assetResponse(ctx.request, asset);
640640 });
641641 // The service worker: root scope needs a root path, and it must never be
642642 // 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 {
2929 /** The `:name` the route matches, i.e. the path's basename without `.js`. */
3030 name: string;
3131 code: string;
32 /** The strong ETag, hashed once with the name. */
33 etag: string;
3234 }
3335
3436 let pending: Promise<BuiltAsset | null> | null = null;
@@ -53,23 +55,43 @@ async function build(): Promise<BuiltAsset | null> {
5355 return null;
5456 }
5557 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}"` };
5861 } catch (error) {
5962 log.warn("web.highlight_asset_failed", { error: String(error) });
6063 return null;
6164 }
6265 }
6366
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.
outdated

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.

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 W/ before comparing would also match a tag an intermediary weakened. It doesn't matter for browsers today and isn't blocking.

90 }
91
6492 /** 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);
7395 }
7496
7597 /** The stylesheet, content-addressed the same way. Hashed once per process. */
@@ -78,27 +100,24 @@ export interface CssAsset {
78100 path: string;
79101 /** The `:name` the route matches, i.e. the path's basename without `.css`. */
80102 name: string;
103 /** The strong ETag, hashed once with the name. */
104 etag: string;
81105 }
82106
83107 let cssAsset: CssAsset | null = null;
84108
85109 export function stylesheetAsset(): CssAsset {
86110 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}"` };
89114 }
90115 return cssAsset;
91116 }
92117
93118 /** 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);
102121 }
103122
104123 /** `/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;
123142 */
124143 export function shellAsset(): BuiltAsset {
125144 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}"` };
128148 }
129149 return shell;
130150 }
test/web-editor.test.ts +33 −1
@@ -38,7 +38,8 @@ import {
3838 mirrorFor,
3939 suggestBranch,
4040 } 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";
4243 import { highlightToHtml } from "../src/web/highlight-html.ts";
4344 import { fixtureSlot } from "./helpers/slot.ts";
4445 import { seedAdmin } from "./helpers/admin.ts";
@@ -668,6 +669,37 @@ describe("editing files on the web", () => {
668669 expect((await get("/assets/../src/main.js")).status).toBe(404);
669670 });
670671
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 staticResponse too, but it is not in this list, so its new ETag and 304 behaviour has no test that would fail without it. Add TOOLBAR_PATH from src/apps/toolbar.ts here.

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
671703 test("the served bundle renders exactly what the server renders", async () => {
672704 const res = await get((await highlightAsset())!.path);
673705 const dir = await mkdtemp(join(tmpdir(), "forge-hl-bundle-"));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs