Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

✔️  **Zlib Compression:** You can use zlib compression (brotli, gzip and deflate)

✔️  **Stream Compression:** You can use native stream compressions (gzip, deflate)
✔️  **Stream Compression:** You can use stream compressions (gzip, deflate and opt-in brotli)

✔️  **Compression Detection:** It uses the best compression which is accepted

Expand Down Expand Up @@ -87,9 +87,34 @@ createServer(toNodeHandler(app)).listen(process.env.PORT || 3000)
You can also force a specific method (e.g. `compression('gzip')`) instead of detecting it from the
`Accept-Encoding` header.

## Brotli and stream compression

The native [`CompressionStream`](https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream)
implements the WHATWG `CompressionFormat` enum, which only defines `gzip`, `deflate` and
`deflate-raw` — there is no brotli format. This package therefore streams brotli through
`node:zlib` instead, so it is available wherever `node:zlib` is (Node, and runtimes with node
compatibility), but not on pure edge runtimes.

Because brotli is noticeably more CPU-expensive per request than gzip, it is **never picked
automatically**. Turn it on with the `brotli` flag, or force it as the method:

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

The same flag works for the composable:

```ts
await useCompressionStream(event, response, { brotli: true })
// or explicitly
await useBrotliCompressionStream(event, response)
```

> [!NOTE]
> `compressionStream` uses the native [`CompressionStream`](https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream),
> which only supports `gzip` and `deflate` (no brotli).
> The brotli stream is flushed per chunk (`BROTLI_OPERATION_FLUSH`) so that streamed responses
> stay streamed. With zlib's defaults brotli buffers the whole body until the source closes.

## Nuxt 3 & 4

