From 70dedf6229b309260670f7d758e6850d20fdab93 Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Thu, 6 Aug 2026 10:03:42 +0200 Subject: [PATCH] feat: add opt-in zstd content-encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 2 +- README.md | 47 ++++++-- package.json | 8 +- pnpm-lock.yaml | 88 +++++++++++---- src/compression.ts | 22 +++- src/compressionStream.ts | 19 +++- src/helper.ts | 121 ++++++++++++++++---- src/index.ts | 10 +- src/middleware.ts | 38 +++++-- test/compression-v1.test.ts | 41 ++++++- test/zstd.test.ts | 207 ++++++++++++++++++++++++++++++++++ 12 files changed, 541 insertions(+), 69 deletions(-) create mode 100644 test/zstd.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 524725d..c52b45f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Set node uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 20.x - name: Setup run: npm i -g @antfu/ni @@ -37,7 +37,10 @@ jobs: strategy: matrix: - node: [18, 20] + # 20 is the floor (matches h3's `engines`) and has no zstd in + # `node:zlib`; 22 and 24 do (added in 22.15 / 23.8), so both the zstd + # path and its fallback get exercised. + node: [20, 22, 24] h3: [1, 2] os: [ubuntu-latest, macos-latest] fail-fast: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d7e827..58b5306 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: - name: Set node uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 20.x - run: npx changelogithub env: diff --git a/README.md b/README.md index 1014bf7..3d33a63 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ ## Features -✔️  **Zlib Compression:** You can use zlib compression (brotli, gzip and deflate) +✔️  **Zlib Compression:** You can use zlib compression (brotli, gzip, deflate and opt-in zstd) -✔️  **Stream Compression:** You can use stream compressions (gzip, deflate and opt-in brotli) +✔️  **Stream Compression:** You can use stream compressions (gzip, deflate and opt-in brotli / zstd) ✔️  **Compression Detection:** It uses the best compression which is accepted @@ -116,6 +116,36 @@ await useBrotliCompressionStream(event, response) > 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. +## Zstd + +Zstd is supported on both paths, and is opt-in for a different reason than brotli: `node:zlib` +only gained zstd in **Node 22.15.0** (and 23.8.0). Enabling it by default would make the +negotiated `Content-Encoding` depend on the Node version the app happens to run on, which is a +poor thing to discover in production. The package itself only requires Node >= 20.11.1, the same +floor as h3. + +```ts +app.use(compression({ zstd: true })) // zstd, then brotli, then gzip, then deflate +app.use(compressionStream({ zstd: true, brotli: true })) // same order, streamed + +await useCompression(event, response, { zstd: true }) +``` + +Behaviour on a runtime without zstd: + +- with the `zstd: true` **flag**, zstd is skipped during negotiation and the next accepted + encoding is used — no error, no special-casing needed in your code +- when **forced** (`compression('zstd')`, `useZstdCompression`), a `TypeError` naming the + required Node version is thrown, because silently sending something else would be worse + +Branch on it yourself with the exported predicate: + +```ts +import { isZstdSupported } from 'h3-compression' + +app.use(compression({ zstd: isZstdSupported() })) +``` + ## Nuxt 3 & 4 If you want to use it in Nuxt you can define a nitro plugin. @@ -175,21 +205,24 @@ H3-compression has a concept of composable utilities that accept `event` (from ` - `useGZipCompression(event, response)` - `useDeflateCompression(event, response)` - `useBrotliCompression(event, response)` -- `useCompression(event, response)` +- `useZstdCompression(event, response)`  – requires Node >= 22.15 +- `useCompression(event, response, options?)`  – pass `{ zstd: true }` to include zstd #### Stream Compression - `useGZipCompressionStream(event, response)` - `useDeflateCompressionStream(event, response)` - `useBrotliCompressionStream(event, response)` -- `useCompressionStream(event, response, options?)`  – pass `{ brotli: true }` to include brotli +- `useZstdCompressionStream(event, response)`  – requires Node >= 22.15 +- `useCompressionStream(event, response, options?)`  – pass `{ brotli: true }` / `{ zstd: true }` #### Middleware (h3 v2) -- `compression(method?)`  – middleware using zlib (brotli, gzip, deflate) -- `compressionStream(method | options?)`  – stream middleware (gzip, deflate, opt-in brotli) -- `compressResponse(event, value, method?)`  – low-level helper returning a compressed `Response` +- `compression(method | options?)`  – middleware using zlib (brotli, gzip, deflate, opt-in zstd) +- `compressionStream(method | options?)`  – stream middleware (gzip, deflate, opt-in brotli / zstd) +- `compressResponse(event, value, method?, options?)`  – low-level helper returning a compressed `Response` - `compressResponseStream(event, value, method?, options?)`  – low-level stream helper returning a compressed `Response` +- `isZstdSupported()`  – whether the runtime can compress with zstd ## Sponsors diff --git a/package.json b/package.json index e4060ab..0ae1055 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "module", "version": "1.0.1", "packageManager": "pnpm@8.7.0", - "description": "Adds compression to h3 request (brotli, gzip, deflate)", + "description": "Adds compression to h3 request (brotli, gzip, deflate, zstd)", "author": { "name": "Gregor Becker", "email": "gregor@codedredd.de" @@ -22,6 +22,7 @@ "gzip", "brotli", "deflate", + "zstd", "compression" ], "sideEffects": false, @@ -48,6 +49,9 @@ "LICENSE", "README.md" ], + "engines": { + "node": ">=20.11.1" + }, "scripts": { "build": "unbuild", "dev": "unbuild --stub", @@ -66,7 +70,7 @@ "@antfu/eslint-config": "^0.41.0", "@antfu/ni": "^0.21.6", "@antfu/utils": "^0.7.6", - "@types/node": "^20.5.7", + "@types/node": "^22.20.1", "@types/supertest": "^2.0.12", "@vitest/coverage-v8": "^0.34.3", "bumpp": "^9.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed23865..ef52be4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^0.7.6 version: 0.7.6 '@types/node': - specifier: ^20.5.7 - version: 20.5.7 + specifier: ^22.20.1 + version: 22.20.1 '@types/supertest': specifier: ^2.0.12 version: 2.0.12 @@ -67,7 +67,7 @@ importers: version: 2.0.0(typescript@5.2.2) vite: specifier: ^4.4.9 - version: 4.4.9(@types/node@20.5.7) + version: 4.4.9(@types/node@22.20.1) vitest: specifier: ^0.34.3 version: 0.34.3 @@ -79,7 +79,7 @@ importers: version: 0.8.2(nuxt@3.7.0)(rollup@3.28.1)(vite@4.4.9) nuxt: specifier: ^3.7.0 - version: 3.7.0(@types/node@20.5.7)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2) + version: 3.7.0(@types/node@22.20.1)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2) packages: @@ -1424,8 +1424,8 @@ packages: '@nuxt/kit': 3.7.0(rollup@3.28.1) '@nuxt/schema': 3.7.0(rollup@3.28.1) execa: 7.2.0 - nuxt: 3.7.0(@types/node@20.5.7)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2) - vite: 4.4.9(@types/node@20.5.7) + nuxt: 3.7.0(@types/node@22.20.1)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2) + vite: 4.4.9(@types/node@22.20.1) transitivePeerDependencies: - rollup - supports-color @@ -1476,7 +1476,7 @@ packages: launch-editor: 2.6.0 local-pkg: 0.4.3 magicast: 0.2.10 - nuxt: 3.7.0(@types/node@20.5.7)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2) + nuxt: 3.7.0(@types/node@22.20.1)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2) nypm: 0.3.1 ofetch: 1.3.3 ohash: 1.1.3 @@ -1490,7 +1490,7 @@ packages: simple-git: 3.19.1 sirv: 2.0.3 unimport: 3.2.0(rollup@3.28.1) - vite: 4.4.9(@types/node@20.5.7) + vite: 4.4.9(@types/node@22.20.1) vite-plugin-inspect: 0.7.38(@nuxt/kit@3.7.0)(rollup@3.28.1)(vite@4.4.9) vite-plugin-vue-inspector: 3.6.0(vite@4.4.9) wait-on: 7.0.1 @@ -1584,7 +1584,7 @@ packages: resolution: {integrity: sha512-5gc02Pu1HycOVUWJ8aYsWeeXcSTPe8iX8+KIrhyEtEoOSkY0eMBuo0ssljB8wALuEmepv31DlYe5gpiRwkjESA==} dev: true - /@nuxt/vite-builder@3.7.0(@types/node@20.5.7)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2)(vue@3.3.4): + /@nuxt/vite-builder@3.7.0(@types/node@22.20.1)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2)(vue@3.3.4): resolution: {integrity: sha512-bRJy3KarHrFm/xLGHoHeZyqI/h6c4UFRCF5ngRZ/R9uebJEHuL4UhAioxDLTFu7D0vEeK7XaDgx6+NPLhBg51g==} engines: {node: ^14.18.0 || >=16.10.0} peerDependencies: @@ -1621,8 +1621,8 @@ packages: strip-literal: 1.3.0 ufo: 1.3.0 unplugin: 1.4.0 - vite: 4.4.9(@types/node@20.5.7) - vite-node: 0.33.0(@types/node@20.5.7) + vite: 4.4.9(@types/node@22.20.1) + vite-node: 0.33.0(@types/node@22.20.1) vite-plugin-checker: 0.6.2(eslint@8.48.0)(typescript@5.2.2)(vite@4.4.9) vue: 3.3.4 vue-bundle-renderer: 2.0.0 @@ -2054,6 +2054,12 @@ packages: resolution: {integrity: sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==} dev: true + /@types/node@22.20.1: + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + dependencies: + undici-types: 6.21.0 + dev: true + /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -2346,7 +2352,7 @@ packages: '@babel/core': 7.22.11 '@babel/plugin-transform-typescript': 7.22.11(@babel/core@7.22.11) '@vue/babel-plugin-jsx': 1.1.5(@babel/core@7.22.11) - vite: 4.4.9(@types/node@20.5.7) + vite: 4.4.9(@types/node@22.20.1) vue: 3.3.4 transitivePeerDependencies: - supports-color @@ -2359,7 +2365,7 @@ packages: vite: ^4.0.0 vue: ^3.2.25 dependencies: - vite: 4.4.9(@types/node@20.5.7) + vite: 4.4.9(@types/node@22.20.1) vue: 3.3.4 dev: true @@ -6454,7 +6460,7 @@ packages: fsevents: 2.3.3 dev: true - /nuxt@3.7.0(@types/node@20.5.7)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2): + /nuxt@3.7.0(@types/node@22.20.1)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2): resolution: {integrity: sha512-y0/xHYqwuJt20r26xezjpr74FLWR144dMpwSxZ/O2XXUrQUnyO7vHm3fEY4vi+miKbf343YMH5B78GXAELO/Vw==} engines: {node: ^14.18.0 || >=16.10.0} hasBin: true @@ -6472,8 +6478,8 @@ packages: '@nuxt/schema': 3.7.0(rollup@3.28.1) '@nuxt/telemetry': 2.4.1(rollup@3.28.1) '@nuxt/ui-templates': 1.3.1 - '@nuxt/vite-builder': 3.7.0(@types/node@20.5.7)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2)(vue@3.3.4) - '@types/node': 20.5.7 + '@nuxt/vite-builder': 3.7.0(@types/node@22.20.1)(eslint@8.48.0)(rollup@3.28.1)(typescript@5.2.2)(vue@3.3.4) + '@types/node': 22.20.1 '@unhead/dom': 1.3.9 '@unhead/ssr': 1.3.9 '@unhead/vue': 1.3.9(vue@3.3.4) @@ -8363,6 +8369,10 @@ packages: unplugin: 1.4.0 dev: true + /undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + dev: true + /undici@5.23.0: resolution: {integrity: sha512-1D7w+fvRsqlQ9GscLBwcAJinqcZGHUKjbOmXdlE/v8BvEGXjeWAax+341q44EuTcHXXnfyKNbKRq4Lg7OzhMmg==} engines: {node: '>=14.0'} @@ -8612,7 +8622,7 @@ packages: builtins: 5.0.1 dev: true - /vite-node@0.33.0(@types/node@20.5.7): + /vite-node@0.33.0(@types/node@22.20.1): resolution: {integrity: sha512-19FpHYbwWWxDr73ruNahC+vtEdza52kA90Qb3La98yZ0xULqV8A5JLNPUff0f5zID4984tW7l3DH2przTJUZSw==} engines: {node: '>=v14.18.0'} hasBin: true @@ -8622,7 +8632,7 @@ packages: mlly: 1.4.1 pathe: 1.1.1 picocolors: 1.0.0 - vite: 4.4.9(@types/node@20.5.7) + vite: 4.4.9(@types/node@22.20.1) transitivePeerDependencies: - '@types/node' - less @@ -8702,7 +8712,7 @@ packages: strip-ansi: 6.0.1 tiny-invariant: 1.3.1 typescript: 5.2.2 - vite: 4.4.9(@types/node@20.5.7) + vite: 4.4.9(@types/node@22.20.1) vscode-languageclient: 7.0.0 vscode-languageserver: 7.0.0 vscode-languageserver-textdocument: 1.0.8 @@ -8728,7 +8738,7 @@ packages: open: 9.1.0 picocolors: 1.0.0 sirv: 2.0.3 - vite: 4.4.9(@types/node@20.5.7) + vite: 4.4.9(@types/node@22.20.1) transitivePeerDependencies: - rollup - supports-color @@ -8748,7 +8758,7 @@ packages: kolorist: 1.8.0 magic-string: 0.30.3 shell-quote: 1.8.1 - vite: 4.4.9(@types/node@20.5.7) + vite: 4.4.9(@types/node@22.20.1) transitivePeerDependencies: - supports-color dev: true @@ -8789,6 +8799,42 @@ packages: fsevents: 2.3.3 dev: true + /vite@4.4.9(@types/node@22.20.1): + resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 22.20.1 + esbuild: 0.18.17 + postcss: 8.4.27 + rollup: 3.28.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /vitest@0.34.3: resolution: {integrity: sha512-7+VA5Iw4S3USYk+qwPxHl8plCMhA5rtfwMjgoQXMT7rO5ldWcdsdo3U1QD289JgglGK4WeOzgoLTsGFu6VISyQ==} engines: {node: '>=v14.18.0'} diff --git a/src/compression.ts b/src/compression.ts index cd4f5fa..441360c 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -1,5 +1,5 @@ import type { H3Event } from 'h3' -import type { RenderResponse } from './helper' +import type { CompressionOptions, RenderResponse } from './helper' import { compress, getAnyCompression } from './helper' /** @@ -41,18 +41,36 @@ export async function useBrotliCompression( await compress(event, response, 'br') } +/** + * Compresses the response with [zlib.zstdCompress]{@link https://nodejs.org/api/zlib.html}. + * Requires Node >= 22.15.0 — throws a `TypeError` on older runtimes. + * @param { H3Event } event - A H3 event object. + * @param { RenderResponse } response - A response object with body parameter. + * @returns { Promise } + */ +export async function useZstdCompression( + event: H3Event, + response: Partial, +): Promise { + await compress(event, response, 'zstd') +} + /** * Compresses the response with [Zlib]{@link https://www.w3schools.com/nodejs/ref_zlib.asp} * by 'Accept-Encoding' header. Best is used first. + * + * Zstd is only picked when enabled via `options.zstd` and supported by the runtime. * @param { H3Event } event - A H3 event object. * @param { RenderResponse } response - A response object with body parameter. + * @param { CompressionOptions } options - Opt into zstd detection with `{ zstd: true }`. * @returns { Promise } */ export async function useCompression( event: H3Event, response: Partial, + options: CompressionOptions = {}, ): Promise { - const compression = getAnyCompression(event) + const compression = getAnyCompression(event, options) if (compression) await compress(event, response, compression) } diff --git a/src/compressionStream.ts b/src/compressionStream.ts index d8cbead..7a5f4bb 100644 --- a/src/compressionStream.ts +++ b/src/compressionStream.ts @@ -42,15 +42,30 @@ export async function useBrotliCompressionStream( await compressStream(event, response, 'br') } +/** + * Compresses the response with [zlib.createZstdCompress]{@link https://nodejs.org/api/zlib.html} + * piped as a stream. The native `CompressionStream` has no zstd format, so this + * one is backed by `node:zlib` and requires Node >= 22.15.0 — it throws a + * `TypeError` on older runtimes. + * @param event - A H3 event object. + * @param response - A response object with body parameter. + */ +export async function useZstdCompressionStream( + event: H3Event, + response: Partial, +) { + await compressStream(event, response, 'zstd') +} + /** * 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`. + * Zstd and brotli are only picked when enabled via `options`. * @param event - A H3 event object. * @param response - A response object with body parameter. - * @param options - Opt into brotli detection with `{ brotli: true }`. + * @param options - Opt into zstd / brotli detection, e.g. `{ brotli: true }`. */ export async function useCompressionStream( event: H3Event, diff --git a/src/helper.ts b/src/helper.ts index 06329b9..097ae66 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -2,6 +2,7 @@ import { promisify } from 'node:util' import zlib from 'node:zlib' import { Buffer } from 'node:buffer' import { Duplex } from 'node:stream' +import process from 'node:process' import type { H3Event } from 'h3' import * as h3 from 'h3' @@ -14,10 +15,22 @@ export interface RenderResponse { headers: Record } -export type Compression = 'gzip' | 'deflate' | 'br' -export type StreamCompression = 'gzip' | 'deflate' | 'br' +export type Compression = 'gzip' | 'deflate' | 'br' | 'zstd' +export type StreamCompression = 'gzip' | 'deflate' | 'br' | 'zstd' -export interface StreamCompressionOptions { +export interface CompressionOptions { + /** + * Consider zstd when picking a compression from the `Accept-Encoding` + * header. Off by default: `node:zlib` only gained zstd in Node 22.15, so + * enabling it by default would make the negotiated encoding depend on the + * runtime version. The flag is ignored when the runtime has no zstd — the + * next accepted encoding is used instead. See {@link isZstdSupported}. + * @default false + */ + zstd?: boolean +} + +export interface StreamCompressionOptions extends CompressionOptions { /** * Consider brotli when picking a compression from the `Accept-Encoding` * header. Off by default because brotli is noticeably more CPU-expensive @@ -27,6 +40,25 @@ export interface StreamCompressionOptions { brotli?: boolean } +/** + * Whether the current runtime can compress with zstd. `node:zlib` gained the + * zstd bindings in Node 22.15.0 / 23.8.0; on anything older this is `false` + * and zstd is skipped during `Accept-Encoding` negotiation. + * @returns { boolean } + */ +export function isZstdSupported(): boolean { + return typeof zlib.createZstdCompress === 'function' +} + +function ensureZstdSupported(): void { + if (!isZstdSupported()) { + throw new TypeError( + 'zstd compression requires Node >= 22.15.0 (or >= 23.8.0), which is when `node:zlib` gained zstd support. ' + + `This runtime is ${process.version}. Use \`isZstdSupported()\` to branch, or the \`zstd\` option to fall back automatically.`, + ) + } +} + /** * `send` (h3 v1) and `toResponse` (h3 v2) each only exist in a single major * version. They are read through a runtime key so that bundlers (Nuxt / Nitro / @@ -43,12 +75,21 @@ const toResponse = h3Export<(val: unknown, event: H3Event) => Response | Promise /** * Returns the best compression accepted by the client via the - * `Accept-Encoding` header. Brotli is preferred, then gzip, then deflate. + * `Accept-Encoding` header. Zstd is preferred when enabled via + * `options.zstd` and supported by the runtime, then brotli, then gzip, + * then deflate. * @param { H3Event } event - A H3 event object. + * @param { CompressionOptions } [options] - Opt into zstd detection. * @returns { Compression | undefined } */ -export function getAnyCompression(event: H3Event): Compression | undefined { +export function getAnyCompression( + event: H3Event, + options: CompressionOptions = {}, +): Compression | undefined { const encoding = getRequestHeader(event, 'accept-encoding') + if (options.zstd && isZstdSupported() && encoding?.includes('zstd')) + return 'zstd' + if (encoding?.includes('br')) return 'br' @@ -62,11 +103,11 @@ export function getAnyCompression(event: H3Event): Compression | undefined { } /** - * 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. + * Returns the best stream compression accepted by the client. Zstd and brotli + * are only considered when enabled via `options`, otherwise gzip is preferred, + * then deflate. * @param { H3Event } event - A H3 event object. - * @param { StreamCompressionOptions } [options] - Opt into brotli detection. + * @param { StreamCompressionOptions } [options] - Opt into zstd / brotli detection. * @returns { StreamCompression | undefined } */ export function getStreamCompression( @@ -74,6 +115,9 @@ export function getStreamCompression( options: StreamCompressionOptions = {}, ): StreamCompression | undefined { const encoding = getRequestHeader(event, 'accept-encoding') + if (options.zstd && isZstdSupported() && encoding?.includes('zstd')) + return 'zstd' + if (options.brotli && encoding?.includes('br')) return 'br' @@ -90,20 +134,47 @@ export function getStreamCompression( * 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. + * enum, which only defines gzip, deflate and deflate-raw — brotli and zstd are + * therefore streamed through `node:zlib` instead. The explicit flush mode is + * required to keep the output chunked; with zlib's defaults both buffer 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) + if (method === 'br') { + return Duplex.toWeb(zlib.createBrotliCompress({ + flush: zlib.constants.BROTLI_OPERATION_FLUSH, + })) as unknown as ReadableWritablePair + } + + if (method === 'zstd') { + ensureZstdSupported() + return Duplex.toWeb(zlib.createZstdCompress({ + flush: zlib.constants.ZSTD_e_flush, + })) as unknown as ReadableWritablePair + } - return Duplex.toWeb(zlib.createBrotliCompress({ - flush: zlib.constants.BROTLI_OPERATION_FLUSH, - })) as unknown as ReadableWritablePair + return new CompressionStream(method) +} + +/** + * Returns the buffered (one-shot) compressor for a method. + * @param { Compression } method - The compression to apply. + * @returns { (payload: Uint8Array) => Promise } + */ +function createCompressor(method: Compression): (payload: Uint8Array) => Promise { + if (method === 'zstd') + ensureZstdSupported() + + const compressors = { + br: 'brotliCompress', + zstd: 'zstdCompress', + gzip: 'gzip', + deflate: 'deflate', + } as const + + return promisify(zlib[compressors[method]]) as (payload: Uint8Array) => Promise } function isReadableStream(value: unknown): boolean { @@ -154,7 +225,7 @@ export async function compress(event: H3Event, response: Partial if (!payload) return - const compression = promisify(zlib[method === 'br' ? 'brotliCompress' : method]) + const compression = createCompressor(method) setResponseHeader(event, 'Content-Encoding', method) const compressed = await compression(payload) // h3 v1 streams the body via `send`, h3 v2 expects the (mutated) body. @@ -208,11 +279,17 @@ function cloneResponse(response: Response, body: BodyInit, method: string): Resp * @param { H3Event } event - A H3 event object. * @param { unknown } value - The value returned by the next handler. * @param { Compression } [method] - Force a specific compression method. + * @param { CompressionOptions } [options] - Opt into zstd detection. * @returns { Promise } */ -export async function compressResponse(event: H3Event, value: unknown, method?: Compression): Promise { +export async function compressResponse( + event: H3Event, + value: unknown, + method?: Compression, + options?: CompressionOptions, +): Promise { const response = await ensureToResponse()(value, event) - const compressionMethod = method ?? getAnyCompression(event) + const compressionMethod = method ?? getAnyCompression(event, options) if (!compressionMethod || response.headers.has('Content-Encoding')) return response @@ -221,7 +298,7 @@ export async function compressResponse(event: H3Event, value: unknown, method?: if (body.byteLength === 0) return response - const compression = promisify(zlib[compressionMethod === 'br' ? 'brotliCompress' : compressionMethod]) + const compression = createCompressor(compressionMethod) return cloneResponse(response, await compression(body), compressionMethod) } diff --git a/src/index.ts b/src/index.ts index edc68a0..d2a7da1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,14 @@ export { useCompression, useBrotliCompression, useDeflateCompression, + useZstdCompression, } from './compression' export { useGZipCompressionStream, useDeflateCompressionStream, useBrotliCompressionStream, + useZstdCompressionStream, useCompressionStream, } from './compressionStream' @@ -22,13 +24,19 @@ export { compressResponseStream, getAnyCompression, getStreamCompression, + isZstdSupported, } from './helper' export type { Compression, + CompressionOptions, StreamCompression, StreamCompressionOptions, RenderResponse, } from './helper' -export type { CompressionMiddleware, CompressionStreamOptions } from './middleware' +export type { + CompressionMiddleware, + CompressionMiddlewareOptions, + CompressionStreamOptions, +} from './middleware' diff --git a/src/middleware.ts b/src/middleware.ts index af8bb84..9778827 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,5 @@ import type { H3Event } from 'h3' -import type { Compression, StreamCompression, StreamCompressionOptions } from './helper' +import type { Compression, CompressionOptions, StreamCompression, StreamCompressionOptions } from './helper' import { compressResponse, compressResponseStream } from './helper' type Next = () => unknown | Promise @@ -9,6 +9,18 @@ type Next = () => unknown | Promise */ export type CompressionMiddleware = (event: H3Event, next: Next) => Promise +/** + * Configuration for the {@link compression} middleware. + */ +export interface CompressionMiddlewareOptions extends CompressionOptions { + /** + * Force a specific compression method instead of detecting it from the + * `Accept-Encoding` header. A forced `'zstd'` does not need `zstd: true`, + * but it does throw on runtimes without zstd support. + */ + method?: Compression +} + /** * Configuration for the {@link compressionStream} middleware. */ @@ -25,20 +37,29 @@ export interface CompressionStreamOptions extends StreamCompressionOptions { * compresses the response with [Zlib]{@link https://nodejs.org/api/zlib.html} * based on the `Accept-Encoding` header. Best is used first. * + * Zstd is opt-in: `node:zlib` only gained it in Node 22.15, so enabling it by + * default would make the negotiated encoding depend on the runtime version. + * With `zstd: true` on an older runtime the next accepted encoding is used. + * * @example * ```ts * import { H3 } from 'h3' * import { compression } from 'h3-compression' * * const app = new H3() - * app.use(compression()) + * + * app.use(compression()) // brotli / gzip / deflate + * app.use(compression({ zstd: true })) // zstd, brotli, gzip, deflate + * app.use(compression('zstd')) // always zstd * ``` * - * @param { Compression } [method] - Force a specific compression method instead of detecting it. + * @param { Compression | CompressionMiddlewareOptions } [options] - A forced compression method or a config object. * @returns { CompressionMiddleware } */ -export function compression(method?: Compression): CompressionMiddleware { - return (event, next) => compressResponse(event, next(), method) +export function compression(options?: Compression | CompressionMiddlewareOptions): CompressionMiddleware { + const config: CompressionMiddlewareOptions = typeof options === 'string' ? { method: options } : { ...options } + + return (event, next) => compressResponse(event, next(), config.method, config) } /** @@ -47,9 +68,9 @@ 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. + * Brotli and zstd are streamed through `node:zlib` and are opt-in — they are + * more CPU-expensive per request (brotli) or runtime-version dependent (zstd), + * so they are never picked from `Accept-Encoding` unless enabled. * * @example * ```ts @@ -60,6 +81,7 @@ export function compression(method?: Compression): CompressionMiddleware { * * app.use(compressionStream()) // gzip / deflate * app.use(compressionStream({ brotli: true })) // brotli, gzip, deflate + * app.use(compressionStream({ zstd: true, brotli: true })) // zstd, brotli, gzip, deflate * app.use(compressionStream('br')) // always brotli * ``` * diff --git a/test/compression-v1.test.ts b/test/compression-v1.test.ts index 046a59f..a0abe57 100644 --- a/test/compression-v1.test.ts +++ b/test/compression-v1.test.ts @@ -4,9 +4,12 @@ import type { SuperTest, Test } from 'supertest' import supertest from 'supertest' import { beforeEach, describe, expect, it } from 'vitest' import * as h3 from 'h3' -import { useCompression, useCompressionStream } from '../src' +import { isZstdSupported, useCompression, useCompressionStream } from '../src' import { isV1 } from './_version' +// `node:zlib` gained zstd in Node 22.15.0 / 23.8.0. +const hasZstd = isZstdSupported() + // 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[] = [] @@ -138,3 +141,39 @@ describe.runIf(isV1)('useCompressionStream (h3 v1 app hook)', () => { expect(zlib.brotliDecompressSync(result.body).toString()).toEqual(html) }) }) + +describe.runIf(isV1)('zstd on the h3 v1 app hook (#7)', () => { + it('ignores zstd by default and falls back to gzip', async () => { + const result = await appWith(useCompression).get('/').set('Accept-Encoding', 'zstd, gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.text).toEqual(html) + }) + + it.runIf(hasZstd)('compresses with zstd when enabled via options', async () => { + const zstdRequest = appWith((event, response) => + useCompression(event, response, { zstd: true }), + ) + const result = await zstdRequest + .get('/') + .set('Accept-Encoding', 'zstd, gzip') + .buffer(true) + .parse(rawParser) + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('zstd') + expect(zlib.zstdDecompressSync(result.body).toString()).toEqual(html) + }) + + it.runIf(!hasZstd)('falls back to gzip on runtimes without zstd', async () => { + const zstdRequest = appWith((event, response) => + useCompression(event, response, { zstd: true }), + ) + const result = await zstdRequest.get('/').set('Accept-Encoding', 'zstd, gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.text).toEqual(html) + }) +}) diff --git a/test/zstd.test.ts b/test/zstd.test.ts new file mode 100644 index 0000000..329c5d0 --- /dev/null +++ b/test/zstd.test.ts @@ -0,0 +1,207 @@ +import { Buffer } from 'node:buffer' +import zlib from 'node:zlib' +import type { SuperTest, Test } from 'supertest' +import supertest from 'supertest' +import { describe, expect, it } from 'vitest' +import * as h3 from 'h3' +import { + compression, + compressionStream, + getAnyCompression, + getStreamCompression, + isZstdSupported, + useCompression, + useZstdCompression, + useZstdCompressionStream, +} from '../src' +import { isV2 } from './_version' + +// `mockEvent` / `H3` / `toNodeHandler` only exist in h3 v2 — access them lazily +// so this file still loads (but is skipped) under h3 v1. +const { H3, mockEvent, toNodeHandler } = h3 as typeof import('h3') + +const html = '

