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

Static assets now send a strong ETag and answer a matching `If-None-Match` with a 304 and no body. The tests I ran pass. The type check did not run: `bun x tsc` failed with "unable to write files to tempdir: ReadOnlyFileSystem", so none of this has been type-checked.

shipped production is serving it deploy eb43263

opened this

Static assets now send a strong ETag and answer a matching If-None-Match with a 304 and no body. The tests I ran pass. The type check did not run: bun x tsc failed with "unable to write files to tempdir: ReadOnlyFileSystem", so none of this has been type-checked.

  • ETag and 304: staticResponse() in src/web/asset.ts sets ETag to the quoted sha256 of the body. A matching If-None-Match gets a 304 with no body and the same ETag and cache headers. The match covers a single tag, a comma list, and *. A non-matching tag gets a normal 200.
  • Which assets: this covers the stylesheet, the shell script, the highlighter bundle, and the app toolbar script in src/apps/toolbar.ts. The toolbar script is also content-hashed, so I moved it onto the same helper. Their response functions now take the request, and the routes in src/http/server.ts pass it in.
  • Cache-Control: public, max-age=31536000, immutable is sent only by the content-hashed assets, through that one helper. The old unhashed /assets/forge.css redirect stays no-cache, and HTML pages never get the immutable header.
  • Security headers: the router applies securityFloor to every response, so 304s carry X-Content-Type-Options, Referrer-Policy and the Content-Security-Policy.
  • Tests: two new tests in test/web-editor.test.ts. One checks the ETag format, the 304s (exact tag, list, *), the headers on a 304, and a 200 on a non-matching tag. The other checks that / is never immutable and the old redirect is no-cache. I ran bun test on test/web-editor.test.ts, test/web.test.ts and test/apps-toolbar.test.ts: 83 pass. I did not run the whole suite.

I left out weak-ETag (W/) comparison, since the assets use strong tags. I also left out If-Modified-Since and compression.

To try it, fetch any /assets/.js or /assets/.css path, then repeat the request with -H 'if-none-match: <etag>' and expect 304.

🤖 Generated with Claude Code

Closes #2718

commented

Static assets now send a strong ETag and answer a matching If-None-Match with a 304 and no body. The tests I ran pass. The type check did not run: bun x tsc failed with "unable to write files to tempdir: ReadOnlyFileSystem", so none of this has been type-checked.

  • ETag and 304: staticResponse() in src/web/asset.ts sets ETag to the quoted sha256 of the body. A matching If-None-Match gets a 304 with no body and the same ETag and cache headers. The match covers a single tag, a comma list, and *. A non-matching tag gets a normal 200.
  • Which assets: this covers the stylesheet, the shell script, the highlighter bundle, and the app toolbar script in src/apps/toolbar.ts. The toolbar script is also content-hashed, so I moved it onto the same helper. Their response functions now take the request, and the routes in src/http/server.ts pass it in.
  • Cache-Control: public, max-age=31536000, immutable is sent only by the content-hashed assets, through that one helper. The old unhashed /assets/forge.css redirect stays no-cache, and HTML pages never get the immutable header.
  • Security headers: the router applies securityFloor to every response, so 304s carry X-Content-Type-Options, Referrer-Policy and the Content-Security-Policy.
  • Tests: two new tests in test/web-editor.test.ts. One checks the ETag format, the 304s (exact tag, list, *), the headers on a 304, and a 200 on a non-matching tag. The other checks that / is never immutable and the old redirect is no-cache. I ran bun test on test/web-editor.test.ts, test/web.test.ts and test/apps-toolbar.test.ts: 83 pass. I did not run the whole suite.

I left out weak-ETag (W/) comparison, since the assets use strong tags. I also left out If-Modified-Since and compression.

To try it, fetch any /assets/.js or /assets/.css path, then repeat the request with -H 'if-none-match: <etag>' and expect 304.

🤖 Generated with Claude Code

Changes requested — before the latest push

The ETag and 304 logic is correct: a strong ETag, comma lists and * all match, the 304s carry the security floor because the router applies securityFloor to them, and immutable stays on the hashed assets only. At e14388b8, bun test test/web-editor.test.ts test/apps-toolbar.test.ts test/web.test.ts passes 83 of 83. I found no security problem.

I could not reach the preview or open its screenshots from my sandbox, so this review is based on the code and the tests only.

One thing blocks it, and it is the fix to make first: the toolbar script (src/apps/toolbar.ts) now sends an ETag and answers 304, but no test covers that. You can revert toolbarResponse to the old body and every test still passes. Add TOOLBAR_PATH to the path list in the new test in test/web-editor.test.ts (it is exported from src/apps/toolbar.ts), or add the same checks to test/apps-toolbar.test.ts.

Second, smaller: staticResponse runs sha256 over the whole body on every request, the highlighter bundle included. That undoes the "hashed once per process" property the module promises. Compute the ETag once, next to each asset's name, for example an etag field on BuiltAsset and CssAsset and a memoized one for the toolbar, and pass it in, so a 304 costs no hashing.

commented

Both review points are fixed and pushed as dc5a9210. The three test files pass (83 of 83). I couldn't run the type check: bun x tsc fails in this sandbox with "unable to write files to tempdir: ReadOnlyFileSystem".

  • Toolbar test: TOOLBAR_PATH is now in the path list of the ETag/304 test in test/web-editor.test.ts. Reverting toolbarResponse to the old body would now fail that test.
  • Hash once: each asset now computes its full sha256 once, next to its name, and the ETag is built from it.

- BuiltAsset and CssAsset have a new etag field. - The toolbar script hashes at module load. - staticResponse takes the ETag as an argument and no longer hashes anything, so a 304 costs no hashing.

I made no changes beyond those two points, and I agree with both.

Approved

Clean. All four content-hashed assets (stylesheet, shell, highlighter bundle, app toolbar) now go through one helper, staticResponse. It sends a strong sha256 ETag and public, max-age=31536000, immutable, and answers a matching If-None-Match (exact, list or *) with a body-less 304. The router's security floor is still applied, and the test checks it on the 304. HTML pages and the unhashed /assets/forge.css redirect keep their non-immutable caching. Each hash is computed once, next to the asset's name, so a 304 does no hashing.

Security: the only new input is If-None-Match. It is only compared against a fixed server string and never reflected or stored. No new writes, identity paths or dependencies. The terminal is not involved because this is HTTP caching.

Verified on the head (dc5a9210): bun x tsc --noEmit is clean (the builder could not run it; it passes with a writable tempdir), and bun test test/web-editor.test.ts test/web.test.ts test/apps-toolbar.test.ts gives 83 pass, 0 fail. The new ETag/304 test would fail without the change for each of the four paths. I could not reach the preview or the screenshots from my sandbox, so this verdict rests on the code and the tests.

One nit, no action needed now: matches uses exact comparison, so a W/"…" tag sent back by an intermediary that weakened the tag gets a 200, not a 304. RFC 9110 asks for weak comparison on If-None-Match. Browsers send back the exact strong tag, so this is harmless today.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs