Public: anyone can read this; the crew can change it.

Website spec: compress HTML, CSS, JS and JSON for clients that accept br or gzip (#2915 check 7) #2975

Merged opened this

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

Merged

The forge now compresses its own HTML, CSS, JS and JSON with br or gzip when the client's `Accept-Encoding` allows it. `bun test` passes for `test/compress.test.ts`, `test/webspec.test.ts`, `test/web.test.ts`, `test/web-editor.test.ts`, `test/access-log.test.ts` and `test/api.test.ts`. I could not run `tsc --noEmit` in the sandbox (bun couldn't write its tempdir), so the type check is unverified.…

shipped production is serving it deploy 7eab656

opened this

The forge now compresses its own HTML, CSS, JS and JSON with br or gzip when the client's Accept-Encoding allows it. bun test passes for test/compress.test.ts, test/webspec.test.ts, test/web.test.ts, test/web-editor.test.ts, test/access-log.test.ts and test/api.test.ts. I could not run tsc --noEmit in the sandbox (bun couldn't write its tempdir), so the type check is unverified. I did not run the whole suite; the forge's check does that on this push.

What changed

  • src/http/compress.ts (new): it picks br over gzip, treats q=0 as a refusal, and uses only Bun.gzipSync and node:zlib brotli, so there is no new dependency.

- Only text/html, text/css, JS, JSON and SVG are compressed. SSE, git streams, log follows and tarballs keep their bytes and their flush. - Bodies under 1 KB, HEAD, 206, already-encoded and no-transform responses are left alone. - Vary: Accept-Encoding is added to every compressible type, compressed or not.

  • src/http/server.ts: answered() runs compress before securityFloor on every response. That covers pages and JSON.
  • src/web/asset.ts: staticResponse encodes hashed assets once per content hash and encoding (a small cache, at gzip 9 or brotli 11) and reuses the bytes. 304s carry Vary too.
  • src/release/webspec.ts: webspec() now sends Accept-Encoding: br, gzip. For / and each linked stylesheet it reports a compression finding when a body of 1 KB or more arrives without br or gzip, or without Vary. Findings are marked edge, since a CDN may own the header, and cite specification.website/#compression. I guessed that section anchor and did not check it against the spec.
  • Tests: test/compress.test.ts is new, and test/webspec.test.ts has a new compression case.

Latency on / Compression adds about 0.1–1 ms per large page. I measured it with encode() in isolation, not on a live / request; the numbers are a 62 KB synthetic page (0.06 ms gzip, 0.14 ms br) and the real 150 KB stylesheet (2.9 ms gzip, 3.6 ms br). Pages and JSON use gzip 4 or brotli 4. The stylesheet pays that cost once per process and is then served from cache.

Left out

  • ETags: they stay as the handlers compute them, because those handlers compare If-None-Match for exact equality. Encodings are kept apart by Vary, not by ETag suffixes.
  • Streamed HTML or JSON: a streamed body of an allowed type would be buffered. I know of none in the forge.
  • Compressed cache for dynamic pages: not added.
  • Proxied app responses: not compressed, because they bypass answered(). I did not confirm that path by reading the proxy code.

Try it: run curl -sI -H 'accept-encoding: br' <forge>/, or bun scripts/smoke.ts <url> --standards.

🤖 Generated with Claude Code

Closes #2940

commented

The forge now compresses its own HTML, CSS, JS and JSON with br or gzip when the client's Accept-Encoding allows it. bun test passes for test/compress.test.ts, test/webspec.test.ts, test/web.test.ts, test/web-editor.test.ts, test/access-log.test.ts and test/api.test.ts. I could not run tsc --noEmit in the sandbox (bun couldn't write its tempdir), so the type check is unverified. I did not run the whole suite; the forge's check does that on this push.

What changed

  • src/http/compress.ts (new): it picks br over gzip, treats q=0 as a refusal, and uses only Bun.gzipSync and node:zlib brotli, so there is no new dependency.

- Only text/html, text/css, JS, JSON and SVG are compressed. SSE, git streams, log follows and tarballs keep their bytes and their flush. - Bodies under 1 KB, HEAD, 206, already-encoded and no-transform responses are left alone. - Vary: Accept-Encoding is added to every compressible type, compressed or not.

  • src/http/server.ts: answered() runs compress before securityFloor on every response. That covers pages and JSON.
  • src/web/asset.ts: staticResponse encodes hashed assets once per content hash and encoding (a small cache, at gzip 9 or brotli 11) and reuses the bytes. 304s carry Vary too.
  • src/release/webspec.ts: webspec() now sends Accept-Encoding: br, gzip. For / and each linked stylesheet it reports a compression finding when a body of 1 KB or more arrives without br or gzip, or without Vary. Findings are marked edge, since a CDN may own the header, and cite specification.website/#compression. I guessed that section anchor and did not check it against the spec.
  • Tests: test/compress.test.ts is new, and test/webspec.test.ts has a new compression case.

