diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f6eecf..524725d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,9 +38,12 @@ jobs: strategy: matrix: node: [18, 20] + h3: [1, 2] os: [ubuntu-latest, macos-latest] fail-fast: false + name: test (node-${{ matrix.node }}, h3-v${{ matrix.h3 }}, ${{ matrix.os }}) + steps: - uses: actions/checkout@v3 with: @@ -51,6 +54,8 @@ jobs: node-version: ${{ matrix.node }} cache: pnpm - run: pnpm install + - name: Install h3 v${{ matrix.h3 }} + run: pnpm add -D h3@${{ matrix.h3 == 1 && '^1.8.0' || '2.0.1-rc.22' }} --ignore-scripts - run: pnpm build # - run: pnpm test:types - run: pnpm vitest --coverage && rm -rf coverage/tmp diff --git a/README.md b/README.md index 70fb90a..fbb7b91 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ ✔️  **Compression Detection:** It uses the best compression which is accepted +✔️  **h3 v1 & v2:** Works with both [h3](https://h3.dev) v1 and v2 + ## Install @@ -31,7 +33,7 @@ yarn add h3-compression pnpm add h3-compression ``` -## Usage +## Usage (h3 v1) ```ts import { createServer } from 'node:http' @@ -63,6 +65,32 @@ app.use( listen(toNodeListener(app)) ``` +## Usage (h3 v2) + +In [h3 v2](https://h3.dev) the response is an immutable web `Response` and the `onBeforeResponse` +hook was removed. Use the `compression` / `compressionStream` middleware instead — they read the +response returned by the next handler and replace it with a compressed one. + +```ts +import { createServer } from 'node:http' +import { H3, toNodeHandler } from 'h3' +import { compression } from 'h3-compression' + +const app = new H3() + +app.use(compression()) // or app.use(compressionStream()) +app.get('/', () => 'Hello world!') + +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. + +> [!NOTE] +> `compressionStream` uses the native [`CompressionStream`](https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream), +> which only supports `gzip` and `deflate` (no brotli). + ## Nuxt 3 & 4 If you want to use it in Nuxt you can define a nitro plugin. @@ -104,6 +132,13 @@ H3-compression has a concept of composable utilities that accept `event` (from ` - `useDeflateCompressionStream(event, response)` - `useCompressionStream(event, response)` +#### Middleware (h3 v2) + +- `compression(method?)`  – middleware using zlib (brotli, gzip, deflate) +- `compressionStream(method?)`  – middleware using the native `CompressionStream` (gzip, deflate) +- `compressResponse(event, value, method?)`  – low-level helper returning a compressed `Response` +- `compressResponseStream(event, value, method?)`  – low-level stream helper returning a compressed `Response` + ## Sponsors

