Skip to content

feat(amuleapi): gzip Content-Encoding on regular responses + SSE stream - #314

Merged
got3nks merged 1 commit into
amule-org:masterfrom
got3nks:feat/amuleapi-compression
Jul 5, 2026
Merged

feat(amuleapi): gzip Content-Encoding on regular responses + SSE stream#314
got3nks merged 1 commit into
amule-org:masterfrom
got3nks:feat/amuleapi-compression

Conversation

@got3nks

@got3nks got3nks commented Jul 5, 2026

Copy link
Copy Markdown

What

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.

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):

Endpoint Uncompressed With gzip Ratio
/api/v0/stats/tree (regular) 7 098 B 1 217 B 5.8×
/api/v0/events (SSE, 60 sec, active DLs) 1 382 980 B 61 061 B 22.6×

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)

  • Gate: client Accept-Encoding contains gzip AND body ≥ kGzipMinBodyBytes (256, since gzip header + trailer eats sub-256-byte bodies) AND handler didn't already set Content-Encoding.
  • One-shot deflate(Z_FINISH) with windowBits=15+16 (gzip wrapper), Z_DEFAULT_COMPRESSION, mem_level=8 — nginx / Apache defaults.
  • On any zlib error: ship the body uncompressed rather than 500. A transient zlib failure shouldn't become a user-visible outage.
  • Add Vary: Accept-Encoding to every response (compressed or not) so intermediary caches key correctly.

SSE (SocketWriter)

  • One persistent z_stream per 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.
  • Each Write(chunk) runs the chunk 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 rather than after a compression buffer fills.
  • Worker exit path calls writer->Finalize() before DoClose(): emits deflate(Z_FINISH) trailer (gzip CRC + length) as a final chunked frame so the browser sees a complete gzip stream.
  • Response headers: Content-Encoding: gzip, Vary: Accept-Encoding (only when compressing), and unconditionally X-Accel-Buffering: no + Cache-Control: no-cache.

nginx / reverse-proxy safety

Three separate concerns, addressed independently:

  1. Buffering stall: nginx by default buffers chunked-transfer + text-ish responses upstream, which turns SSE into "events show up in bursts every N seconds". Emitting X-Accel-Buffering: no opts out on nginx (and OpenResty / ingress-nginx honour the same header). Harmless on backends that don't recognise it.
  2. Cache key collisions: emitting Vary: Accept-Encoding on every response tells intermediary caches to key by encoding so a client that stripped Accept-Encoding never hits an entry keyed for a client that sent it.
  3. Proxy re-compression: any intermediary that decodes + re-encodes will restart zlib's dictionary and kill the SSE ratio. Vary mitigates but doesn't fully prevent — realistic only if operators put amuleapi behind Cloudflare / a CDN, which the loopback default doesn't invite.

Non-goals

  • No config toggle. If a specific deployment sees a stall we haven't anticipated, disabling is a one-liner revert; but shipping with a toggle invites bug reports of the "did you enable it?" shape without adding safety.
  • No br (Brotli) support. Adds a new dep for marginal ratio improvement over gzip on JSON.
  • No compression on request bodies. amuleapi request bodies are login JSON (~64 B) and category patches (~few KB); not worth the code.

Frontend impact

None. The existing fetch() in api.js and EventSource in events.js set Accept-Encoding automatically (browsers hard-code it; it's a forbidden header for JS to modify) and decompress Content-Encoding: gzip transparently before the response reaches JS. response.json() / EventSource.onmessage never see the wire bytes.

Test plan

  • Regular endpoint smoke test on macOS: /api/v0/stats/tree with Accept-Encoding: gzip returns Content-Encoding: gzip, decoded body byte-identical to uncompressed, 5.8× smaller wire.
  • SSE smoke test on macOS: curl --compressed decodes cleanly, delivers valid SSE events. Headers: Content-Encoding: gzip, Vary: Accept-Encoding, X-Accel-Buffering: no, Cache-Control: no-cache, Transfer-Encoding: chunked.
  • 60-sec SSE ratio measurement with active downloads: 1.32 MB → 60 KB wire (22.6× smaller).

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
got3nks marked this pull request as ready for review July 5, 2026 15:24
@got3nks
got3nks merged commit 78d8ae0 into amule-org:master Jul 5, 2026
13 checks passed
@got3nks
got3nks deleted the feat/amuleapi-compression branch July 5, 2026 15:44
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant