feat: add opt-in brotli support to the stream compression path - #20
Merged
Conversation
The native CompressionStream implements the WHATWG CompressionFormat enum
(gzip, deflate, deflate-raw) and has no brotli format, so brotli is streamed
through `node:zlib` via `Duplex.toWeb(zlib.createBrotliCompress())` behind a
new `createCompressionTransform()` seam. `BROTLI_OPERATION_FLUSH` keeps the
output chunked — with zlib's defaults brotli buffers the whole body until the
source closes, which would turn a stream back into a buffer.
Brotli is opt-in rather than preferred: it is noticeably more CPU-expensive
per request, so picking it automatically would silently change behaviour for
every existing `compressionStream()` user.
app.use(compressionStream()) // gzip / deflate, unchanged
app.use(compressionStream({ brotli: true })) // brotli, gzip, deflate
app.use(compressionStream('br')) // always brotli
Also fixes `useCompressionStream` skipping compression entirely when the
client sent `Accept-Encoding: br, gzip` — it used `getAnyCompression` and
bailed on the resulting `'br'`. It now uses `getStreamCompression` and falls
back to gzip.
Closes #19
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Uktme231JRZSJXFeLPiZy
CodeDredd
added a commit
that referenced
this pull request
Aug 6, 2026
The native CompressionStream implements the WHATWG CompressionFormat enum
(gzip, deflate, deflate-raw) and has no brotli format, so brotli is streamed
through `node:zlib` via `Duplex.toWeb(zlib.createBrotliCompress())` behind a
new `createCompressionTransform()` seam. `BROTLI_OPERATION_FLUSH` keeps the
output chunked — with zlib's defaults brotli buffers the whole body until the
source closes, which would turn a stream back into a buffer.
Brotli is opt-in rather than preferred: it is noticeably more CPU-expensive
per request, so picking it automatically would silently change behaviour for
every existing `compressionStream()` user.
app.use(compressionStream()) // gzip / deflate, unchanged
app.use(compressionStream({ brotli: true })) // brotli, gzip, deflate
app.use(compressionStream('br')) // always brotli
Also fixes `useCompressionStream` skipping compression entirely when the
client sent `Accept-Encoding: br, gzip` — it used `getAnyCompression` and
bailed on the resulting `'br'`. It now uses `getStreamCompression` and falls
back to gzip.
Closes #19
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 #19
What
Adds brotli to the stream compression path — opt-in, never picked automatically.
Brotli stays behind a flag because it is noticeably more CPU-expensive per request than gzip; picking it automatically would silently change the behaviour of every existing
compressionStream()user onAccept-Encoding: br, gzip.How
The native
CompressionStreamimplements the WHATWGCompressionFormatenum —gzip,deflate,deflate-raw— and rejects'br'. Brotli is therefore streamed throughnode:zlib, wrapped into a webReadableWritablePairwithDuplex.toWeb(), behind a newcreateCompressionTransform()seam that both stream call sites (compressStreamfor the h3 v1 / nitro hook path andcompressResponseStreamfor the v2 middleware) now go through.BROTLI_OPERATION_FLUSHis load-bearing: with zlib's defaults brotli buffers the whole body until the source closes, which turns a streamed response back into a buffered one. Measured with 5 slowly produced chunks:CompressionStream('gzip')BROTLI_OPERATION_FLUSHThere is a test asserting the stream stays chunked so this can't silently regress.
No new runtime constraint:
src/helper.tsalready importednode:zlibstatically, so the stream path was never runtime-agnostic. The README now says so explicitly instead of claiming brotli is impossible.Drive-by fix
useCompressionStreamusedgetAnyCompressionand then bailed out onif (compression !== 'br'). For a client sendingAccept-Encoding: br, gzipthat meant no compression at all — not a gzip fallback. It now usesgetStreamCompression, which falls back to gzip. Covered by a test on both the v1 and v2 path.API
StreamCompression'gzip' | 'deflate' | 'br'StreamCompressionOptions{ brotli?: boolean }CompressionStreamOptionsmethod?compressionStream(method | options?)compressResponseStream(event, value, method?, options?)useCompressionStream(event, response, options?)useBrotliCompressionStream(event, response)All additive — no breaking changes.
Verification
pnpm testgreen on h3 2.0.1-rc.22 (26 passed) and on h3 1.8.1 (10 passed), matching the CI matrix legspnpm lintclean (the 2 remaining warnings are the pre-existingvue/one-component-per-filefalse positives oncreateAppin the v1 test)pnpm buildclean from a wipeddist/, and thedist-bundlingregression test still passes — the addednode:streamimport does not reintroduce the downstream RollupMISSING_EXPORTproblem from fix: read version-specific h3 exports via runtime key #18New tests: brotli round-trip through the stream on both paths, the default-off fallback, the opt-in pick, forced
'br'without the flag, gzip fallback with brotli enabled but not accepted, and the chunked-output assertion.