diff --git a/README.md b/README.md index 49d9054..1014bf7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/src/compressionStream.ts b/src/compressionStream.ts index ac84a19..d8cbead 100644 --- a/src/compressionStream.ts +++ b/src/compressionStream.ts @@ -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 @@ -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, +) { + 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, + options: StreamCompressionOptions = {}, ) { - const compression = getAnyCompression(event) + const compression = getStreamCompression(event, options) - if (compression && compression !== 'br') + if (compression) await compressStream(event, response, compression) } diff --git a/src/helper.ts b/src/helper.ts index 3831849..06329b9 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -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' @@ -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 @@ -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' @@ -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 } + */ +function createCompressionTransform(method: StreamCompression): ReadableWritablePair { + if (method !== 'br') + return new CompressionStream(method) + + return Duplex.toWeb(zlib.createBrotliCompress({ + flush: zlib.constants.BROTLI_OPERATION_FLUSH, + })) as unknown as ReadableWritablePair +} + function isReadableStream(value: unknown): boolean { return typeof value === 'object' && value !== null && typeof (value as ReadableStream).getReader === 'function' @@ -133,7 +172,7 @@ export async function compressStream(event: H3Event, response: Partial } */ -export async function compressResponseStream(event: H3Event, value: unknown, method?: StreamCompression): Promise { +export async function compressResponseStream( + event: H3Event, + value: unknown, + method?: StreamCompression, + options?: StreamCompressionOptions, +): Promise { 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) } diff --git a/src/index.ts b/src/index.ts index 6d8c795..edc68a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { export { useGZipCompressionStream, useDeflateCompressionStream, + useBrotliCompressionStream, useCompressionStream, } from './compressionStream' @@ -26,7 +27,8 @@ export { export type { Compression, StreamCompression, + StreamCompressionOptions, RenderResponse, } from './helper' -export type { CompressionMiddleware } from './middleware' +export type { CompressionMiddleware, CompressionStreamOptions } from './middleware' diff --git a/src/middleware.ts b/src/middleware.ts index 8eff9cf..af8bb84 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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 @@ -9,6 +9,17 @@ type Next = () => unknown | Promise */ export type CompressionMiddleware = (event: H3Event, next: Next) => Promise +/** + * 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} @@ -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) } diff --git a/test/compression-stream.test.ts b/test/compression-stream.test.ts index 74d8136..e3c02bf 100644 --- a/test/compression-stream.test.ts +++ b/test/compression-stream.test.ts @@ -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, @@ -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 } @@ -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') + }) }) diff --git a/test/compression-v1.test.ts b/test/compression-v1.test.ts index d8010ff..046a59f 100644 --- a/test/compression-v1.test.ts +++ b/test/compression-v1.test.ts @@ -114,4 +114,27 @@ describe.runIf(isV1)('useCompressionStream (h3 v1 app hook)', () => { expect(result.headers['content-encoding']).toEqual('deflate') expect(result.text).toEqual(html) }) + + it('ignores brotli by default and falls back to gzip (#19)', async () => { + const result = await request.get('/').set('Accept-Encoding', 'br, gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.text).toEqual(html) + }) + + it('picks brotli when enabled via options (#19)', async () => { + const brotliRequest = appWith((event, response) => + useCompressionStream(event, response, { brotli: true }), + ) + const result = await brotliRequest + .get('/') + .set('Accept-Encoding', 'br, gzip') + .buffer(true) + .parse(rawParser) + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('br') + expect(zlib.brotliDecompressSync(result.body).toString()).toEqual(html) + }) }) diff --git a/test/middleware.test.ts b/test/middleware.test.ts index a4cd7bb..f0124e0 100644 --- a/test/middleware.test.ts +++ b/test/middleware.test.ts @@ -1,3 +1,5 @@ +import { Buffer } from 'node:buffer' +import zlib from 'node:zlib' import type { SuperTest, Test } from 'supertest' import supertest from 'supertest' import { beforeEach, describe, expect, it } from 'vitest' @@ -5,6 +7,13 @@ import * as h3 from 'h3' import { compression, compressionStream } from '../src' import { isV2 } from './_version' +// superagent does not auto-decode brotli, so read the raw bytes ourselves. +function rawParser(res: any, cb: (err: Error | null, body: Buffer) => void) { + const chunks: Buffer[] = [] + res.on('data', (c: Buffer) => chunks.push(c)) + res.on('end', () => cb(null, Buffer.concat(chunks))) +} + // `H3` and `toNodeHandler` only exist in h3 v2 — access them lazily so this // file still loads (but is skipped) under h3 v1. const { H3, toNodeHandler } = h3 as typeof import('h3') @@ -80,11 +89,60 @@ describe.runIf(isV2)('compressionStream middleware (h3 v2)', () => { expect(result.text).toEqual(html) }) - it('falls back to gzip when brotli is the only listed but unsupported by streams', async () => { + it('does not pick brotli unless it is enabled (#19)', async () => { const result = await request.get('/').set('Accept-Encoding', 'br, gzip') expect(result.status).toEqual(200) expect(result.headers['content-encoding']).toEqual('gzip') expect(result.text).toEqual(html) }) + + it('does not compress when no supported encoding is accepted', async () => { + const result = await request.get('/').set('Accept-Encoding', 'identity') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toBeUndefined() + expect(result.text).toEqual(html) + }) +}) + +describe.runIf(isV2)('compressionStream middleware with brotli (h3 v2, #19)', () => { + function appWith(options: Parameters[0]) { + const app = new H3() + app.use(compressionStream(options)) + app.get('/', () => html) + return supertest(toNodeHandler(app)) + } + + it('picks brotli when enabled via options', async () => { + const result = await appWith({ brotli: true }) + .get('/') + .set('Accept-Encoding', 'br, gzip') + .buffer() + .parse(rawParser) + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('br') + expect(zlib.brotliDecompressSync(result.body).toString()).toEqual(html) + }) + + it('forces brotli without the flag when passed as a method', async () => { + const result = await appWith('br') + .get('/') + .set('Accept-Encoding', 'br') + .buffer() + .parse(rawParser) + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('br') + expect(zlib.brotliDecompressSync(result.body).toString()).toEqual(html) + }) + + it('falls back to gzip when brotli is enabled but not accepted', async () => { + const result = await appWith({ brotli: true }).get('/').set('Accept-Encoding', 'gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.text).toEqual(html) + }) })