feat(amuleapi): gzip Content-Encoding on regular responses + SSE stream - #314
Merged
Merged
Conversation
Adds Content-Encoding: gzip negotiation to the amuleapi HTTP server on both the regular request/response path and the SSE /api/v0/events stream. Frontend needs no changes: fetch() and EventSource handle Accept-Encoding / Content-Encoding transparently. Regular responses: one-shot deflate(Z_FINISH) with windowBits=15+16 (gzip wrapper), gated on client Accept-Encoding + body >= 256 bytes + no handler-set Content-Encoding. On any zlib error the body is served uncompressed rather than 500. Vary: Accept-Encoding is emitted on every response so intermediary caches key correctly. SSE: one persistent z_stream per session, so the dictionary carries across every emitted event and repeating JSON keys / hash prefixes / priority strings compress against a shared reference. Each Write() runs through deflate(Z_SYNC_FLUSH) then frames the compressed bytes as a single HTTP chunked frame; Z_SYNC_FLUSH byte-aligns the block so the browser sees events live. Worker exit emits Z_FINISH (gzip CRC + length) before DoClose so the browser sees a complete stream. Reverse-proxy safety (all SSE responses regardless of compression): X-Accel-Buffering: no opts out of nginx's default upstream buffering which would otherwise stall event delivery; Cache-Control: no-cache is the SSE convention. Measurements against a live daemon on a Mac (60-sec sample, active downloads): /api/v0/stats/tree: 7098 B -> 1217 B (5.8x) /api/v0/events : 1.32 MB -> 60 KB (22.6x)
got3nks
marked this pull request as ready for review
July 5, 2026 15:24
Cflsft
pushed a commit
to Cflsft/amule
that referenced
this pull request
Jul 6, 2026
…le-org#314) Adds Content-Encoding: gzip negotiation to the amuleapi HTTP server on both the regular request/response path and the SSE /api/v0/events stream. Frontend needs no changes: fetch() and EventSource handle Accept-Encoding / Content-Encoding transparently. Regular responses: one-shot deflate(Z_FINISH) with windowBits=15+16 (gzip wrapper), gated on client Accept-Encoding + body >= 256 bytes + no handler-set Content-Encoding. On any zlib error the body is served uncompressed rather than 500. Vary: Accept-Encoding is emitted on every response so intermediary caches key correctly. SSE: one persistent z_stream per session, so the dictionary carries across every emitted event and repeating JSON keys / hash prefixes / priority strings compress against a shared reference. Each Write() runs through deflate(Z_SYNC_FLUSH) then frames the compressed bytes as a single HTTP chunked frame; Z_SYNC_FLUSH byte-aligns the block so the browser sees events live. Worker exit emits Z_FINISH (gzip CRC + length) before DoClose so the browser sees a complete stream. Reverse-proxy safety (all SSE responses regardless of compression): X-Accel-Buffering: no opts out of nginx's default upstream buffering which would otherwise stall event delivery; Cache-Control: no-cache is the SSE convention. Measurements against a live daemon on a Mac (60-sec sample, active downloads): /api/v0/stats/tree: 7098 B -> 1217 B (5.8x) /api/v0/events : 1.32 MB -> 60 KB (22.6x)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
Content-Encoding: gzipnegotiation to the amuleapi HTTP server on both the regular request/response path and the SSE/api/v0/eventsstream. Frontend needs no changes:fetch()andEventSourcehandleAccept-Encoding/Content-Encodingtransparently.Why
The SSE event stream is the dominant amuleapi bandwidth consumer on cellular / low-bandwidth links. Measured against a live daemon with active downloads on a Mac (60-sec sample):
/api/v0/stats/tree(regular)/api/v0/events(SSE, 60 sec, active DLs)SSE compresses harder because gzip's dictionary is shared across events, so repeating JSON keys / hash prefixes / priority strings across every delta emit a few bits each.
Design
Regular responses (
WriteResponse)Accept-EncodingcontainsgzipAND body ≥kGzipMinBodyBytes(256, since gzip header + trailer eats sub-256-byte bodies) AND handler didn't already setContent-Encoding.deflate(Z_FINISH)withwindowBits=15+16(gzip wrapper),Z_DEFAULT_COMPRESSION,mem_level=8— nginx / Apache defaults.Vary: Accept-Encodingto every response (compressed or not) so intermediary caches key correctly.SSE (
SocketWriter)z_streamper session. The dictionary carries across every emitted event → the big ratio comes from repeated JSON keys / hash prefixes / priority strings compressing against the shared reference.Write(chunk)runs the chunk throughdeflate(Z_SYNC_FLUSH), then frames the compressed bytes as a single HTTP chunked frame.Z_SYNC_FLUSHbyte-aligns the block so the browser sees events live rather than after a compression buffer fills.writer->Finalize()beforeDoClose(): emitsdeflate(Z_FINISH)trailer (gzip CRC + length) as a final chunked frame so the browser sees a complete gzip stream.Content-Encoding: gzip,Vary: Accept-Encoding(only when compressing), and unconditionallyX-Accel-Buffering: no+Cache-Control: no-cache.nginx / reverse-proxy safety
Three separate concerns, addressed independently:
X-Accel-Buffering: noopts out on nginx (and OpenResty / ingress-nginx honour the same header). Harmless on backends that don't recognise it.Vary: Accept-Encodingon every response tells intermediary caches to key by encoding so a client that strippedAccept-Encodingnever hits an entry keyed for a client that sent it.Varymitigates but doesn't fully prevent — realistic only if operators put amuleapi behind Cloudflare / a CDN, which the loopback default doesn't invite.Non-goals
br(Brotli) support. Adds a new dep for marginal ratio improvement over gzip on JSON.Frontend impact
None. The existing
fetch()inapi.jsandEventSourceinevents.jssetAccept-Encodingautomatically (browsers hard-code it; it's a forbidden header for JS to modify) and decompressContent-Encoding: gziptransparently before the response reaches JS.response.json()/EventSource.onmessagenever see the wire bytes.Test plan
/api/v0/stats/treewithAccept-Encoding: gzipreturnsContent-Encoding: gzip, decoded body byte-identical to uncompressed, 5.8× smaller wire.curl --compresseddecodes cleanly, delivers valid SSE events. Headers:Content-Encoding: gzip,Vary: Accept-Encoding,X-Accel-Buffering: no,Cache-Control: no-cache,Transfer-Encoding: chunked.