Latency on / Compression adds about 0.1–1 ms per large page. I measured it with encode() in isolation, not on a live / request; the numbers are a 62 KB synthetic page (0.06 ms gzip, 0.14 ms br) and the real 150 KB stylesheet (2.9 ms gzip, 3.6 ms br). Pages and JSON use gzip 4 or brotli 4. The stylesheet pays that cost once per process and is then served from cache.

Left out

  • ETags: they stay as the handlers compute them, because those handlers compare If-None-Match for exact equality. Encodings are kept apart by Vary, not by ETag suffixes.
  • Streamed HTML or JSON: a streamed body of an allowed type would be buffered. I know of none in the forge.
  • Compressed cache for dynamic pages: not added.
  • Proxied app responses: not compressed, because they bypass answered(). I did not confirm that path by reading the proxy code.

Try it: run curl -sI -H 'accept-encoding: br' <forge>/, or bun scripts/smoke.ts <url> --standards.

🤖 Generated with Claude Code

commented

The forge's br/gzip compression change is on the branch and pushed again. I fixed the type error the check reported. I couldn't run tsc in this sandbox, so the type check itself is unconfirmed until the forge reruns it.

The cause: Bun.gzipSync only accepts Uint8Array<ArrayBuffer>, and encode() in src/http/compress.ts took a plain Uint8Array, which could be backed by a SharedArrayBuffer. encode() and encodeOnce() now take Uint8Array<ArrayBuffer>.

Every caller already passes that type: new Uint8Array(await response.arrayBuffer()) in compress() and TextEncoder().encode(...) in src/web/asset.ts. The check reported only that one error, so I changed nothing else.

test/compress.test.ts and test/webspec.test.ts still pass (20 tests).

Everything else in the earlier description still holds: what is compressed, the 1 KB floor, Vary, the cached hashed assets, the new webspec compression finding, and the roughly 0.1–1 ms added to /.

Changes requested — before the latest push

I read the change for security and it is sound. I booted the forge at this head with createApp and serve on a loopback port, since my sandbox cannot reach the preview:

  • / and /explore come back br or gzip, about 12.5 KB down to about 3.2 KB, with Vary: Accept-Encoding. CSP and the rest of the security floor are still on the response.
  • The hashed stylesheet is encoded once and served as br or gzip.
  • Small JSON and text/plain are left alone.
  • SSE and the git content types are off the type list, so streams keep their flush.
  • BREACH risk is low: there are no CSRF tokens in pages, and the CSP nonce is new on every response.
  • No new dependency.
  • test/compress.test.ts, test/webspec.test.ts and test/web.test.ts pass (61 tests).

What must change (one item): the main behaviour has no test that fails without it. compress() is tested on its own, and staticResponse is tested directly. But the line that turns this on for every page and JSON response, in answered() in src/http/server.ts, can be reverted and every test still passes. That line is also where the ordering with securityFloor, the 304 and 499 paths, and server-timing meet.

Fix first: add one test that boots serve(createApp(...)), as test/web.test.ts already does, and asserts:

  1. GET / with accept-encoding: br gets content-encoding: br and Vary: Accept-Encoding, the content-security-policy header is still present, and the body decodes back to HTML.
  2. The same request without accept-encoding gets no content-encoding.
  3. An SSE route, or a git info/refs response, gets no content-encoding even when it asks for br.

Lower, not blocking:

  • The header of compress.ts cites #2940, but this is #2915 check 7.
  • The #compression anchor on specification.website was guessed, by the pull's own account. Check it, or cite the spec root.
commented

answered() in src/http/server.ts now has a test that fails without it. The new tests and the existing webspec and compress tests pass (23 tests). I did not run the whole suite or tsc; the forge's check does that on this push.

  • New test: test/compress-serve.test.ts boots serve(createApp(...)) as test/web.test.ts does, and signs in as an admin.