diff --git a/package.json b/package.json index d494c4e..95072e1 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "prepare": "simple-git-hooks" }, "peerDependencies": { - "h3": "^1.6.0" + "h3": "^1.6.0 || ^2.0.0" }, "devDependencies": { "@antfu/eslint-config": "^0.41.0", @@ -72,7 +72,7 @@ "bumpp": "^9.2.0", "eslint": "^8.48.0", "esno": "^0.17.0", - "h3": "^1.8.1", + "h3": "2.0.1-rc.22", "lint-staged": "^14.0.1", "node-fetch-native": "^1.4.0", "pnpm": "^8.7.0", diff --git a/playground/package.json b/playground/package.json index fe1ca7f..4f7c349 100644 --- a/playground/package.json +++ b/playground/package.json @@ -9,7 +9,7 @@ "postinstall": "nuxt prepare" }, "devDependencies": { - "@nuxt/devtools": "latest", + "@nuxt/devtools": "^0.8.2", "nuxt": "^3.7.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f069cc..90e6bf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^0.17.0 version: 0.17.0 h3: - specifier: ^1.8.1 - version: 1.8.1 + specifier: 2.0.1-rc.22 + version: 2.0.1-rc.22 lint-staged: specifier: ^14.0.1 version: 14.0.1 @@ -72,7 +72,7 @@ importers: playground: devDependencies: '@nuxt/devtools': - specifier: latest + specifier: ^0.8.2 version: 0.8.2(nuxt@3.7.0)(rollup@3.28.1)(vite@4.4.9) nuxt: specifier: ^3.7.0 @@ -85,14 +85,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /@ampproject/remapping@2.2.0: - resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/gen-mapping': 0.1.1 - '@jridgewell/trace-mapping': 0.3.17 - dev: true - /@ampproject/remapping@2.2.1: resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} engines: {node: '>=6.0.0'} @@ -223,7 +215,7 @@ packages: resolution: {integrity: sha512-lh7RJrtPdhibbxndr6/xx0w8+CVlY5FJZiaSz908Fpy+G0xkBFTvwLcKJFF4PJxVfGhVWNebikpWGnOoC71juQ==} engines: {node: '>=6.9.0'} dependencies: - '@ampproject/remapping': 2.2.0 + '@ampproject/remapping': 2.2.1 '@babel/code-frame': 7.22.13 '@babel/generator': 7.22.10 '@babel/helper-compilation-targets': 7.22.10 @@ -1228,14 +1220,6 @@ packages: '@sinclair/typebox': 0.27.8 dev: true - /@jridgewell/gen-mapping@0.1.1: - resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/set-array': 1.1.2 - '@jridgewell/sourcemap-codec': 1.4.15 - dev: true - /@jridgewell/gen-mapping@0.3.2: resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==} engines: {node: '>=6.0.0'} @@ -2754,6 +2738,7 @@ packages: /are-we-there-yet@2.0.0: resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} engines: {node: '>=10'} + deprecated: This package is no longer supported. dependencies: delegates: 1.0.0 readable-stream: 3.6.2 @@ -2762,6 +2747,7 @@ packages: /are-we-there-yet@3.0.1: resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. dependencies: delegates: 1.0.0 readable-stream: 3.6.2 @@ -4570,7 +4556,7 @@ packages: resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} engines: {node: ^10.12.0 || >=12.0.0} dependencies: - flatted: 3.2.6 + flatted: 3.2.7 rimraf: 3.0.2 dev: true @@ -4579,10 +4565,6 @@ packages: hasBin: true dev: true - /flatted@3.2.6: - resolution: {integrity: sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ==} - dev: true - /flatted@3.2.7: resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} dev: true @@ -4608,7 +4590,7 @@ packages: engines: {node: '>=14'} dependencies: cross-spawn: 7.0.3 - signal-exit: 4.0.2 + signal-exit: 4.1.0 dev: true /form-data@4.0.0: @@ -4691,6 +4673,7 @@ packages: /gauge@3.0.2: resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} engines: {node: '>=10'} + deprecated: This package is no longer supported. dependencies: aproba: 2.0.0 color-support: 1.1.3 @@ -4706,6 +4689,7 @@ packages: /gauge@4.0.4: resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. dependencies: aproba: 2.0.0 color-support: 1.1.3 @@ -4925,6 +4909,20 @@ packages: unenv: 1.7.4 dev: true + /h3@2.0.1-rc.22: + resolution: {integrity: sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + dependencies: + rou3: 0.8.1 + srvx: 0.11.16 + dev: true + /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -6089,7 +6087,7 @@ packages: acorn: 8.10.0 pathe: 1.1.1 pkg-types: 1.0.3 - ufo: 1.1.2 + ufo: 1.3.0 dev: true /mlly@1.4.1: @@ -6420,6 +6418,7 @@ packages: /npmlog@5.0.1: resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. dependencies: are-we-there-yet: 2.0.0 console-control-strings: 1.1.0 @@ -6430,6 +6429,7 @@ packages: /npmlog@6.0.2: resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. dependencies: are-we-there-yet: 3.0.1 console-control-strings: 1.1.0 @@ -7367,6 +7367,7 @@ packages: /read-package-json@7.0.0: resolution: {integrity: sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==} engines: {node: ^16.14.0 || >=18.0.0} + deprecated: This package is no longer supported. Please use @npmcli/package-json instead. dependencies: glob: 10.2.6 json-parse-even-better-errors: 3.0.0 @@ -7566,6 +7567,10 @@ packages: fsevents: 2.3.3 dev: true + /rou3@0.8.1: + resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + dev: true + /run-applescript@5.0.0: resolution: {integrity: sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==} engines: {node: '>=12'} @@ -7713,11 +7718,6 @@ packages: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: true - /signal-exit@4.0.2: - resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} - engines: {node: '>=14'} - dev: true - /signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -7854,6 +7854,12 @@ packages: resolution: {integrity: sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==} dev: true + /srvx@0.11.16: + resolution: {integrity: sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw==} + engines: {node: '>=20.16.0'} + hasBin: true + dev: true + /ssri@10.0.5: resolution: {integrity: sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -8094,6 +8100,7 @@ packages: /tar@6.1.13: resolution: {integrity: sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: chownr: 2.0.0 fs-minipass: 2.1.0 @@ -8286,10 +8293,6 @@ packages: hasBin: true dev: true - /ufo@1.1.2: - resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==} - dev: true - /ufo@1.3.0: resolution: {integrity: sha512-bRn3CsoojyNStCZe0BG0Mt4Nr/4KF+rhFlnNXybgqt5pXHNFRlqinSoQaTrGyzE4X8aHplSb+TorH+COin9Yxw==} dev: true diff --git a/src/helper.ts b/src/helper.ts index 8c97518..fe50ca3 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -2,11 +2,9 @@ import { promisify } from 'node:util' import zlib from 'node:zlib' import { Buffer } from 'node:buffer' import type { H3Event } from 'h3' -import { - getRequestHeader, - send, - setResponseHeader, -} from 'h3' +import * as h3 from 'h3' + +const { getRequestHeader, setResponseHeader } = h3 export interface RenderResponse { body: string | unknown @@ -15,7 +13,25 @@ export interface RenderResponse { headers: Record } -export function getAnyCompression(event: H3Event) { +export type Compression = 'gzip' | 'deflate' | 'br' +export type StreamCompression = 'gzip' | 'deflate' + +/** + * `send` was removed and `toResponse` was added in h3 v2. They are accessed + * dynamically so this package keeps working with both h3 v1 and v2. + */ +const send = (h3 as { send?: (event: H3Event, data?: unknown) => unknown }).send +const toResponse = (h3 as { + toResponse?: (val: unknown, event: H3Event) => Response | Promise +}).toResponse + +/** + * Returns the best compression accepted by the client via the + * `Accept-Encoding` header. Brotli is preferred, then gzip, then deflate. + * @param { H3Event } event - A H3 event object. + * @returns { Compression | undefined } + */ +export function getAnyCompression(event: H3Event): Compression | undefined { const encoding = getRequestHeader(event, 'accept-encoding') if (encoding?.includes('br')) return 'br' @@ -29,7 +45,24 @@ export function getAnyCompression(event: H3Event) { return undefined } -export async function compress(event: H3Event, response: Partial, method: 'gzip' | 'deflate' | 'br') { +/** + * Returns the best stream compression accepted by the client. The native + * `CompressionStream` only supports gzip and deflate, so brotli is ignored. + * @param { H3Event } event - A H3 event object. + * @returns { StreamCompression | undefined } + */ +export function getStreamCompression(event: H3Event): StreamCompression | undefined { + const encoding = getRequestHeader(event, 'accept-encoding') + if (encoding?.includes('gzip')) + return 'gzip' + + if (encoding?.includes('deflate')) + return 'deflate' + + return undefined +} + +export async function compress(event: H3Event, response: Partial, method: Compression) { const compression = promisify(zlib[method === 'br' ? 'brotliCompress' : method]) const acceptsEncoding = getRequestHeader(event, 'accept-encoding')?.includes( method, @@ -37,11 +70,16 @@ export async function compress(event: H3Event, response: Partial if (acceptsEncoding && typeof response.body === 'string') { setResponseHeader(event, 'Content-Encoding', method) - send(event, await compression(Buffer.from(response.body))) + const compressed = await compression(Buffer.from(response.body)) + // h3 v1 streams the body via `send`, h3 v2 expects the (mutated) body. + if (typeof send === 'function') + send(event, compressed) + else + response.body = compressed } } -export async function compressStream(event: H3Event, response: Partial, method: 'gzip' | 'deflate') { +export async function compressStream(event: H3Event, response: Partial, method: StreamCompression) { const stream = new Response(response.body as string).body as ReadableStream const acceptsEncoding = getRequestHeader(event, 'accept-encoding')?.includes( method, @@ -55,3 +93,69 @@ export async function compressStream(event: H3Event, response: Partial { + if (typeof toResponse !== 'function') { + throw new TypeError( + 'The compression middleware requires h3 v2. With h3 v1 use `useCompression` inside an `onBeforeResponse` / `render:response` hook.', + ) + } + + return toResponse +} + +function cloneResponse(response: Response, body: BodyInit, method: string): Response { + const headers = new Headers(response.headers) + headers.set('Content-Encoding', method) + // The length changes after compression, let the runtime recompute it. + headers.delete('Content-Length') + + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +/** + * Compresses an h3 v2 response value with [Zlib]{@link https://nodejs.org/api/zlib.html}. + * Used by the {@link compression} middleware. + * @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. + * @returns { Promise } + */ +export async function compressResponse(event: H3Event, value: unknown, method?: Compression): Promise { + const response = await ensureToResponse()(value, event) + const compressionMethod = method ?? getAnyCompression(event) + + if (!compressionMethod || response.headers.has('Content-Encoding')) + return response + + const body = new Uint8Array(await response.arrayBuffer()) + if (body.byteLength === 0) + return response + + const compression = promisify(zlib[compressionMethod === 'br' ? 'brotliCompress' : compressionMethod]) + + return cloneResponse(response, await compression(body), compressionMethod) +} + +/** + * Compresses an h3 v2 response value with + * [CompressionStream]{@link https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream}. + * Used by the {@link compressionStream} middleware. + * @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. + * @returns { Promise } + */ +export async function compressResponseStream(event: H3Event, value: unknown, method?: StreamCompression): Promise { + const response = await ensureToResponse()(value, event) + const compressionMethod = method ?? getStreamCompression(event) + + if (!compressionMethod || !response.body || response.headers.has('Content-Encoding')) + return response + + return cloneResponse(response, response.body.pipeThrough(new CompressionStream(compressionMethod)), compressionMethod) +} diff --git a/src/index.ts b/src/index.ts index ce822ec..6d8c795 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,3 +10,23 @@ export { useDeflateCompressionStream, useCompressionStream, } from './compressionStream' + +export { + compression, + compressionStream, +} from './middleware' + +export { + compressResponse, + compressResponseStream, + getAnyCompression, + getStreamCompression, +} from './helper' + +export type { + Compression, + StreamCompression, + RenderResponse, +} from './helper' + +export type { CompressionMiddleware } from './middleware' diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..8eff9cf --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,53 @@ +import type { H3Event } from 'h3' +import type { Compression, StreamCompression } from './helper' +import { compressResponse, compressResponseStream } from './helper' + +type Next = () => unknown | Promise + +/** + * A h3 v2 middleware compressing the response. + */ +export type CompressionMiddleware = (event: H3Event, next: Next) => Promise + +/** + * 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} + * based on the `Accept-Encoding` header. Best is used first. + * + * @example + * ```ts + * import { H3 } from 'h3' + * import { compression } from 'h3-compression' + * + * const app = new H3() + * app.use(compression()) + * ``` + * + * @param { Compression } [method] - Force a specific compression method instead of detecting it. + * @returns { CompressionMiddleware } + */ +export function compression(method?: Compression): CompressionMiddleware { + return (event, next) => compressResponse(event, next(), method) +} + +/** + * Creates a [h3 v2 middleware]{@link https://h3.dev/guide/basics/middleware} that + * compresses the response with the native + * [CompressionStream]{@link https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream} + * based on the `Accept-Encoding` header. Best is used first. + * + * @example + * ```ts + * import { H3 } from 'h3' + * import { compressionStream } from 'h3-compression' + * + * const app = new H3() + * app.use(compressionStream()) + * ``` + * + * @param { StreamCompression } [method] - Force a specific compression method instead of detecting it. + * @returns { CompressionMiddleware } + */ +export function compressionStream(method?: StreamCompression): CompressionMiddleware { + return (event, next) => compressResponseStream(event, next(), method) +} diff --git a/test/_version.ts b/test/_version.ts new file mode 100644 index 0000000..fa9ffe3 --- /dev/null +++ b/test/_version.ts @@ -0,0 +1,5 @@ +import * as h3 from 'h3' + +// h3 v2 added `toResponse` and removed the v1-only `send` util. +export const isV2 = typeof (h3 as { toResponse?: unknown }).toResponse === 'function' +export const isV1 = !isV2 diff --git a/test/compression-stream.test.ts b/test/compression-stream.test.ts index 2f96e46..74d8136 100644 --- a/test/compression-stream.test.ts +++ b/test/compression-stream.test.ts @@ -1,48 +1,64 @@ -import type { SuperTest, Test } from 'supertest' -import supertest from 'supertest' -import { beforeEach, describe, expect, it } from 'vitest' -import type { App } from 'h3' -import { createApp, eventHandler, toNodeListener } from 'h3' -import { useCompressionStream } from '../src' - -describe('use compression', () => { - let app: App - let request: SuperTest - - beforeEach(() => { - app = createApp({ debug: true, onBeforeResponse: useCompressionStream }) - app.use('/', eventHandler({ - handler: () => { - return '

Hello World

' - }, - })) - request = supertest(toNodeListener(app)) - }) +import { Buffer } from 'node:buffer' +import zlib from 'node:zlib' +import { describe, expect, it } from 'vitest' +import * as h3 from 'h3' +import { + useCompressionStream, + useDeflateCompressionStream, + useGZipCompressionStream, +} from '../src' +import { isV2 } from './_version' + +// `mockEvent` only exists in h3 v2 — access it lazily so this file still loads +// (but is skipped) under h3 v1. +const { mockEvent } = h3 as typeof import('h3') + +const html = '

Hello World

' + +function eventFor(encoding: string) { + return mockEvent('/', { headers: { 'accept-encoding': encoding } }) +} - it('returns 200 OK with gzip compression stream', async () => { - const result = await request - .get('/') - .set('Accept-Encoding', 'gzip') +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) +} - expect(result.status).toEqual(200) - expect(result.headers['content-encoding']).toEqual('gzip') +describe.runIf(isV2)('useCompressionStream (mutable response / nitro path)', () => { + it('compresses the body stream with gzip', async () => { + const event = eventFor('gzip') + const response = { body: html } + + await useGZipCompressionStream(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('returns 200 OK with deflate compression stream', async () => { - const result = await request - .get('/') - .set('Accept-Encoding', 'deflate') + it('compresses the body stream with deflate', async () => { + const event = eventFor('deflate') + const response = { body: html } - expect(result.status).toEqual(200) - expect(result.headers['content-encoding']).toEqual('deflate') + await useDeflateCompressionStream(event, response) + + expect(event.res.headers.get('content-encoding')).toEqual('deflate') + expect(zlib.inflateSync(await readStream(response.body as unknown as ReadableStream)).toString()).toEqual(html) }) - it.skip('returns 200 OK with brotli compression', async () => { - const result = await request - .get('/') - .set('Accept-Encoding', 'br') + it('picks the best stream compression accepted', async () => { + const event = eventFor('gzip, deflate') + const response = { body: html } + + await useCompressionStream(event, response) - expect(result.status).toEqual(200) - expect(result.headers['content-encoding']).toEqual('br') + expect(event.res.headers.get('content-encoding')).toEqual('gzip') + expect(zlib.gunzipSync(await readStream(response.body as unknown as ReadableStream)).toString()).toEqual(html) }) }) diff --git a/test/compression-v1.test.ts b/test/compression-v1.test.ts new file mode 100644 index 0000000..4ea82f5 --- /dev/null +++ b/test/compression-v1.test.ts @@ -0,0 +1,73 @@ +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 { isV1 } from './_version' + +// `createApp` / `eventHandler` / `toNodeListener` exist in both h3 versions, but +// the `onBeforeResponse` app hook only works in v1. +const { createApp, eventHandler, toNodeListener } = h3 as typeof import('h3') + +const html = '

Hello World

' + +function appWith(hook: typeof useCompression) { + const app = createApp({ debug: true, onBeforeResponse: hook }) + app.use('/', eventHandler(() => html)) + return supertest(toNodeListener(app)) +} + +describe.runIf(isV1)('useCompression (h3 v1 app hook)', () => { + let request: SuperTest + + beforeEach(() => { + request = appWith(useCompression) + }) + + it('returns 200 OK with gzip compression', async () => { + const result = await request.get('/').set('Accept-Encoding', 'gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.text).toEqual(html) + }) + + it('returns 200 OK with deflate compression', async () => { + const result = await request.get('/').set('Accept-Encoding', 'deflate') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('deflate') + expect(result.text).toEqual(html) + }) + + it('returns 200 OK with brotli compression', async () => { + const result = await request.get('/').set('Accept-Encoding', 'br') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('br') + }) +}) + +describe.runIf(isV1)('useCompressionStream (h3 v1 app hook)', () => { + let request: SuperTest + + beforeEach(() => { + request = appWith(useCompressionStream) + }) + + it('returns 200 OK with gzip compression stream', async () => { + const result = await request.get('/').set('Accept-Encoding', 'gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.text).toEqual(html) + }) + + it('returns 200 OK with deflate compression stream', async () => { + const result = await request.get('/').set('Accept-Encoding', 'deflate') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('deflate') + expect(result.text).toEqual(html) + }) +}) diff --git a/test/compression.test.ts b/test/compression.test.ts index 493a494..2e22fe9 100644 --- a/test/compression.test.ts +++ b/test/compression.test.ts @@ -1,48 +1,79 @@ -import type { SuperTest, Test } from 'supertest' -import supertest from 'supertest' -import { beforeEach, describe, expect, it } from 'vitest' -import type { App } from 'h3' -import { createApp, eventHandler, toNodeListener } from 'h3' -import { useCompression } from '../src' - -describe('use compression', () => { - let app: App - let request: SuperTest - - beforeEach(() => { - app = createApp({ debug: true, onBeforeResponse: useCompression }) - app.use('/', eventHandler({ - handler: () => { - return '

Hello World

' - }, - })) - request = supertest(toNodeListener(app)) +import type { Buffer } from 'node:buffer' +import zlib from 'node:zlib' +import { describe, expect, it } from 'vitest' +import * as h3 from 'h3' +import { + useBrotliCompression, + useCompression, + useDeflateCompression, + useGZipCompression, +} from '../src' +import { isV2 } from './_version' + +// `mockEvent` only exists in h3 v2 — access it lazily so this file still loads +// (but is skipped) under h3 v1. +const { mockEvent } = h3 as typeof import('h3') + +const html = '

Hello World

' + +function eventFor(encoding: string) { + return mockEvent('/', { headers: { 'accept-encoding': encoding } }) +} + +const decoders: Record Buffer> = { + gzip: zlib.gunzipSync, + deflate: zlib.inflateSync, + br: zlib.brotliDecompressSync, +} + +describe.runIf(isV2)('useCompression (mutable response / nitro path)', () => { + it('compresses the body with gzip', async () => { + const event = eventFor('gzip') + const response = { body: html } + + await useGZipCompression(event, response) + + expect(event.res.headers.get('content-encoding')).toEqual('gzip') + expect(decoders.gzip(response.body as Buffer).toString()).toEqual(html) }) - it('returns 200 OK with gzip compression', async () => { - const result = await request - .get('/') - .set('Accept-Encoding', 'gzip') + it('compresses the body with deflate', async () => { + const event = eventFor('deflate') + const response = { body: html } - expect(result.status).toEqual(200) - expect(result.headers['content-encoding']).toEqual('gzip') + await useDeflateCompression(event, response) + + expect(event.res.headers.get('content-encoding')).toEqual('deflate') + expect(decoders.deflate(response.body as Buffer).toString()).toEqual(html) }) - it('returns 200 OK with deflate compression', async () => { - const result = await request - .get('/') - .set('Accept-Encoding', 'deflate') + it('compresses the body with brotli', async () => { + const event = eventFor('br') + const response = { body: html } + + await useBrotliCompression(event, response) - expect(result.status).toEqual(200) - expect(result.headers['content-encoding']).toEqual('deflate') + expect(event.res.headers.get('content-encoding')).toEqual('br') + expect(decoders.br(response.body as Buffer).toString()).toEqual(html) }) - it('returns 200 OK with brotli compression', async () => { - const result = await request - .get('/') - .set('Accept-Encoding', 'br') + it('picks the best accepted compression', async () => { + const event = eventFor('gzip, deflate, br') + const response = { body: html } + + await useCompression(event, response) + + expect(event.res.headers.get('content-encoding')).toEqual('br') + expect(decoders.br(response.body as Buffer).toString()).toEqual(html) + }) + + it('does nothing when no compression is accepted', async () => { + const event = eventFor('identity') + const response = { body: html } + + await useCompression(event, response) - expect(result.status).toEqual(200) - expect(result.headers['content-encoding']).toEqual('br') + expect(event.res.headers.get('content-encoding')).toBeNull() + expect(response.body).toEqual(html) }) }) diff --git a/test/middleware.test.ts b/test/middleware.test.ts new file mode 100644 index 0000000..a4cd7bb --- /dev/null +++ b/test/middleware.test.ts @@ -0,0 +1,90 @@ +import type { SuperTest, Test } from 'supertest' +import supertest from 'supertest' +import { beforeEach, describe, expect, it } from 'vitest' +import * as h3 from 'h3' +import { compression, compressionStream } from '../src' +import { isV2 } from './_version' + +// `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') + +const html = '

Hello World

' + +describe.runIf(isV2)('compression middleware (h3 v2)', () => { + let request: SuperTest + + beforeEach(() => { + const app = new H3() + app.use(compression()) + app.get('/', () => html) + request = supertest(toNodeHandler(app)) + }) + + it('returns 200 OK with gzip compression', async () => { + const result = await request.get('/').set('Accept-Encoding', 'gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.text).toEqual(html) + }) + + it('returns 200 OK with deflate compression', async () => { + const result = await request.get('/').set('Accept-Encoding', 'deflate') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('deflate') + expect(result.text).toEqual(html) + }) + + it('returns 200 OK with brotli compression', async () => { + // supertest/superagent does not auto-decode brotli, so only the header is asserted here. + const result = await request.get('/').set('Accept-Encoding', 'br') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('br') + }) + + 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 (h3 v2)', () => { + let request: SuperTest + + beforeEach(() => { + const app = new H3() + app.use(compressionStream()) + app.get('/', () => html) + request = supertest(toNodeHandler(app)) + }) + + it('returns 200 OK with gzip compression stream', async () => { + const result = await request.get('/').set('Accept-Encoding', 'gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.text).toEqual(html) + }) + + it('returns 200 OK with deflate compression stream', async () => { + const result = await request.get('/').set('Accept-Encoding', 'deflate') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('deflate') + expect(result.text).toEqual(html) + }) + + it('falls back to gzip when brotli is the only listed but unsupported by streams', 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) + }) +})