Skip to content

feat: add opt-in brotli support to the stream compression path - #20

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

feat: add opt-in brotli support to the stream compression path#20
CodeDredd merged 1 commit into
mainfrom
feat/brotli-compression-stream

Conversation

@CodeDredd

Copy link
Copy Markdown
Owner

Closes #19

What

Adds brotli to the stream compression path — opt-in, never picked automatically.

app.use(compressionStream())                 // gzip / deflate — unchanged default
app.use(compressionStream({ brotli: true })) // brotli, then gzip, then deflate
app.use(compressionStream('br'))             // always brotli

await useCompressionStream(event, response, { brotli: true })
await useBrotliCompressionStream(event, response)

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 on Accept-Encoding: br, gzip.

How

The native CompressionStream implements the WHATWG CompressionFormat enum — gzip, deflate, deflate-raw — and rejects 'br'. Brotli is therefore streamed through node:zlib, wrapped into a web ReadableWritablePair with Duplex.toWeb(), behind a new createCompressionTransform() seam that both stream call sites (compressStream for the h3 v1 / nitro hook path and compressResponseStream for the v2 middleware) now go through.

BROTLI_OPERATION_FLUSH is 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:

variant emitted output chunks
CompressionStream('gzip') 2
brotli, zlib defaults 1
brotli, BROTLI_OPERATION_FLUSH 6

There is a test asserting the stream stays chunked so this can't silently regress.

No new runtime constraint: src/helper.ts already imported node:zlib statically, so the stream path was never runtime-agnostic. The README now says so explicitly instead of claiming brotli is impossible.

Drive-by fix

useCompressionStream used getAnyCompression and then bailed out on if (compression !== 'br'). For a client sending Accept-Encoding: br, gzip that meant no compression at all — not a gzip fallback. It now uses getStreamCompression, which falls back to gzip. Covered by a test on both the v1 and v2 path.

API

StreamCompression widened to 'gzip' | 'deflate' | 'br'
StreamCompressionOptions new — { brotli?: boolean }
CompressionStreamOptions new — the above plus method?
compressionStream(method | options?) string argument still works
compressResponseStream(event, value, method?, options?) new optional 4th argument
useCompressionStream(event, response, options?) new optional 3rd argument
useBrotliCompressionStream(event, response) new export

All additive — no breaking changes.

Verification

  • pnpm test green on h3 2.0.1-rc.22 (26 passed) and on h3 1.8.1 (10 passed), matching the CI matrix legs
  • pnpm lint clean (the 2 remaining warnings are the pre-existing vue/one-component-per-file false positives on createApp in the v1 test)
  • pnpm build clean from a wiped dist/, and the dist-bundling regression test still passes — the added node:stream import does not reintroduce the downstream Rollup MISSING_EXPORT problem from fix: read version-specific h3 exports via runtime key #18

New 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.

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
CodeDredd merged commit b23a0e2 into main Aug 6, 2026
9 checks passed
@CodeDredd
CodeDredd deleted the feat/brotli-compression-stream branch August 6, 2026 07:55
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
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 brotli support to the stream compression path (opt-in)

1 participant