Hello World

' + +// `node:zlib` gained zstd in Node 22.15.0 / 23.8.0. Everything that actually +// compresses is gated on the runtime; the negotiation fallbacks are not. +const hasZstd = isZstdSupported() + +function eventFor(encoding: string) { + return mockEvent('/', { headers: { 'accept-encoding': encoding } }) +} + +// superagent does not auto-decode zstd, 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))) +} + +async function readStream(stream: ReadableStream): Promise { + const chunks: Buffer[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) + break + chunks.push(Buffer.from(value)) + } + return Buffer.concat(chunks) +} + +describe.runIf(isV2)('zstd negotiation (#7)', () => { + it('never picks zstd without the flag, even when accepted', () => { + const event = eventFor('zstd, gzip') + + expect(getAnyCompression(event)).toEqual('gzip') + expect(getStreamCompression(event)).toEqual('gzip') + }) + + it('does not let zstd outrank an unflagged brotli in the zlib path', () => { + const event = eventFor('zstd, br, gzip') + + expect(getAnyCompression(event)).toEqual('br') + }) + + it('falls back when zstd is enabled but not accepted', () => { + const event = eventFor('gzip, deflate') + + expect(getAnyCompression(event, { zstd: true })).toEqual('gzip') + expect(getStreamCompression(event, { zstd: true })).toEqual('gzip') + }) + + it.runIf(hasZstd)('prefers zstd over brotli when enabled', () => { + const event = eventFor('zstd, br, gzip') + + expect(getAnyCompression(event, { zstd: true })).toEqual('zstd') + expect(getStreamCompression(event, { zstd: true, brotli: true })).toEqual('zstd') + }) + + it.runIf(!hasZstd)('ignores the flag and falls back on runtimes without zstd', () => { + const event = eventFor('zstd, gzip') + + expect(getAnyCompression(event, { zstd: true })).toEqual('gzip') + expect(getStreamCompression(event, { zstd: true })).toEqual('gzip') + }) + + it.runIf(!hasZstd)('throws a helpful error when zstd is forced without support', async () => { + const event = eventFor('zstd') + + await expect(useZstdCompression(event, { body: html })) + .rejects + .toThrow(/requires Node >= 22\.15\.0/) + }) +}) + +describe.runIf(isV2 && hasZstd)('zstd compression (#7)', () => { + it('compresses the body with zstd', async () => { + const event = eventFor('zstd') + const response: { body: unknown } = { body: html } + + await useZstdCompression(event, response) + + expect(event.res.headers.get('content-encoding')).toEqual('zstd') + expect(zlib.zstdDecompressSync(response.body as Buffer).toString()).toEqual(html) + }) + + it('compresses the body stream with zstd', async () => { + const event = eventFor('zstd') + const response: { body: unknown } = { body: html } + + await useZstdCompressionStream(event, response) + + expect(event.res.headers.get('content-encoding')).toEqual('zstd') + expect(zlib.zstdDecompressSync(await readStream(response.body as ReadableStream)).toString()).toEqual(html) + }) + + it('picks zstd through useCompression when enabled', async () => { + const event = eventFor('zstd, br, gzip') + const response: { body: unknown } = { body: html } + + await useCompression(event, response, { zstd: true }) + + expect(event.res.headers.get('content-encoding')).toEqual('zstd') + expect(zlib.zstdDecompressSync(response.body as Buffer).toString()).toEqual(html) + }) + + it('keeps the zstd stream chunked instead of buffering the whole body', async () => { + const event = eventFor('zstd') + 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: unknown } = { body: source } + + await useZstdCompressionStream(event, response) + + const chunks: Buffer[] = [] + const reader = (response.body 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.zstdDecompressSync(Buffer.concat(chunks)).toString()).toContain('chunk-2') + }) +}) + +describe.runIf(isV2 && hasZstd)('zstd middleware (#7)', () => { + function appWith(middleware: ReturnType): SuperTest { + const app = new H3() + app.use(middleware) + app.get('/', () => html) + return supertest(toNodeHandler(app)) + } + + it('compresses with zstd when enabled via options', async () => { + const result = await appWith(compression({ zstd: true })) + .get('/') + .set('Accept-Encoding', 'zstd, gzip') + .buffer() + .parse(rawParser) + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('zstd') + expect(zlib.zstdDecompressSync(result.body).toString()).toEqual(html) + }) + + it('streams zstd when enabled via options', async () => { + const result = await appWith(compressionStream({ zstd: true })) + .get('/') + .set('Accept-Encoding', 'zstd, gzip') + .buffer() + .parse(rawParser) + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('zstd') + expect(zlib.zstdDecompressSync(result.body).toString()).toEqual(html) + }) + + it('forces zstd without the flag when passed as a method', async () => { + const result = await appWith(compression('zstd')) + .get('/') + .set('Accept-Encoding', 'zstd') + .buffer() + .parse(rawParser) + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('zstd') + expect(zlib.zstdDecompressSync(result.body).toString()).toEqual(html) + }) + + it('still accepts a plain method string (backwards compatible)', async () => { + const result = await appWith(compression('gzip')).get('/').set('Accept-Encoding', 'gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.text).toEqual(html) + }) +})