feat: add opt-in zstd content-encoding - #21
Merged
Conversation
CodeDredd
force-pushed
the
feat/zstd-compression
branch
from
August 6, 2026 08:08
96ee273 to
bddbc03
Compare
Owner
Author
|
Reopening to re-trigger CI after the trailer cleanup force-push (tree unchanged). |
`node:zlib` gained zstd in Node 22.15.0 / 23.8.0, and the native
CompressionStream rejects 'zstd' just like it rejects 'br', so both paths go
through zlib — buffered via `zstdCompress`, streamed via
`Duplex.toWeb(createZstdCompress({ flush: ZSTD_e_flush }))`. Without the
explicit flush mode zstd buffers the whole body until the source closes, the
same trap brotli has.
Zstd is opt-in for a different reason than brotli: enabling it by default
would make the negotiated Content-Encoding depend on the Node version the app
happens to run on. Behaviour on a runtime without zstd:
- with the `zstd: true` flag it is skipped during negotiation and the next
accepted encoding is used — no error
- when forced (`compression('zstd')`, `useZstdCompression`) it throws a
TypeError naming the required Node version, because silently sending a
different encoding would be worse
`isZstdSupported()` is exported so callers can branch themselves.
`engines` is set to >=20.11.1, matching h3 v2 — NOT to >=22.15, which would
lock every gzip/brotli user out over a feature they may never enable. The CI
matrix moves from node [18, 20] to [20, 22, 24] so both the zstd path and its
fallback are exercised; @types/node is bumped to ^22.20 for the zstd typings
(build-time only).
Closes #7
CodeDredd
force-pushed
the
feat/zstd-compression
branch
from
August 6, 2026 08:23
bddbc03 to
70dedf6
Compare
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.
Closes #7
What
Adds
zstdas a content-encoding on both paths — buffered (zlib) and streamed. Opt-in, like brotli in #20.Why opt-in — and why
enginesis not bumped to 22.15node:zlibgained zstd in Node 22.15.0 / 23.8.0. Enabling it by default would make the negotiatedContent-Encodingdepend on which Node the app happens to run on — a bad thing to discover in production.Pinning
enginesto>=22.15instead would be worse: it locks every gzip/brotli user out over a feature they may never enable. Soenginesis set to>=20.11.1, matching h3 v2's own floor, and zstd is gated at runtime:{ zstd: true }, runtime has zstd{ zstd: true }, runtime has no zstdcompression('zstd'),useZstdCompression) without supportTypeErrornaming the required Node versionThrowing on the forced path is deliberate: the caller asked for zstd specifically, and silently sending a different encoding would hide the problem.
isZstdSupported()is exported so callers can branch themselves:How
Same shape as the brotli work —
createCompressionTransform()gained a zstd branch, and the buffered path moved behind a newcreateCompressor()so the method → zlib-function mapping lives in one place instead of being inlined twice.ZSTD_e_flushis load-bearing, exactly likeBROTLI_OPERATION_FLUSH: zstd's default isZSTD_e_continue, which buffers the whole body until the source closes. Measured on Node 24 with 5 slowly produced chunks:ZSTD_e_flushA test asserts the stream stays chunked so this can't silently regress.
Note that
CompressionStream('zstd')is rejected on every Node version tested (20, 22, 24) — zstd is not in the WHATWGCompressionFormatenum either, so there is no native fast path to prefer.Ordering
When enabled, zstd outranks brotli — better ratio at meaningfully lower CPU cost for dynamic content. Without the flag nothing changes: the zlib path still prefers
br > gzip > deflate, the stream path still prefersgzip > deflate.Repo changes
engines: { node: ">=20.11.1" }— the package had none before; this matches h3 v2node: [18, 20]→[20, 22, 24]. Node 18 is below the new floor (and EOL); 20 covers the no-zstd fallback, 22/24 cover the zstd path@types/node^20.5.7→^22.20.1—@types/node@20has no zstd typings and the.d.tsbuild fails without it. Build-time only, no runtime effectAPI
Compression/StreamCompression'zstd'CompressionOptions{ zstd?: boolean }StreamCompressionOptions{ brotli?, zstd? })CompressionMiddlewareOptionsmethod?compression(method | options?)compressResponse(event, value, method?, options?)useCompression(event, response, options?)useZstdCompression,useZstdCompressionStream,isZstdSupportedAll additive.
Verification
Ran the full suite across all four combinations that matter, locally:
The skip counts differ by design — the zstd-executing tests are gated on
isZstdSupported(), while the negotiation-fallback and forced-throw tests run on the unsupported runtime specifically. Both directions are covered rather than one being silently skipped everywhere.Also:
pnpm lintclean (2 remaining warnings are the pre-existingvue/one-component-per-filefalse positives oncreateApp),pnpm buildclean from a wipeddist/, and thedist-bundlingregression test from #18 still passes.