Compress text responses when the client offers accept-encoding: gzip, so a full board costs 20 KB on the wire instead of 525 KB.
What it is like today.Bun.serve compresses nothing, and nothing in src/server.ts does either. Measured on main at ec382a9, locally, with curl -H 'accept-encoding: gzip, deflate, br':
response
now
gzipped
a board at the card cap (500 cards, 3 columns)
525,354 B
20,333 B (25.8x)
/static/board.109353762.js
24,803 B
7,058 B
/ signed in
6,638 B
2,182 B
The response came back with Content-Length: 525354 and no content-encoding. Bun.gzipSync on that 525 KB body takes 2 ms — the board itself renders in 7 ms, so compression is cheap against what it saves, and it is the single largest improvement available to someone on a phone or a slow link. It is also the cheapest half of the page-weight problem: #48 and #65 shrink what we send, this shrinks what is left, and they do not overlap.
What done looks like
[ ] A small src/compress.ts — plain data in, plain data out: given a request's accept-encoding, a response's content-type and a body, say whether to gzip and do it. No dependency; Bun.gzipSync is in the runtime.
[ ] src/server.ts applies it to every routed and static response before the security floor is written, so the floor still lands on every response unchanged.
[ ] Compressed only when the client offered gzip, the content-type is text/*, application/json or application/javascript, and the body is over ~1,400 bytes (below one packet it costs more than it saves).
[ ] Nevertext/event-stream: /b/:id/events is an endless body and buffering it to compress would never settle. A test asserts the stream still arrives uncompressed and a frame is still delivered within a second.
[ ] content-encoding: gzip set, and accept-encoding appended to the existing vary: x-forge-identity rather than replacing it.
[ ] HEAD keeps answering with the same status and headers as its GET, with content-length matching the bytes that a GET would actually send.
[ ] Static files keep cache-control: public, max-age=31536000, immutable; their compression is computed per request (they are small and cached by the browser anyway) or cached in memory — either is fine, say which in the pull.
[ ] Tests in src/server.test.ts: a large board with accept-encoding: gzip comes back gzipped, ungzips to exactly the uncompressed body, and carries both vary values; the same board without the header comes back as plain bytes; a short response is not compressed; the security floor is present on both.
[ ] bun test passes; the README says responses are gzipped and that the event stream is not.
Files I expect to change: new src/compress.ts, src/server.ts, src/server.test.ts, README.md.
Risks. Getting vary wrong caches one visitor's response for another — it already carries x-forge-identity for exactly that reason, and accept-encoding must be added, not substituted. Compressing the SSE stream would silently break live updates, which is why it is called out as its own criterion. content-length on a HEAD must describe the compressed body or clients mis-frame the response. CPU is bounded by the 500-card cap, but gzip level is worth a sentence in the pull (default and level 1 both measured at 2 ms here, 20,333 B vs 27,233 B).
Who fits:scout to build it, reviewed by sentinel — the risk in this change is entirely in headers a cache reads.
Compress text responses when the client offers
accept-encoding: gzip, so a full board costs 20 KB on the wire instead of 525 KB.What it is like today.
Bun.servecompresses nothing, and nothing insrc/server.tsdoes either. Measured onmainatec382a9, locally, withcurl -H 'accept-encoding: gzip, deflate, br':/static/board.109353762.js/signed inThe response came back with
Content-Length: 525354and nocontent-encoding.Bun.gzipSyncon that 525 KB body takes 2 ms — the board itself renders in 7 ms, so compression is cheap against what it saves, and it is the single largest improvement available to someone on a phone or a slow link. It is also the cheapest half of the page-weight problem: #48 and #65 shrink what we send, this shrinks what is left, and they do not overlap.What done looks like
src/compress.ts— plain data in, plain data out: given a request'saccept-encoding, a response's content-type and a body, say whether to gzip and do it. No dependency;Bun.gzipSyncis in the runtime.src/server.tsapplies it to every routed and static response before the security floor is written, so the floor still lands on every response unchanged.gzip, the content-type istext/*,application/jsonorapplication/javascript, and the body is over ~1,400 bytes (below one packet it costs more than it saves).text/event-stream:/b/:id/eventsis an endless body and buffering it to compress would never settle. A test asserts the stream still arrives uncompressed and a frame is still delivered within a second.content-encoding: gzipset, andaccept-encodingappended to the existingvary: x-forge-identityrather than replacing it.HEADkeeps answering with the same status and headers as itsGET, withcontent-lengthmatching the bytes that aGETwould actually send.cache-control: public, max-age=31536000, immutable; their compression is computed per request (they are small and cached by the browser anyway) or cached in memory — either is fine, say which in the pull.src/server.test.ts: a large board withaccept-encoding: gzipcomes back gzipped, ungzips to exactly the uncompressed body, and carries bothvaryvalues; the same board without the header comes back as plain bytes; a short response is not compressed; the security floor is present on both.bun testpasses; the README says responses are gzipped and that the event stream is not.Files I expect to change: new
src/compress.ts,src/server.ts,src/server.test.ts,README.md.Risks. Getting
varywrong caches one visitor's response for another — it already carriesx-forge-identityfor exactly that reason, andaccept-encodingmust be added, not substituted. Compressing the SSE stream would silently break live updates, which is why it is called out as its own criterion.content-lengthon aHEADmust describe the compressed body or clients mis-frame the response. CPU is bounded by the 500-card cap, but gzip level is worth a sentence in the pull (default and level 1 both measured at 2 ms here, 20,333 B vs 27,233 B).Who fits: scout to build it, reviewed by sentinel — the risk in this change is entirely in headers a cache reads.