Skip to content

feat: add opt-in zstd content-encoding - #21

Merged
CodeDredd merged 1 commit into
mainfrom
feat/zstd-compression
Aug 6, 2026
Merged

feat: add opt-in zstd content-encoding#21
CodeDredd merged 1 commit into
mainfrom
feat/zstd-compression

Conversation

@CodeDredd

Copy link
Copy Markdown
Owner

Closes #7

What

Adds zstd as a content-encoding on both paths — buffered (zlib) and streamed. Opt-in, like brotli in #20.

app.use(compression({ zstd: true }))                     // zstd, br, gzip, deflate
app.use(compressionStream({ zstd: true, brotli: true })) // same order, streamed
app.use(compression('zstd'))                             // always zstd

await useCompression(event, response, { zstd: true })
await useZstdCompression(event, response)
await useZstdCompressionStream(event, response)

Why opt-in — and why engines is not bumped to 22.15

node:zlib gained zstd in Node 22.15.0 / 23.8.0. Enabling it by default would make the negotiated Content-Encoding depend on which Node the app happens to run on — a bad thing to discover in production.

Pinning engines to >=22.15 instead would be worse: it locks every gzip/brotli user out over a feature they may never enable. So engines is set to >=20.11.1, matching h3 v2's own floor, and zstd is gated at runtime:

situation behaviour
{ zstd: true }, runtime has zstd zstd is used
{ zstd: true }, runtime has no zstd skipped during negotiation, next accepted encoding used — no error
forced (compression('zstd'), useZstdCompression) without support TypeError naming the required Node version

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

import { isZstdSupported } from 'h3-compression'
app.use(compression({ zstd: isZstdSupported() }))

How

Same shape as the brotli work — createCompressionTransform() gained a zstd branch, and the buffered path moved behind a new createCompressor() so the method → zlib-function mapping lives in one place instead of being inlined twice.

ZSTD_e_flush is load-bearing, exactly like BROTLI_OPERATION_FLUSH: zstd's default is ZSTD_e_continue, which buffers the whole body until the source closes. Measured on Node 24 with 5 slowly produced chunks:

variant emitted output chunks
zstd, zlib defaults 1
zstd, ZSTD_e_flush 6

A 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 WHATWG CompressionFormat enum 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 prefers gzip > deflate.

Repo changes

  • engines: { node: ">=20.11.1" } — the package had none before; this matches h3 v2
  • CI matrix node: [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
  • lint + release jobs move from Node 18 to 20
  • @types/node ^20.5.7^22.20.1@types/node@20 has no zstd typings and the .d.ts build fails without it. Build-time only, no runtime effect

API

Compression / StreamCompression widened with 'zstd'
CompressionOptions new — { zstd?: boolean }
StreamCompressionOptions now extends the above ({ brotli?, zstd? })
CompressionMiddlewareOptions new — the above plus method?
compression(method | options?) string argument still works
compressResponse(event, value, method?, options?) new optional 4th argument
useCompression(event, response, options?) new optional 3rd argument
useZstdCompression, useZstdCompressionStream, isZstdSupported new exports

All additive.

Verification

Ran the full suite across all four combinations that matter, locally:

h3 1.8.1 h3 2.0.1-rc.22
Node 22.14 (no zstd) 11 passed 31 passed
Node 24.17 (zstd) 11 passed 38 passed

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 lint clean (2 remaining warnings are the pre-existing vue/one-component-per-file false positives on createApp), pnpm build clean from a wiped dist/, and the dist-bundling regression test from #18 still passes.

@CodeDredd

Copy link
Copy Markdown
Owner Author

Reopening to re-trigger CI after the trailer cleanup force-push (tree unchanged).

@CodeDredd CodeDredd closed this Aug 6, 2026
@CodeDredd CodeDredd reopened this Aug 6, 2026
`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
CodeDredd force-pushed the feat/zstd-compression branch from bddbc03 to 70dedf6 Compare August 6, 2026 08:23
@CodeDredd
CodeDredd merged commit 2b77b74 into main Aug 6, 2026
13 checks passed
@CodeDredd
CodeDredd deleted the feat/zstd-compression branch August 6, 2026 08:26
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.

Add zstd content-encoding

1 participant