Expand Down Expand Up @@ -156,14 +181,15 @@ H3-compression has a concept of composable utilities that accept `event` (from `

- `useGZipCompressionStream(event, response)`
- `useDeflateCompressionStream(event, response)`
- `useCompressionStream(event, response)`
- `useBrotliCompressionStream(event, response)`
- `useCompressionStream(event, response, options?)`  – pass `{ brotli: true }` to include brotli

#### Middleware (h3 v2)

- `compression(method?)`  – middleware using zlib (brotli, gzip, deflate)
- `compressionStream(method?)`  – middleware using the native `CompressionStream` (gzip, deflate)
- `compressionStream(method | options?)`  – stream middleware (gzip, deflate, opt-in brotli)
- `compressResponse(event, value, method?)`  – low-level helper returning a compressed `Response`
- `compressResponseStream(event, value, method?)`  – low-level stream helper returning a compressed `Response`
- `compressResponseStream(event, value, method?, options?)`  – low-level stream helper returning a compressed `Response`

## Sponsors

Expand Down
26 changes: 22 additions & 4 deletions src/compressionStream.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { H3Event } from 'h3'
import type { RenderResponse } from './helper'
import { compressStream, getAnyCompression } from './helper'
import type { RenderResponse, StreamCompressionOptions } from './helper'
import { compressStream, getStreamCompression } from './helper'

/**
* Compresses the response with
Expand Down Expand Up @@ -28,19 +28,37 @@ export async function useDeflateCompressionStream(
await compressStream(event, response, 'deflate')
}

/**
* Compresses the response with [zlib.createBrotliCompress]{@link https://nodejs.org/api/zlib.html}
* piped as a stream. The native `CompressionStream` has no brotli format, so
* this one is backed by `node:zlib` and is not available on non-node runtimes.
* @param event - A H3 event object.
* @param response - A response object with body parameter.
*/
export async function useBrotliCompressionStream(
event: H3Event,
response: Partial<RenderResponse>,
) {
await compressStream(event, response, 'br')
}

/**
* Compresses the response with
* [CompressionStream]{@link https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream}
* by 'Accept-Encoding' header. Best is used first.
*
* Brotli is only picked when enabled via `options.brotli`.
* @param event - A H3 event object.
* @param response - A response object with body parameter.
* @param options - Opt into brotli detection with `{ brotli: true }`.
*/
export async function useCompressionStream(
event: H3Event,
response: Partial<RenderResponse>,
options: StreamCompressionOptions = {},
) {
const compression = getAnyCompression(event)
const compression = getStreamCompression(event, options)

if (compression && compression !== 'br')
if (compression)
await compressStream(event, response, compression)
}
61 changes: 53 additions & 8 deletions src/helper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { promisify } from 'node:util'
import zlib from 'node:zlib'
import { Buffer } from 'node:buffer'
import { Duplex } from 'node:stream'
import type { H3Event } from 'h3'
import * as h3 from 'h3'

Expand All @@ -14,7 +15,17 @@ export interface RenderResponse {
}

export type Compression = 'gzip' | 'deflate' | 'br'
export type StreamCompression = 'gzip' | 'deflate'
export type StreamCompression = 'gzip' | 'deflate' | 'br'

export interface StreamCompressionOptions {
/**
* Consider brotli when picking a compression from the `Accept-Encoding`
* header. Off by default because brotli is noticeably more CPU-expensive
* per request than gzip. Forcing `'br'` explicitly works without this flag.
* @default false
*/
brotli?: boolean
}

/**
* `send` (h3 v1) and `toResponse` (h3 v2) each only exist in a single major
Expand Down Expand Up @@ -51,13 +62,21 @@ export function getAnyCompression(event: H3Event): Compression | undefined {
}

/**
* Returns the best stream compression accepted by the client. The native
* `CompressionStream` only supports gzip and deflate, so brotli is ignored.
* Returns the best stream compression accepted by the client. Brotli is only
* considered when it is enabled via `options.brotli`, otherwise gzip is
* preferred, then deflate.
* @param { H3Event } event - A H3 event object.
* @param { StreamCompressionOptions } [options] - Opt into brotli detection.
* @returns { StreamCompression | undefined }
*/
export function getStreamCompression(event: H3Event): StreamCompression | undefined {
export function getStreamCompression(
event: H3Event,
options: StreamCompressionOptions = {},
): StreamCompression | undefined {
const encoding = getRequestHeader(event, 'accept-encoding')
if (options.brotli && encoding?.includes('br'))
return 'br'

if (encoding?.includes('gzip'))
return 'gzip'

Expand All @@ -67,6 +86,26 @@ export function getStreamCompression(event: H3Event): StreamCompression | undefi
return undefined
}

/**
* Creates the transform used to compress a response body stream.
*
* The native `CompressionStream` implements the WHATWG `CompressionFormat`
* enum, which only defines gzip, deflate and deflate-raw — brotli is therefore
* streamed through `node:zlib` instead. `BROTLI_OPERATION_FLUSH` is required
* to keep the output chunked; with zlib's defaults brotli buffers the whole
* body until the source closes, which defeats the point of a stream.
* @param { StreamCompression } method - The compression to apply.
* @returns { ReadableWritablePair<Uint8Array, Uint8Array> }
*/
function createCompressionTransform(method: StreamCompression): ReadableWritablePair<Uint8Array, Uint8Array> {
if (method !== 'br')
return new CompressionStream(method)

return Duplex.toWeb(zlib.createBrotliCompress({
flush: zlib.constants.BROTLI_OPERATION_FLUSH,
})) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>
}

function isReadableStream(value: unknown): boolean {
return typeof value === 'object' && value !== null
&& typeof (value as ReadableStream).getReader === 'function'
Expand Down Expand Up @@ -133,7 +172,7 @@ export async function compressStream(event: H3Event, response: Partial<RenderRes

if (acceptsEncoding) {
setResponseHeader(event, 'Content-Encoding', method)
response.body = stream.pipeThrough(new CompressionStream(method))
response.body = stream.pipeThrough(createCompressionTransform(method))
}
else {
response.body = stream
Expand Down Expand Up @@ -194,14 +233,20 @@ export async function compressResponse(event: H3Event, value: unknown, method?:
* @param { H3Event } event - A H3 event object.
* @param { unknown } value - The value returned by the next handler.
* @param { StreamCompression } [method] - Force a specific compression method.
* @param { StreamCompressionOptions } [options] - Opt into brotli detection.
* @returns { Promise<Response> }
*/
export async function compressResponseStream(event: H3Event, value: unknown, method?: StreamCompression): Promise<Response> {
export async function compressResponseStream(
event: H3Event,
value: unknown,
method?: StreamCompression,
options?: StreamCompressionOptions,
): Promise<Response> {
const response = await ensureToResponse()(value, event)
const compressionMethod = method ?? getStreamCompression(event)
const compressionMethod = method ?? getStreamCompression(event, options)

if (!compressionMethod || !response.body || response.headers.has('Content-Encoding'))
return response

return cloneResponse(response, response.body.pipeThrough(new CompressionStream(compressionMethod)), compressionMethod)
return cloneResponse(response, response.body.pipeThrough(createCompressionTransform(compressionMethod)), compressionMethod)
}
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
export {
useGZipCompressionStream,
useDeflateCompressionStream,
useBrotliCompressionStream,
useCompressionStream,
} from './compressionStream'

Expand All @@ -26,7 +27,8 @@ export {
export type {
Compression,
StreamCompression,
StreamCompressionOptions,
RenderResponse,
} from './helper'

export type { CompressionMiddleware } from './middleware'
export type { CompressionMiddleware, CompressionStreamOptions } from './middleware'
30 changes: 25 additions & 5 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { H3Event } from 'h3'
import type { Compression, StreamCompression } from './helper'
import type { Compression, StreamCompression, StreamCompressionOptions } from './helper'
import { compressResponse, compressResponseStream } from './helper'

type Next = () => unknown | Promise<unknown>
Expand All @@ -9,6 +9,17 @@ type Next = () => unknown | Promise<unknown>
*/
export type CompressionMiddleware = (event: H3Event, next: Next) => Promise<Response>

/**
* Configuration for the {@link compressionStream} middleware.
*/
export interface CompressionStreamOptions extends StreamCompressionOptions {
/**
* Force a specific compression method instead of detecting it from the
* `Accept-Encoding` header. A forced `'br'` does not need `brotli: true`.
*/
method?: StreamCompression
}

/**
* Creates a [h3 v2 middleware]{@link https://h3.dev/guide/basics/middleware} that
* compresses the response with [Zlib]{@link https://nodejs.org/api/zlib.html}
Expand Down Expand Up @@ -36,18 +47,27 @@ export function compression(method?: Compression): CompressionMiddleware {
* [CompressionStream]{@link https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream}
* based on the `Accept-Encoding` header. Best is used first.
*
* Brotli is streamed through `node:zlib` and is opt-in — it is more
* CPU-expensive per request, so it is never picked from `Accept-Encoding`
* unless `brotli: true` is set.
*
* @example
* ```ts
* import { H3 } from 'h3'
* import { compressionStream } from 'h3-compression'
*
* const app = new H3()
* app.use(compressionStream())
*
* app.use(compressionStream()) // gzip / deflate
* app.use(compressionStream({ brotli: true })) // brotli, gzip, deflate
* app.use(compressionStream('br')) // always brotli
* ```
*
* @param { StreamCompression } [method] - Force a specific compression method instead of detecting it.
* @param { StreamCompression | CompressionStreamOptions } [options] - A forced compression method or a config object.
* @returns { CompressionMiddleware }
*/
export function compressionStream(method?: StreamCompression): CompressionMiddleware {
return (event, next) => compressResponseStream(event, next(), method)
export function compressionStream(options?: StreamCompression | CompressionStreamOptions): CompressionMiddleware {
const config: CompressionStreamOptions = typeof options === 'string' ? { method: options } : { ...options }

return (event, next) => compressResponseStream(event, next(), config.method, config)
}
71 changes: 71 additions & 0 deletions test/compression-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import zlib from 'node:zlib'
import { describe, expect, it } from 'vitest'
import * as h3 from 'h3'
import {
useBrotliCompressionStream,
useCompressionStream,
useDeflateCompressionStream,
useGZipCompressionStream,
Expand Down Expand Up @@ -52,6 +53,16 @@ describe.runIf(isV2)('useCompressionStream (mutable response / nitro path)', ()
expect(zlib.inflateSync(await readStream(response.body as unknown as ReadableStream)).toString()).toEqual(html)
})

it('compresses the body stream with brotli', async () => {
const event = eventFor('br')
const response = { body: html }

await useBrotliCompressionStream(event, response)

expect(event.res.headers.get('content-encoding')).toEqual('br')
expect(zlib.brotliDecompressSync(await readStream(response.body as unknown as ReadableStream)).toString()).toEqual(html)
})

it('picks the best stream compression accepted', async () => {
const event = eventFor('gzip, deflate')
const response = { body: html }
Expand All @@ -61,4 +72,64 @@ describe.runIf(isV2)('useCompressionStream (mutable response / nitro path)', ()
expect(event.res.headers.get('content-encoding')).toEqual('gzip')
expect(zlib.gunzipSync(await readStream(response.body as unknown as ReadableStream)).toString()).toEqual(html)
})

it('ignores brotli by default and falls back to gzip (#19)', async () => {
const event = eventFor('br, gzip, deflate')
const response = { body: html }

await useCompressionStream(event, response)

expect(event.res.headers.get('content-encoding')).toEqual('gzip')
expect(zlib.gunzipSync(await readStream(response.body as unknown as ReadableStream)).toString()).toEqual(html)
})

it('picks brotli when it is enabled via options (#19)', async () => {
const event = eventFor('br, gzip, deflate')
const response = { body: html }

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

expect(event.res.headers.get('content-encoding')).toEqual('br')
expect(zlib.brotliDecompressSync(await readStream(response.body as unknown as ReadableStream)).toString()).toEqual(html)
})

it('still falls back to gzip with brotli enabled but not accepted (#19)', async () => {
const event = eventFor('gzip')
const response = { body: html }

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

expect(event.res.headers.get('content-encoding')).toEqual('gzip')
expect(zlib.gunzipSync(await readStream(response.body as unknown as ReadableStream)).toString()).toEqual(html)
})

it('keeps the brotli stream chunked instead of buffering the whole body (#19)', async () => {
const event = eventFor('br')
// A source that yields several chunks over time — with zlib's default
// flush mode brotli would emit a single chunk only once it closed.
const source = new ReadableStream({
async pull(controller) {
for (let i = 0; i < 3; i++) {
controller.enqueue(new TextEncoder().encode(`chunk-${i}-${'x'.repeat(64)}`))
await new Promise(resolve => setTimeout(resolve, 10))
}
controller.close()
},
})
const response = { body: source }

await useBrotliCompressionStream(event, response as any)

const chunks: Buffer[] = []
const reader = (response.body as unknown as ReadableStream).getReader()
while (true) {
const { done, value } = await reader.read()
if (done)
break
chunks.push(Buffer.from(value))
}

expect(chunks.length).toBeGreaterThan(1)
expect(zlib.brotliDecompressSync(Buffer.concat(chunks)).toString()).toContain('chunk-2')
})
})
Loading
Loading