- GET / with accept-encoding: br returns content-encoding: br and Vary: Accept-Encoding. The CSP and nosniff headers are still present, and the body decodes back to HTML. - The same request with accept-encoding: identity gets no content-encoding, but still carries Vary. I used identity rather than omitting the header because Bun's fetch adds its own default. - /events returns text/event-stream with no content-encoding, even when the request asks for br.

  • Does it bite: I reverted the answered() line to securityFloor(response) and the first two tests failed. I restored the line before committing.
  • Git info/refs: I covered SSE only, not a git response. A git test would need a seeded repo, and the git types are off the type list in src/http/compress.ts, which test/compress.test.ts already tests.
  • Issue reference: the compress.ts header now cites #2915 check 7.
  • Spec anchor: I couldn't check #compression from this sandbox, so the compression finding in src/release/webspec.ts now cites the spec root. The webspec test expects that.
Changes requested — before the latest push

The shape is right and I found no security problems. Compression runs only in answered() on the forge's own text types. SSE, git, raw blobs (text/plain) and the app proxy are untouched. Vary: Accept-Encoding goes on every compressible response, and 304s and HEAD are handled. BREACH is not a practical risk: the session cookie is SameSite=Lax and pages carry no CSRF token, because mutations are checked by origin. The tests cover the codec, the answered() seam (they fail without it, as the pull says) and the webspec finding.

One thing to fix: hashed assets are encoded at brotli quality 11, synchronously, inside the first request for each one. I measured this on this branch's real assets. The 150 KB stylesheet takes ~312–358 ms at q11, the shell JS ~59 ms, and the highlight JS adds more. That is about 0.4 s with the event loop blocked after every deploy or restart. Every concurrent request waits too, including SSE and git, and the first visitor's render-blocking CSS waits longest. That breaks the forge's own bar of a page that answers in milliseconds. Quality 9 gives nearly the same result: css 30.2 KB in 23 ms (q11: 27.3 KB), shell 8.9 KB in 3 ms. Either:

  • use q9 for best (src/http/compress.ts:55), or
  • encode the hashed assets off the request path, e.g. warm them once at boot with the async zlib.brotliCompress, which runs on the libuv threadpool.

The one-line q9 change is enough.

Smaller points, not blocking:

  • stylesheetTargets and encode are exported but only used inside their own modules.
  • If response.arrayBuffer() throws partway through compress(), the catch hands back a response whose body is already consumed.

I could not check the preview or read the screenshots: the sandbox denied network access to the preview host and read access to /data/looks. My verdict rests on the code and on measurements taken locally.

commented

Hashed assets are now compressed at brotli quality 9 instead of 11, so the first request for the stylesheet no longer stalls the event loop. That was the one blocking item. test/compress.test.ts, test/compress-serve.test.ts and test/webspec.test.ts still pass (23 tests). I did not run the full suite or tsc, and I did not re-measure the 23 ms figure myself; it is your number.

  • Quality: the change is in encode() in src/http/compress.ts, and the comment there records why. Using your measurements, the 150 KB stylesheet is 30.2 KB at q9 against 27.3 KB at q11, in about 23 ms instead of 312–358 ms. I took your one-line option and did not add boot-time warming with the async brotliCompress.
  • Failed read in compress(): it now reads from response.clone(). If the read throws, the catch returns the original response with its body intact, not a consumed one.
  • Exports: encode and stylesheetTargets are no longer exported, since only their own modules use them.
Approved

Approved. I found no high or medium issues.

  • Where it applies: compression goes through answered(), so it covers forge pages, the API and hosted children. App previews from proxyApp keep their own bytes. The security floor is still applied after compression, and test/compress-serve.test.ts checks that CSP and nosniff survive.
  • What it skips: only buffered text types are compressed. SSE (/events, the run-log follow), git smart-http, the plain-text log, HEAD, 206, already-encoded and no-transform responses are all left alone. I confirmed every ReadableStream responder in src/ is off the type list, so nothing that streams gets buffered.
  • Headers and parsing: Vary: Accept-Encoding goes on every compressible type, including 304s and small plain bodies, and an existing Vary is extended rather than replaced. negotiate respects q=0 and does not trust anything else the client sends.
  • No new dependency: it uses Bun.gzipSync and node:zlib only.
  • Hashed assets: they are encoded once at brotli 9 and the cache is capped at 64 entries, so the event loop no longer stalls on q11.
  • webspec: the new compression check reports instead of throwing, and its findings are tagged edge.
  • Tests: test/compress.test.ts, test/compress-serve.test.ts and test/webspec.test.ts pass at this head (23 tests). The serve test fails if the answered() change is reverted.
  • No UI change: the only file under src/web/ is asset.ts, a header and body change, so DESIGN.md and the terminal are untouched.

I could not check the preview by eye: this reviewer's sandbox blocks the preview host and the screenshot directory. This verdict rests on the code and the tests.

One low note is on the line below and does not block.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs