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
Check · Review · Land · Live done — Landed and live in production.
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
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, treatsq=0as a refusal, and uses onlyBun.gzipSyncandnode:zlibbrotli, 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()runscompressbeforesecurityFlooron every response. That covers pages and JSON.src/web/asset.ts:staticResponseencodes hashed assets once per content hash and encoding (a small cache, at gzip 9 or brotli 11) and reuses the bytes. 304s carryVarytoo.src/release/webspec.ts:webspec()now sendsAccept-Encoding: br, gzip. For/and each linked stylesheet it reports acompressionfinding when a body of 1 KB or more arrives withoutbrorgzip, or withoutVary. Findings are markededge, since a CDN may own the header, and citespecification.website/#compression. I guessed that section anchor and did not check it against the spec.- Tests:
test/compress.test.tsis new, andtest/webspec.test.tshas 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-Matchfor exact equality. Encodings are kept apart byVary, 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
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 /.
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/explorecome back br or gzip, about 12.5 KB down to about 3.2 KB, withVary: 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/plainare 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.tsandtest/web.test.tspass (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:
GET /withaccept-encoding: brgetscontent-encoding: brandVary: Accept-Encoding, thecontent-security-policyheader is still present, and the body decodes back to HTML.- The same request without
accept-encodinggets nocontent-encoding. - An SSE route, or a git
info/refsresponse, gets nocontent-encodingeven when it asks for br.
Lower, not blocking:
- The header of
compress.tscites #2940, but this is #2915 check 7. - The
#compressionanchor on specification.website was guessed, by the pull's own account. Check it, or cite the spec root.
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.tsbootsserve(createApp(...))astest/web.test.tsdoes, 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 tosecurityFloor(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 insrc/http/compress.ts, whichtest/compress.test.tsalready tests. - Issue reference: the
compress.tsheader now cites #2915 check 7. - Spec anchor: I couldn't check
#compressionfrom this sandbox, so the compression finding insrc/release/webspec.tsnow cites the spec root. Thewebspectest expects that.
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:
stylesheetTargetsandencodeare exported but only used inside their own modules.- If
response.arrayBuffer()throws partway throughcompress(), thecatchhands 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.
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()insrc/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 asyncbrotliCompress. - Failed read in
compress(): it now reads fromresponse.clone(). If the read throws, thecatchreturns the original response with its body intact, not a consumed one. - Exports:
encodeandstylesheetTargetsare no longer exported, since only their own modules use them.
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 fromproxyAppkeep their own bytes. The security floor is still applied after compression, andtest/compress-serve.test.tschecks 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 andno-transformresponses are all left alone. I confirmed everyReadableStreamresponder insrc/is off the type list, so nothing that streams gets buffered. - Headers and parsing:
Vary: Accept-Encodinggoes on every compressible type, including 304s and small plain bodies, and an existing Vary is extended rather than replaced.negotiaterespectsq=0and does not trust anything else the client sends. - No new dependency: it uses
Bun.gzipSyncandnode:zlibonly. - 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.tsandtest/webspec.test.tspass at this head (23 tests). The serve test fails if theanswered()change is reverted. - No UI change: the only file under
src/web/isasset.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.
The forge now compresses its own HTML, CSS, JS and JSON with br or gzip when the client's
Accept-Encodingallows it.bun testpasses fortest/compress.test.ts,test/webspec.test.ts,test/web.test.ts,test/web-editor.test.ts,test/access-log.test.tsandtest/api.test.ts. I could not runtsc --noEmitin 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, treatsq=0as a refusal, and uses onlyBun.gzipSyncandnode:zlibbrotli, 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 andno-transformresponses are left alone. -Vary: Accept-Encodingis added to every compressible type, compressed or not.src/http/server.ts:answered()runscompressbeforesecurityFlooron every response. That covers pages and JSON.src/web/asset.ts:staticResponseencodes hashed assets once per content hash and encoding (a small cache, at gzip 9 or brotli 11) and reuses the bytes. 304s carryVarytoo.src/release/webspec.ts:webspec()now sendsAccept-Encoding: br, gzip. For/and each linked stylesheet it reports acompressionfinding when a body of 1 KB or more arrives withoutbrorgzip, or withoutVary. Findings are markededge, since a CDN may own the header, and citespecification.website/#compression. I guessed that section anchor and did not check it against the spec.test/compress.test.tsis new, andtest/webspec.test.tshas a new compression case.Latency on
/Compression adds about 0.1–1 ms per large page. I measured it withencode()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
If-None-Matchfor exact equality. Encodings are kept apart byVary, not by ETag suffixes.answered(). I did not confirm that path by reading the proxy code.Try it: run
curl -sI -H 'accept-encoding: br' <forge>/, orbun scripts/smoke.ts <url> --standards.🤖 Generated with Claude Code
Closes #2940