diff --git a/README.md b/README.md index 3d33a63..e7b0079 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ ✔️  **h3 v1 & v2:** Works with both [h3](https://h3.dev) v1 and v2 +✔️  **Nuxt module:** Drop `h3-compression/nuxt` into `nuxt.config` and configure it there + ## Install @@ -148,13 +150,62 @@ app.use(compression({ zstd: isZstdSupported() })) ## Nuxt 3 & 4 -If you want to use it in Nuxt you can define a nitro plugin. +Add the module and you're done — it wires the right Nitro hooks, skips Nuxt's internal +routes and filters by content type for you: + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['h3-compression/nuxt'], +}) +``` + +Everything is configurable under the `compression` key: + +```ts +export default defineNuxtConfig({ + modules: ['h3-compression/nuxt'], + compression: { + enabled: true, + encoding: 'zlib', // or 'stream' + brotli: false, // stream path only — zlib always prefers brotli + zstd: false, // needs Node >= 22.15 + method: undefined, // force one method instead of negotiating + contentTypes: ['text/', 'application/json', 'application/javascript', 'application/xml', 'image/svg+xml'], + exclude: ['/_nuxt', '/__nuxt'], + routeRules: true, // also compress cached (swr/isr) routes and /server/api + threshold: 0, // skip bodies smaller than this many bytes + }, +}) +``` + +| Option | Default | What it does | +| --- | --- | --- | +| `enabled` | `true` | Turn compression off without removing the module | +| `encoding` | `'zlib'` | `'zlib'` buffers the body; `'stream'` pipes it through a compression transform | +| `brotli` | `false` | Consider brotli when negotiating. Only meaningful for `'stream'` — the zlib path already prefers brotli | +| `zstd` | `false` | Consider zstd when negotiating. Ignored on Node < 22.15, see [Zstd](#zstd) | +| `method` | – | Force one method instead of negotiating from `Accept-Encoding` | +| `contentTypes` | text, JSON, JS, XML, SVG | Prefix match against `Content-Type`. Set to `[]` to compress everything | +| `exclude` | `['/_nuxt', '/__nuxt']` | Path prefixes to skip. Compressing these breaks Nuxt's error page | +| `routeRules` | `true` | Also attach to `beforeResponse`, which is what cached (`swr`/`isr`) routes and `/server/api` handlers go through | +| `threshold` | `0` | Skip bodies below this size — under roughly a kilobyte compression makes payloads *larger*. Ignored for `'stream'`, where the size is not known up front | + +> [!NOTE] +> `contentTypes` and `exclude` **replace** the defaults rather than extending them. +> Spread the defaults in if you want to add to them. + +### Doing it manually + +The module is a convenience wrapper — the hooks are still yours to wire if you want +different behaviour per route: `server/plugins/compression.ts` ````ts import { useCompression } from 'h3-compression' export default defineNitroPlugin((nitro) => { + // Freshly rendered SSR pages. nitro.hooks.hook('render:response', async (response, { event }) => { // Skip internal nuxt routes (e.g. error page) if (['/_nuxt', '/__nuxt'].some(prefix => getRequestURL(event).pathname.startsWith(prefix))) @@ -165,24 +216,11 @@ export default defineNitroPlugin((nitro) => { await useCompression(event, response) }) -}) -```` -> [!NOTE] -> `useCompressionStream` doesn't work right now in nitro. So you just can use `useCompression` - -### Cached routes (SWR / ISR) and `/server/api` -The `render:response` hook only runs for freshly rendered SSR pages. Responses served -from the Nitro route cache (`routeRules` with `swr` / `isr`) and `/server/api` handlers -go through the `beforeResponse` hook instead. Use it to compress those too: - -`server/plugins/compression.ts` -````ts -import { useCompression } from 'h3-compression' - -export default defineNitroPlugin((nitro) => { + // The `render:response` hook only runs for freshly rendered SSR pages. + // Responses served from the Nitro route cache (`routeRules` with `swr` / `isr`) + // and `/server/api` handlers go through `beforeResponse` instead. nitro.hooks.hook('beforeResponse', async (event, response) => { - // Skip internal nuxt routes (e.g. error page) if (['/_nuxt', '/__nuxt'].some(prefix => event.path.startsWith(prefix))) return @@ -224,6 +262,10 @@ H3-compression has a concept of composable utilities that accept `event` (from ` - `compressResponseStream(event, value, method?, options?)`  – low-level stream helper returning a compressed `Response` - `isZstdSupported()`  – whether the runtime can compress with zstd +#### Nuxt + +- `h3-compression/nuxt`  – the Nuxt module, configured under the `compression` key + ## Sponsors

diff --git a/build.config.ts b/build.config.ts index 0bd008c..700c430 100644 --- a/build.config.ts +++ b/build.config.ts @@ -3,7 +3,20 @@ import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ entries: [ 'src/index', + 'src/nuxt', + // The nitro plugin is consumed by the generated file the module writes into + // the Nuxt build dir, so it has to stay a resolvable file on disk rather + // than being rolled into the `nuxt` bundle. + { + input: 'src/runtime/', + outDir: 'dist/runtime', + builder: 'mkdist', + format: 'esm', + ext: 'mjs', + declaration: true, + }, ], + externals: ['@nuxt/kit', '@nuxt/schema', 'h3', 'h3-compression'], declaration: true, clean: true, rollup: { diff --git a/package.json b/package.json index 0ae1055..c91c06e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,17 @@ "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" - } + }, + "./nuxt": { + "types": "./dist/nuxt.d.ts", + "import": "./dist/nuxt.mjs", + "require": "./dist/nuxt.cjs" + }, + "./nuxt-runtime": { + "types": "./dist/runtime/nitro-plugin.d.ts", + "import": "./dist/runtime/nitro-plugin.mjs" + }, + "./package.json": "./package.json" }, "main": "./dist/index.mjs", "module": "./dist/index.mjs", @@ -64,12 +74,20 @@ "prepare": "simple-git-hooks" }, "peerDependencies": { + "@nuxt/kit": "^3.0.0 || ^4.0.0", "h3": "^1.6.0 || ^2.0.0" }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + }, "devDependencies": { "@antfu/eslint-config": "^0.41.0", "@antfu/ni": "^0.21.6", "@antfu/utils": "^0.7.6", + "@nuxt/kit": "^3.13.0", + "@nuxt/schema": "3.21.11", "@types/node": "^22.20.1", "@types/supertest": "^2.0.12", "@vitest/coverage-v8": "^0.34.3", diff --git a/playground/README.md b/playground/README.md index 595dda9..bf465d6 100644 --- a/playground/README.md +++ b/playground/README.md @@ -61,3 +61,18 @@ yarn preview ``` Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. + +## Note on h3 versions + +Nuxt 3 runs on Nitro 2, which uses **h3 v1**. Because the workspace symlinks +`h3-compression` to the repo root, it resolves `h3` from the root's devDependency — +so build the playground with h3 v1 installed at the root, otherwise Nitro and +`h3-compression` end up with two different h3 majors in the same bundle: + +```bash +pnpm add -D h3@^1.8.0 --ignore-scripts # in the repo root +pnpm build # rebuild dist/ +cd playground && pnpm nuxt:prepare && pnpm build +``` + +This only affects the workspace. A real install resolves `h3` from the consuming app. diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 9d825c4..e620eff 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -1,4 +1,12 @@ // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ devtools: { enabled: true }, + + modules: ['h3-compression/nuxt'], + + compression: { + // Defaults shown for illustration — none of these are required. + encoding: 'zlib', + threshold: 1024, + }, }) diff --git a/playground/package.json b/playground/package.json index 4f7c349..c2fb89d 100644 --- a/playground/package.json +++ b/playground/package.json @@ -6,7 +6,10 @@ "dev": "nuxt dev", "generate": "nuxt generate", "preview": "nuxt preview", - "postinstall": "nuxt prepare" + "nuxt:prepare": "nuxt prepare" + }, + "dependencies": { + "h3-compression": "workspace:*" }, "devDependencies": { "@nuxt/devtools": "^0.8.2", diff --git a/playground/server/api/items.get.ts b/playground/server/api/items.get.ts new file mode 100644 index 0000000..0f58f1d --- /dev/null +++ b/playground/server/api/items.get.ts @@ -0,0 +1,5 @@ +// A `/server/api` route: goes through nitro's `beforeResponse` hook, not +// `render:response`. The module wires both, so this gets compressed too. +export default defineEventHandler(() => ({ + items: Array.from({ length: 50 }, (_, i) => ({ id: i, name: `Item ${i}` })), +})) diff --git a/playground/server/plugins/compression.ts b/playground/server/plugins/compression.ts deleted file mode 100644 index 44732a1..0000000 --- a/playground/server/plugins/compression.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useCompression } from '../../../src' - -export default defineNitroPlugin((nitro) => { - nitro.hooks.hook('render:response', async (response, { event }) => { - if (!response.headers?.['content-type'].startsWith('text/html')) - return - - await useCompression(event, response) - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef52be4..ce4d8c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: '@antfu/utils': specifier: ^0.7.6 version: 0.7.6 + '@nuxt/kit': + specifier: ^3.13.0 + version: 3.21.11 + '@nuxt/schema': + specifier: 3.21.11 + version: 3.21.11 '@types/node': specifier: ^22.20.1 version: 22.20.1 @@ -73,6 +79,10 @@ importers: version: 0.34.3 playground: + dependencies: + h3-compression: + specifier: workspace:* + version: link:.. devDependencies: '@nuxt/devtools': specifier: ^0.8.2 @@ -1223,6 +1233,13 @@ packages: '@sinclair/typebox': 0.27.8 dev: true + /@jridgewell/gen-mapping@0.3.13: + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + dev: true + /@jridgewell/gen-mapping@0.3.2: resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==} engines: {node: '>=6.0.0'} @@ -1232,6 +1249,13 @@ packages: '@jridgewell/trace-mapping': 0.3.17 dev: true + /@jridgewell/remapping@2.3.5: + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + dev: true + /@jridgewell/resolve-uri@3.1.0: resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} engines: {node: '>=6.0.0'} @@ -1257,6 +1281,10 @@ packages: resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} dev: true + /@jridgewell/sourcemap-codec@1.5.5: + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + dev: true + /@jridgewell/trace-mapping@0.3.17: resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==} dependencies: @@ -1264,6 +1292,13 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: true + /@jridgewell/trace-mapping@0.3.31: + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + /@jsdevtools/ez-spawn@3.0.4: resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==} engines: {node: '>=10'} @@ -1297,7 +1332,7 @@ packages: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.5.4 + semver: 7.8.5 tar: 6.1.13 transitivePeerDependencies: - encoding @@ -1358,7 +1393,7 @@ packages: resolution: {integrity: sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dependencies: - semver: 7.5.4 + semver: 7.8.5 dev: true /@npmcli/git@5.0.1: @@ -1371,7 +1406,7 @@ packages: proc-log: 3.0.0 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.5.4 + semver: 7.8.5 which: 3.0.1 transitivePeerDependencies: - bluebird @@ -1442,10 +1477,10 @@ packages: magicast: 0.2.10 pathe: 1.1.1 picocolors: 1.0.0 - pkg-types: 1.0.3 + pkg-types: 1.3.1 prompts: 2.4.2 rc9: 2.1.1 - semver: 7.5.4 + semver: 7.8.5 dev: true /@nuxt/devtools@0.8.2(nuxt@3.7.0)(rollup@3.28.1)(vite@4.4.9): @@ -1505,6 +1540,35 @@ packages: - utf-8-validate dev: true + /@nuxt/kit@3.21.11: + resolution: {integrity: sha512-0Xi3tgwN77w43Q8GCPIrvWmF1J7Peehkts44E0uKNIml9lB8WoUn8YxyUjxBv47XtVR86NWoWALAT+/IEMHJEA==} + engines: {node: '>=18.12.0'} + dependencies: + c12: 3.3.4 + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + errx: 0.1.2 + exsolve: 1.1.1 + ignore: 7.0.6 + jiti: 2.7.0 + klona: 2.0.6 + knitwork: 1.3.0 + mlly: 1.8.2 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.1 + rc9: 3.0.1 + scule: 1.3.0 + semver: 7.8.5 + tinyglobby: 0.2.17 + ufo: 1.6.4 + unctx: 2.5.0 + untyped: 2.0.0 + transitivePeerDependencies: + - magicast + dev: true + /@nuxt/kit@3.7.0(rollup@3.28.1): resolution: {integrity: sha512-bsPRb2NTLHRacjyybhhA3pZFIqo2pxB6bcP4FQDuzlGzVTI5PtJzbfNpkmQC7q+LZt8K0pNlxKVGisDvZctk6w==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1512,17 +1576,17 @@ packages: '@nuxt/schema': 3.7.0(rollup@3.28.1) c12: 1.4.2 consola: 3.2.3 - defu: 6.1.2 + defu: 6.1.7 globby: 13.2.2 hash-sum: 2.0.0 ignore: 5.2.4 jiti: 1.19.3 knitwork: 1.0.0 - mlly: 1.4.1 + mlly: 1.8.2 pathe: 1.1.1 - pkg-types: 1.0.3 + pkg-types: 1.3.1 scule: 1.0.0 - semver: 7.5.4 + semver: 7.8.5 ufo: 1.3.0 unctx: 2.3.1 unimport: 3.2.0(rollup@3.28.1) @@ -1532,15 +1596,26 @@ packages: - supports-color dev: true + /@nuxt/schema@3.21.11: + resolution: {integrity: sha512-5Couz53Pl/SgTwtMQ9jlxVIC6wZk9aAbWyeOD7D6D1UIP5DzV/yV5lPDY/kM0nmiqPSzJf9WLTdJgB2gNm0dOA==} + engines: {node: ^14.18.0 || >=16.10.0} + dependencies: + '@vue/shared': 3.5.41 + defu: 6.1.7 + pathe: 2.0.3 + pkg-types: 2.3.1 + std-env: 4.2.0 + dev: true + /@nuxt/schema@3.7.0(rollup@3.28.1): resolution: {integrity: sha512-fNRAubny1x6rIibm/HcacnEGeAQri/FkJ5ei24aY4YjQ12+xDfi7bljfFr6C2+CrEGc1beYd4OQcUqXqEpz5+g==} engines: {node: ^14.18.0 || >=16.10.0} dependencies: '@nuxt/ui-templates': 1.3.1 - defu: 6.1.2 + defu: 6.1.7 hookable: 5.5.3 pathe: 1.1.1 - pkg-types: 1.0.3 + pkg-types: 1.3.1 postcss-import-resolver: 2.0.0 std-env: 3.4.3 ufo: 1.3.0 @@ -1560,7 +1635,7 @@ packages: ci-info: 3.8.0 consola: 3.2.3 create-require: 1.1.1 - defu: 6.1.2 + defu: 6.1.7 destr: 2.0.1 dotenv: 16.3.1 fs-extra: 11.1.1 @@ -1598,7 +1673,7 @@ packages: clear: 0.1.0 consola: 3.2.3 cssnano: 6.0.1(postcss@8.4.29) - defu: 6.1.2 + defu: 6.1.7 esbuild: 0.19.2 escape-string-regexp: 5.0.0 estree-walker: 3.0.3 @@ -1608,11 +1683,11 @@ packages: h3: 1.8.1 knitwork: 1.0.0 magic-string: 0.30.3 - mlly: 1.4.1 + mlly: 1.8.2 ohash: 1.1.3 pathe: 1.1.1 perfect-debounce: 1.0.0 - pkg-types: 1.0.3 + pkg-types: 1.3.1 postcss: 8.4.29 postcss-import: 15.1.0(postcss@8.4.29) postcss-url: 10.1.3(postcss@8.4.29) @@ -2199,7 +2274,7 @@ packages: debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.4 + semver: 7.8.5 tsutils: 3.21.0(typescript@5.2.2) typescript: 5.2.2 transitivePeerDependencies: @@ -2220,7 +2295,7 @@ packages: debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.4 + semver: 7.8.5 ts-api-utils: 1.0.1(typescript@5.2.2) typescript: 5.2.2 transitivePeerDependencies: @@ -2241,7 +2316,7 @@ packages: '@typescript-eslint/typescript-estree': 5.59.8(typescript@5.2.2) eslint: 8.48.0 eslint-scope: 5.1.1 - semver: 7.5.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color - typescript @@ -2260,7 +2335,7 @@ packages: '@typescript-eslint/types': 6.4.1 '@typescript-eslint/typescript-estree': 6.4.1(typescript@5.2.2) eslint: 8.48.0 - semver: 7.5.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color - typescript @@ -2558,6 +2633,10 @@ packages: resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==} dev: true + /@vue/shared@3.5.41: + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + dev: true + /abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: true @@ -2581,6 +2660,12 @@ packages: hasBin: true dev: true + /acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -2993,7 +3078,7 @@ packages: /builtins@5.0.1: resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==} dependencies: - semver: 7.5.4 + semver: 7.8.5 dev: true /bumpp@9.2.0: @@ -3043,6 +3128,28 @@ packages: - supports-color dev: true + /c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + dev: true + /cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -3190,6 +3297,13 @@ packages: fsevents: 2.3.3 dev: true + /chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + dependencies: + readdirp: 5.1.1 + dev: true + /chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -3206,6 +3320,12 @@ packages: consola: 3.2.3 dev: true + /citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + dependencies: + consola: 3.4.2 + dev: true + /clean-regexp@1.0.0: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} @@ -3349,11 +3469,24 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + dev: true + + /confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + dev: true + /consola@3.2.3: resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} engines: {node: ^14.18.0 || >=16.10.0} dev: true + /consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + dev: true + /console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} dev: true @@ -3670,6 +3803,10 @@ packages: resolution: {integrity: sha512-+uO4+qr7msjNNWKYPHqN/3+Dx3NFkmIzayk2L1MyZQlvgZb/J1A0fo410dpKrN2SnqFjt8n4JL8fDJE0wIgjFQ==} dev: true + /defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dev: true + /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3693,6 +3830,10 @@ packages: resolution: {integrity: sha512-M1Ob1zPSIvlARiJUkKqvAZ3VAqQY6Jcuth/pBKQ2b1dX/Qx0OnJ8Vux6J2H5PTMQeRzWrrbTu70VxBfv/OPDJA==} dev: true + /destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + dev: true + /destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -3790,6 +3931,11 @@ packages: engines: {node: '>=12'} dev: true + /dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dev: true + /duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} dev: true @@ -3881,6 +4027,10 @@ packages: resolution: {integrity: sha512-g/9rfnvnagiNf+DRMHEVGuGuIBlCIMDFoTA616HaP2l9PlCjGjVhD98PNbVSJvmK4TttqT5mV5tInMhoFgi+aA==} dev: true + /errx@0.1.2: + resolution: {integrity: sha512-chfpPHmCerdo/rXr/nNvPZRkV4WwDRwzwnsJ0Uzz3tVi8Z41tDctRjduYy1138ii77AFlts1qvWtX3g/Acg91Q==} + dev: true + /es6-object-assign@1.1.0: resolution: {integrity: sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==} dev: true @@ -4091,7 +4241,7 @@ packages: is-glob: 4.0.3 minimatch: 3.1.2 resolve: 1.22.3 - semver: 7.5.4 + semver: 7.8.5 transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-typescript @@ -4445,11 +4595,15 @@ packages: resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==} dev: true + /exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + dev: true + /externality@1.0.2: resolution: {integrity: sha512-LyExtJWKxtgVzmgtEHyQtLFpw1KFhQphF9nTG8TpAIVkiI/xQ3FJh75tRFLYl4hkn7BNIIdLJInuDAavX35pMw==} dependencies: enhanced-resolve: 5.15.0 - mlly: 1.4.1 + mlly: 1.8.2 pathe: 1.1.1 ufo: 1.3.0 dev: true @@ -4504,6 +4658,18 @@ packages: pend: 1.2.0 dev: true + /fdir@6.5.0(picomatch@4.0.5): + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.5 + dev: true + /fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -4766,7 +4932,7 @@ packages: hasBin: true dependencies: colorette: 2.0.20 - defu: 6.1.2 + defu: 6.1.7 https-proxy-agent: 5.0.1 mri: 1.2.0 node-fetch-native: 1.4.0 @@ -4776,6 +4942,11 @@ packages: - supports-color dev: true + /giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + dev: true + /git-config-path@2.0.0: resolution: {integrity: sha512-qc8h1KIQbJpp+241id3GuAtkdyJ+IK+LIVtkiFTRKRrmddDzs3SI9CvP1QYmWBFvm1I/PWRwj//of8bgAc0ltA==} engines: {node: '>=4'} @@ -4909,7 +5080,7 @@ packages: resolution: {integrity: sha512-m5rFuu+5bpwBBHqqS0zexjK+Q8dhtFRvO9JXQG0RvSPL6QrIT6vv42vuBM22SLOgGMoZYsHk0y7VPidt9s+nkw==} dependencies: cookie-es: 1.0.0 - defu: 6.1.2 + defu: 6.1.7 destr: 2.0.1 iron-webcrypto: 0.8.0 radix3: 1.1.0 @@ -5120,6 +5291,11 @@ packages: engines: {node: '>= 4'} dev: true + /ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + dev: true + /image-meta@0.1.1: resolution: {integrity: sha512-+oXiHwOEPr1IE5zY0tcBLED/CYcre15J4nwL50x3o0jxWqEkyjrusiKP3YSU+tr9fvJp33ZcP5Gpj2295g3aEw==} engines: {node: '>=10.18.0'} @@ -5436,6 +5612,11 @@ packages: hasBin: true dev: true + /jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + dev: true + /joi@17.10.0: resolution: {integrity: sha512-hrazgRSlhzacZ69LdcKfhi3Vu13z2yFfoAzmEov3yFIJlatTdVGUW6vle1zjH8qkzdCn/qGw8rapjqsObbYXAg==} dependencies: @@ -5539,6 +5720,10 @@ packages: resolution: {integrity: sha512-dWl0Dbjm6Xm+kDxhPQJsCBTxrJzuGl0aP9rhr+TG8D3l+GL90N8O8lYUi7dTSAN2uuDqCtNgb6aEuQH5wsiV8Q==} dev: true + /knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + dev: true + /kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} dev: true @@ -5603,12 +5788,12 @@ packages: citty: 0.1.3 clipboardy: 3.0.0 consola: 3.2.3 - defu: 6.1.2 + defu: 6.1.7 get-port-please: 3.0.2 h3: 1.8.1 http-shutdown: 1.2.2 jiti: 1.19.3 - mlly: 1.4.1 + mlly: 1.8.2 node-forge: 1.3.1 pathe: 1.1.1 ufo: 1.3.0 @@ -5759,6 +5944,12 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + dev: true + /magic-string@0.30.3: resolution: {integrity: sha512-B7xGbll2fG/VjP+SWg4sX3JynwIU0mjoTc6MPpKNuIvftk6u6vqhDnk1R80b8C2GBR6ywqy+1DcKBrevBg+bmw==} engines: {node: '>=12'} @@ -5792,7 +5983,7 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} dependencies: - semver: 7.5.4 + semver: 7.8.5 dev: true /make-fetch-happen@11.1.1: @@ -6079,12 +6270,12 @@ packages: optional: true dependencies: citty: 0.1.3 - defu: 6.1.2 + defu: 6.1.7 esbuild: 0.18.17 fs-extra: 11.1.1 globby: 13.2.2 jiti: 1.19.3 - mlly: 1.4.0 + mlly: 1.8.2 mri: 1.2.0 pathe: 1.1.1 typescript: 5.2.2 @@ -6095,7 +6286,7 @@ packages: dependencies: acorn: 8.10.0 pathe: 1.1.1 - pkg-types: 1.0.3 + pkg-types: 1.3.1 ufo: 1.3.0 dev: true @@ -6104,10 +6295,19 @@ packages: dependencies: acorn: 8.10.0 pathe: 1.1.1 - pkg-types: 1.0.3 + pkg-types: 1.3.1 ufo: 1.3.0 dev: true + /mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + dev: true + /mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -6180,7 +6380,7 @@ packages: citty: 0.1.3 consola: 3.2.3 cookie-es: 1.0.0 - defu: 6.1.2 + defu: 6.1.7 destr: 2.0.1 dot-prop: 8.0.2 esbuild: 0.19.2 @@ -6199,7 +6399,7 @@ packages: listhen: 1.4.3 magic-string: 0.30.3 mime: 3.0.0 - mlly: 1.4.1 + mlly: 1.8.2 mri: 1.2.0 node-fetch-native: 1.4.0 ofetch: 1.3.3 @@ -6207,13 +6407,13 @@ packages: openapi-typescript: 6.5.3 pathe: 1.1.1 perfect-debounce: 1.0.0 - pkg-types: 1.0.3 + pkg-types: 1.3.1 pretty-bytes: 6.1.1 radix3: 1.1.0 rollup: 3.28.1 rollup-plugin-visualizer: 5.9.2(rollup@3.28.1) scule: 1.0.0 - semver: 7.5.4 + semver: 7.8.5 serve-placeholder: 2.0.1 serve-static: 1.15.0 std-env: 3.4.3 @@ -6296,7 +6496,7 @@ packages: nopt: 6.0.0 npmlog: 6.0.2 rimraf: 3.0.2 - semver: 7.5.4 + semver: 7.8.5 tar: 6.1.13 which: 2.0.2 transitivePeerDependencies: @@ -6338,7 +6538,7 @@ packages: dependencies: hosted-git-info: 7.0.0 is-core-module: 2.12.1 - semver: 7.5.4 + semver: 7.8.5 validate-npm-package-license: 3.0.4 dev: true @@ -6363,7 +6563,7 @@ packages: resolution: {integrity: sha512-744wat5wAAHsxa4590mWO0tJ8PKxR8ORZsH9wGpQc3nWTzozMAgBN/XyqYw7mg3yqLM8dLwEnwSfKMmXAjF69g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dependencies: - semver: 7.5.4 + semver: 7.8.5 dev: true /npm-normalize-package-bin@3.0.1: @@ -6377,7 +6577,7 @@ packages: dependencies: hosted-git-info: 7.0.0 proc-log: 3.0.0 - semver: 7.5.4 + semver: 7.8.5 validate-npm-package-name: 5.0.0 dev: true @@ -6395,7 +6595,7 @@ packages: npm-install-checks: 6.2.0 npm-normalize-package-bin: 3.0.1 npm-package-arg: 11.0.0 - semver: 7.5.4 + semver: 7.8.5 dev: true /npm-registry-fetch@16.0.0: @@ -6605,6 +6805,10 @@ packages: resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} dev: true + /ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + dev: true + /on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -6844,6 +7048,10 @@ packages: resolution: {integrity: sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==} dev: true + /pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + dev: true + /pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} dev: true @@ -6856,6 +7064,10 @@ packages: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} dev: true + /perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + dev: true + /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} dev: true @@ -6865,6 +7077,11 @@ packages: engines: {node: '>=8.6'} dev: true + /picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + dev: true + /pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -6897,10 +7114,26 @@ packages: resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==} dependencies: jsonc-parser: 3.2.0 - mlly: 1.4.1 + mlly: 1.8.2 pathe: 1.1.1 dev: true + /pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + dev: true + + /pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + dev: true + /pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -7350,11 +7583,18 @@ packages: /rc9@2.1.1: resolution: {integrity: sha512-lNeOl38Ws0eNxpO3+wD1I9rkHGQyj1NU1jlzv4go2CtEnEQEUfqnIvZG7W+bC/aXdJ27n5x/yUjb6RoT9tko+Q==} dependencies: - defu: 6.1.2 + defu: 6.1.7 destr: 2.0.1 flat: 5.0.2 dev: true + /rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + dependencies: + defu: 6.1.7 + destr: 2.0.5 + dev: true + /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true @@ -7437,6 +7677,11 @@ packages: picomatch: 2.3.1 dev: true + /readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + dev: true + /recast@0.23.4: resolution: {integrity: sha512-qtEDqIZGVcSZCHniWwZWbRy79Dc6Wp3kT/UmDA2RJKBPg7+7k51aQBZirHmUGn5uvHf2rg8DkjizrN26k61ATw==} engines: {node: '>= 4'} @@ -7617,6 +7862,10 @@ packages: resolution: {integrity: sha512-4AsO/FrViE/iDNEPaAQlb77tf0csuq27EsVpy6ett584EcRTp6pTDLoGWVxCD77y5iU5FauOvhsI4o1APwPoSQ==} dev: true + /scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + dev: true + /seek-bzip@1.0.6: resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==} hasBin: true @@ -7642,6 +7891,12 @@ packages: lru-cache: 6.0.0 dev: true + /semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + dev: true + /send@0.18.0: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} engines: {node: '>= 0.8.0'} @@ -7672,7 +7927,7 @@ packages: /serve-placeholder@2.0.1: resolution: {integrity: sha512-rUzLlXk4uPFnbEaIz3SW8VISTxMuONas88nYWjAWaM2W9VDbt9tyFOr3lq8RhVOFrT3XISoBw8vni5una8qMnQ==} dependencies: - defu: 6.1.2 + defu: 6.1.7 dev: true /serve-static@1.15.0: @@ -7897,6 +8152,10 @@ packages: resolution: {integrity: sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q==} dev: true + /std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + dev: true + /streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -8015,7 +8274,7 @@ packages: methods: 1.1.2 mime: 2.6.0 qs: 6.11.2 - semver: 7.5.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color dev: true @@ -8155,6 +8414,14 @@ packages: resolution: {integrity: sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==} dev: true + /tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + dev: true + /tinypool@0.7.0: resolution: {integrity: sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==} engines: {node: '>=14.0.0'} @@ -8306,6 +8573,10 @@ packages: resolution: {integrity: sha512-bRn3CsoojyNStCZe0BG0Mt4Nr/4KF+rhFlnNXybgqt5pXHNFRlqinSoQaTrGyzE4X8aHplSb+TorH+COin9Yxw==} dev: true + /ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + dev: true + /ultrahtml@1.4.0: resolution: {integrity: sha512-2SbudS8oD4GNq4en+3ivp25JTCwP5O2soJhIBxGJrjojjLVaLcP84xVU6Xdf0wKMhZvr68rTtrXtO6uvEr2llQ==} dev: true @@ -8369,6 +8640,15 @@ packages: unplugin: 1.4.0 dev: true + /unctx@2.5.0: + resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + dependencies: + acorn: 8.18.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + unplugin: 2.3.11 + dev: true + /undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} dev: true @@ -8384,7 +8664,7 @@ packages: resolution: {integrity: sha512-fjYsXYi30It0YCQYqLOcT6fHfMXsBr2hw9XC7ycf8rTG7Xxpe3ZssiqUnD0khrjiZEmkBXWLwm42yCSCH46fMw==} dependencies: consola: 3.2.3 - defu: 6.1.2 + defu: 6.1.7 mime: 3.0.0 node-fetch-native: 1.4.0 pathe: 1.1.1 @@ -8407,9 +8687,9 @@ packages: fast-glob: 3.3.1 local-pkg: 0.4.3 magic-string: 0.30.3 - mlly: 1.4.1 + mlly: 1.8.2 pathe: 1.1.1 - pkg-types: 1.0.3 + pkg-types: 1.3.1 scule: 1.0.0 strip-literal: 1.3.0 unplugin: 1.4.0 @@ -8458,7 +8738,7 @@ packages: fast-glob: 3.3.1 json5: 2.2.3 local-pkg: 0.4.3 - mlly: 1.4.1 + mlly: 1.8.2 pathe: 1.1.1 scule: 1.0.0 unplugin: 1.4.0 @@ -8478,6 +8758,16 @@ packages: webpack-virtual-modules: 0.5.0 dev: true + /unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.18.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + dev: true + /unstorage@1.9.0: resolution: {integrity: sha512-VpD8ZEYc/le8DZCrny3bnqKE4ZjioQxBRnWE+j5sGNvziPjeDlaS1NaFFHzl/kkXaO3r7UaF8MGQrs14+1B4pQ==} peerDependencies: @@ -8552,7 +8842,7 @@ packages: '@babel/core': 7.22.11 '@babel/standalone': 7.22.13 '@babel/types': 7.22.11 - defu: 6.1.2 + defu: 6.1.7 jiti: 1.19.3 mri: 1.2.0 scule: 1.0.0 @@ -8560,6 +8850,17 @@ packages: - supports-color dev: true + /untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + dependencies: + citty: 0.1.6 + defu: 6.1.7 + jiti: 2.7.0 + knitwork: 1.3.0 + scule: 1.3.0 + dev: true + /update-browserslist-db@1.0.11(browserslist@4.21.10): resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} hasBin: true @@ -8629,7 +8930,7 @@ packages: dependencies: cac: 6.7.14 debug: 4.3.4 - mlly: 1.4.1 + mlly: 1.8.2 pathe: 1.1.1 picocolors: 1.0.0 vite: 4.4.9(@types/node@22.20.1) @@ -8651,7 +8952,7 @@ packages: dependencies: cac: 6.7.14 debug: 4.3.4 - mlly: 1.4.0 + mlly: 1.4.1 pathe: 1.1.1 picocolors: 1.0.0 vite: 4.4.9(@types/node@20.5.7) @@ -8708,7 +9009,7 @@ packages: lodash.debounce: 4.0.8 lodash.pick: 4.4.0 npm-run-path: 4.0.1 - semver: 7.5.4 + semver: 7.8.5 strip-ansi: 6.0.1 tiny-invariant: 1.3.1 typescript: 5.2.2 @@ -8910,7 +9211,7 @@ packages: engines: {vscode: ^1.52.0} dependencies: minimatch: 3.1.2 - semver: 7.5.4 + semver: 7.8.5 vscode-languageserver-protocol: 3.16.0 dev: true @@ -8963,7 +9264,7 @@ packages: espree: 9.6.1 esquery: 1.5.0 lodash: 4.17.21 - semver: 7.5.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color dev: true @@ -9019,6 +9320,10 @@ packages: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} dev: true + /webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + dev: true + /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: diff --git a/src/compression.ts b/src/compression.ts index 441360c..099aafc 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -72,5 +72,5 @@ export async function useCompression( ): Promise { const compression = getAnyCompression(event, options) if (compression) - await compress(event, response, compression) + await compress(event, response, compression, options) } diff --git a/src/helper.ts b/src/helper.ts index 097ae66..4b5ff83 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -28,6 +28,18 @@ export interface CompressionOptions { * @default false */ zstd?: boolean + + /** + * Skip compression for bodies smaller than this many bytes. Small payloads + * usually get *larger* once framing overhead is added, so compressing a + * 40-byte JSON response is a pure loss. Measured on the serialized body, so + * it applies to object bodies too. + * + * Only applies to the buffered (zlib) path — a stream's size is not known + * up front. + * @default 0 + */ + minSize?: number } export interface StreamCompressionOptions extends CompressionOptions { @@ -214,7 +226,12 @@ function toCompressibleBuffer(event: H3Event, body: unknown): Buffer | undefined } } -export async function compress(event: H3Event, response: Partial, method: Compression) { +export async function compress( + event: H3Event, + response: Partial, + method: Compression, + options: CompressionOptions = {}, +) { const acceptsEncoding = getRequestHeader(event, 'accept-encoding')?.includes( method, ) @@ -222,7 +239,7 @@ export async function compress(event: H3Event, response: Partial return const payload = toCompressibleBuffer(event, response.body) - if (!payload) + if (!payload || payload.byteLength < (options.minSize ?? 0)) return const compression = createCompressor(method) @@ -260,11 +277,23 @@ function ensureToResponse(): NonNullable { return toResponse } -function cloneResponse(response: Response, body: BodyInit, method: string): Response { +/** + * Rebuilds a `Response` around a new body. Reading `response.arrayBuffer()` + * consumes the original, so even the "not compressed after all" paths have to + * go through here rather than returning the drained original. + * @param { Response } response - The response to copy status and headers from. + * @param { BodyInit } body - The replacement body. + * @param { string } [method] - Content-Encoding to set; omitted for a plain passthrough. + * @returns { Response } + */ +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') + + if (method) { + 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, @@ -295,8 +324,8 @@ export async function compressResponse( return response const body = new Uint8Array(await response.arrayBuffer()) - if (body.byteLength === 0) - return response + if (body.byteLength === 0 || body.byteLength < (options?.minSize ?? 0)) + return cloneResponse(response, body) const compression = createCompressor(compressionMethod) diff --git a/src/index.ts b/src/index.ts index d2a7da1..a3633bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,8 @@ export { } from './middleware' export { + compress, + compressStream, compressResponse, compressResponseStream, getAnyCompression, diff --git a/src/nuxt.ts b/src/nuxt.ts new file mode 100644 index 0000000..0828daa --- /dev/null +++ b/src/nuxt.ts @@ -0,0 +1,73 @@ +import { addServerPlugin, addTemplate, defineNuxtModule } from '@nuxt/kit' +import type { Nuxt } from '@nuxt/schema' +import type { NuxtCompressionOptions } from './runtime/types' +import { DEFAULT_CONTENT_TYPES, DEFAULT_EXCLUDE } from './runtime/nitro-plugin' + +export type { NuxtCompressionOptions } from './runtime/types' + +const DEFAULTS: Required> = { + enabled: true, + encoding: 'zlib', + brotli: false, + zstd: false, + contentTypes: DEFAULT_CONTENT_TYPES, + exclude: DEFAULT_EXCLUDE, + routeRules: true, + threshold: 0, +} + +/** + * Nuxt module wiring the compression into Nitro's response hooks. + * + * @example + * ```ts + * // nuxt.config.ts + * export default defineNuxtConfig({ + * modules: ['h3-compression/nuxt'], + * compression: { + * zstd: true, + * threshold: 1024, + * }, + * }) + * ``` + */ +export default defineNuxtModule({ + meta: { + name: 'h3-compression', + configKey: 'compression', + compatibility: { + nuxt: '>=3.0.0', + }, + }, + defaults: DEFAULTS, + // Annotated explicitly: `defineNuxtModule`'s conditional signature does not + // contextually type these under the TypeScript version this repo builds with. + setup(options: NuxtCompressionOptions, _nuxt: Nuxt) { + if (options.enabled === false) + return + + // The plugin is generated rather than shipped ready-made so the resolved + // options are baked in at build time. That keeps the runtime free of + // `useRuntimeConfig` / virtual-module lookups, which each depend on Nitro + // auto-import behaviour that has moved between majors. + // + // The import uses the published subpath rather than an absolute path so + // Nitro resolves and bundles it like any other dependency — an absolute + // path would survive into the build output and break on deploy. + const template = addTemplate({ + filename: 'h3-compression-plugin.mjs', + write: true, + getContents: () => [ + 'import { createCompressionPlugin } from \'h3-compression/nuxt-runtime\'', + '', + `export default createCompressionPlugin(${JSON.stringify(options, null, 2)})`, + '', + ].join('\n'), + }) + + addServerPlugin(template.dst) + }, +}) diff --git a/src/runtime/nitro-plugin.ts b/src/runtime/nitro-plugin.ts new file mode 100644 index 0000000..23da78c --- /dev/null +++ b/src/runtime/nitro-plugin.ts @@ -0,0 +1,174 @@ +import type { H3Event } from 'h3' + +// Self-referencing import rather than `../helper`: this file is shipped as a +// standalone module under `dist/runtime/`, where a relative path out of the +// directory would not resolve against the bundled `dist/index.mjs`. +import type { Compression, RenderResponse, StreamCompression } from 'h3-compression' +import { compress, compressStream, getAnyCompression, getStreamCompression } from 'h3-compression' +import type { NuxtCompressionOptions } from './types' + +/** + * The subset of the Nitro app this plugin touches. Typed structurally so the + * runtime carries no Nitro import — `defineNitroPlugin` is an identity + * function whose import path has moved between Nitro majors, and the plugin + * contract is just "default-export a function taking the nitro app". + */ +export interface NitroAppLike { + hooks: { + hook: (name: string, handler: (...args: any[]) => unknown) => unknown + } +} + +/** + * Nitro passes the event as the second element for `render:response` and as + * the first for `beforeResponse`, so both hooks are normalised before use. + */ +export type HookResponse = Partial & { headers?: Record } + +export const DEFAULT_CONTENT_TYPES = [ + 'text/', + 'application/json', + 'application/javascript', + 'application/xml', + 'image/svg+xml', +] + +export const DEFAULT_EXCLUDE = ['/_nuxt', '/__nuxt'] + +/** + * Resolves the request path the exclusion list is matched against. + * + * h3 v2 exposes `event.url` (a `URL`) and keeps `path` only as a deprecated + * getter, so that is read first. On h3 v1 a prefix-mounted handler + * (`app.use('/prefix', handler)`) rewrites `event.path` to the *remainder*, + * which would silently defeat prefix exclusions — the node adapter keeps the + * untouched path on `req.originalUrl`, so prefer that when it is there. + * @param { H3Event } event - A H3 event object. + * @returns { string } + */ +export function getPath(event: H3Event): string { + const url = (event as unknown as { url?: URL }).url + if (url && typeof url === 'object' && typeof url.pathname === 'string') + return url.pathname + + const originalUrl = (event as unknown as { node?: { req?: { originalUrl?: string } } }) + .node?.req?.originalUrl + if (typeof originalUrl === 'string') + return originalUrl.split('?')[0] + + const path = (event as unknown as { path?: string }).path + return typeof path === 'string' ? path.split('?')[0] : '/' +} + +/** + * Reads the response content type from whichever shape the hook provided. + * `render:response` carries a plain header record, `beforeResponse` may carry + * none at all — in which case the already-set response headers are used. + * @param { H3Event } event - A H3 event object. + * @param { HookResponse } response - The response object handed over by the hook. + * @returns { string } + */ +export function getContentType(event: H3Event, response: HookResponse): string { + const headers = response.headers + if (headers) { + const direct = headers['content-type'] ?? headers['Content-Type'] + if (typeof direct === 'string') + return direct + } + + const resHeaders = (event as unknown as { res?: { headers?: Headers } }).res?.headers + return resHeaders?.get?.('content-type') ?? '' +} + +/** + * Decides whether a response should be compressed at all. Exported so the + * filtering can be tested without booting Nitro. + * @param { H3Event } event - A H3 event object. + * @param { HookResponse } response - The response object handed over by the hook. + * @param { NuxtCompressionOptions } options - The resolved module options. + * @returns { boolean } + */ +export function shouldCompress( + event: H3Event, + response: HookResponse, + options: NuxtCompressionOptions, +): boolean { + if (options.enabled === false) + return false + + const exclude = options.exclude ?? DEFAULT_EXCLUDE + const path = getPath(event) + if (exclude.some(prefix => path.startsWith(prefix))) + return false + + const contentTypes = options.contentTypes ?? DEFAULT_CONTENT_TYPES + if (contentTypes.length === 0) + return true + + const contentType = getContentType(event, response) + // An unset content type means the handler returned a plain object, which + // h3 serializes as JSON — compressible, so don't filter it out. + if (!contentType) + return true + + return contentTypes.some(type => contentType.startsWith(type)) +} + +/** + * Runs the configured compression against one hook payload. + * @param { H3Event } event - A H3 event object. + * @param { HookResponse } response - The response object handed over by the hook. + * @param { NuxtCompressionOptions } options - The resolved module options. + * @returns { Promise } + */ +export async function applyCompression( + event: H3Event, + response: HookResponse, + options: NuxtCompressionOptions, +): Promise { + if (!shouldCompress(event, response, options)) + return + + if (options.encoding === 'stream') { + const method = (options.method as StreamCompression | undefined) + ?? getStreamCompression(event, { brotli: options.brotli, zstd: options.zstd }) + + if (method) + await compressStream(event, response, method) + + return + } + + const method = (options.method as Compression | undefined) + ?? getAnyCompression(event, { zstd: options.zstd }) + + if (method) + await compress(event, response, method, { minSize: options.threshold }) +} + +/** + * Builds the Nitro plugin. The Nuxt module generates a one-line file that + * calls this with the resolved options, so nothing has to be read from + * auto-imported globals or virtual modules at runtime. + * @param { NuxtCompressionOptions } options - The resolved module options. + * @returns { (nitro: NitroAppLike) => void } + */ +export function createCompressionPlugin(options: NuxtCompressionOptions) { + return (nitro: NitroAppLike): void => { + if (options.enabled === false) + return + + // Freshly rendered SSR pages. + nitro.hooks.hook('render:response', async (response: HookResponse, context: { event: H3Event }) => { + await applyCompression(context.event, response, options) + }) + + // Cached (swr / isr) routes and `/server/api` handlers never reach + // `render:response` — they go through `beforeResponse` instead. + if (options.routeRules !== false) { + nitro.hooks.hook('beforeResponse', async (event: H3Event, response: HookResponse) => { + await applyCompression(event, response, options) + }) + } + } +} diff --git a/src/runtime/types.ts b/src/runtime/types.ts new file mode 100644 index 0000000..b17ab73 --- /dev/null +++ b/src/runtime/types.ts @@ -0,0 +1,87 @@ +import type { Compression, StreamCompression } from 'h3-compression' + +/** + * Options for the Nuxt module, available under the `compression` key in + * `nuxt.config`. + * + * Declared as a type alias rather than an interface on purpose: `@nuxt/kit` + * constrains module options to `Record`, which interfaces do not + * satisfy (they have no implicit index signature). + */ +export interface NuxtCompressionOptions { + /** + * Turn compression on or off without removing the module. + * @default true + */ + enabled?: boolean + + /** + * `'zlib'` buffers the body and compresses it in one go — this is the only + * mode that reliably works across Nitro's hooks. + * + * `'stream'` pipes the body through a compression transform. It keeps + * streamed responses streamed, but Nitro's `render:response` hands over an + * already-materialised body, so it only pays off for handlers that actually + * stream. + * @default 'zlib' + */ + encoding?: 'zlib' | 'stream' + + /** + * Consider brotli when negotiating `Accept-Encoding`. + * + * Only meaningful for `encoding: 'stream'` — the zlib path always prefers + * brotli when the client accepts it. + * @default false + */ + brotli?: boolean + + /** + * Consider zstd when negotiating `Accept-Encoding`. Requires Node >= 22.15; + * on older runtimes the flag is ignored and the next accepted encoding is + * used. + * @default false + */ + zstd?: boolean + + /** + * Force a single compression method instead of negotiating from + * `Accept-Encoding`. Rarely what you want — a client that does not accept it + * gets an uncompressed response. + */ + method?: Compression | StreamCompression + + /** + * Compress only responses whose `Content-Type` starts with one of these. + * Keeps images, fonts and other already-compressed assets untouched. + * + * Set to `[]` to compress every content type. + * @default ['text/', 'application/json', 'application/javascript', 'application/xml', 'image/svg+xml'] + */ + contentTypes?: string[] + + /** + * Skip requests whose path starts with one of these prefixes. The defaults + * cover Nuxt's own asset and payload routes — compressing them breaks the + * error page. + * @default ['/_nuxt', '/__nuxt'] + */ + exclude?: string[] + + /** + * Also attach to Nitro's `beforeResponse` hook, which is what cached + * (`swr` / `isr`) routes and `/server/api` handlers go through. + * `render:response` alone only covers freshly rendered SSR pages. + * @default true + */ + routeRules?: boolean + + /** + * Skip bodies smaller than this many bytes — below roughly a kilobyte + * compression tends to make the payload larger. + * + * Ignored for `encoding: 'stream'`, where the size is not known up front. + * @default 0 + */ + threshold?: number +} diff --git a/test/compression-v1.test.ts b/test/compression-v1.test.ts index a0abe57..cde13ef 100644 --- a/test/compression-v1.test.ts +++ b/test/compression-v1.test.ts @@ -5,6 +5,7 @@ import supertest from 'supertest' import { beforeEach, describe, expect, it } from 'vitest' import * as h3 from 'h3' import { isZstdSupported, useCompression, useCompressionStream } from '../src' +import { applyCompression } from '../src/runtime/nitro-plugin' import { isV1 } from './_version' // `node:zlib` gained zstd in Node 22.15.0 / 23.8.0. @@ -177,3 +178,38 @@ describe.runIf(isV1)('zstd on the h3 v1 app hook (#7)', () => { expect(result.text).toEqual(html) }) }) + +describe.runIf(isV1)('nuxt plugin on h3 v1 (#22)', () => { + it('compresses an html response through the plugin logic', async () => { + const request = appWith((event, response) => applyCompression(event, response, {})) + 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('skips nuxt internal routes using the v1 `path` property', async () => { + const app = createApp({ + debug: true, + onBeforeResponse: (event, response) => applyCompression(event, response, {}), + }) + app.use('/_nuxt/entry.js', eventHandler(() => html)) + const result = await supertest(toNodeListener(app)) + .get('/_nuxt/entry.js') + .set('Accept-Encoding', 'gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toBeUndefined() + expect(result.text).toEqual(html) + }) + + it('respects the threshold on the v1 path', async () => { + const request = appWith((event, response) => applyCompression(event, response, { threshold: 4096 })) + const result = await request.get('/').set('Accept-Encoding', 'gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toBeUndefined() + expect(result.text).toEqual(html) + }) +}) diff --git a/test/nuxt-plugin.test.ts b/test/nuxt-plugin.test.ts new file mode 100644 index 0000000..2225f3b --- /dev/null +++ b/test/nuxt-plugin.test.ts @@ -0,0 +1,291 @@ +import { Buffer } from 'node:buffer' +import zlib from 'node:zlib' +import { describe, expect, it, vi } from 'vitest' +import * as h3 from 'h3' +import { + DEFAULT_CONTENT_TYPES, + DEFAULT_EXCLUDE, + applyCompression, + createCompressionPlugin, + getContentType, + getPath, + shouldCompress, +} from '../src/runtime/nitro-plugin' +import type { NuxtCompressionOptions } from '../src/runtime/types' +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

'.repeat(20) + +function eventFor(path = '/', encoding = 'gzip') { + return mockEvent(path, { headers: { 'accept-encoding': encoding } }) +} + +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)('nuxt plugin — path handling (#22)', () => { + it('reads the pathname from the h3 v2 url object', () => { + expect(getPath(eventFor('/api/items?page=2'))).toEqual('/api/items') + }) + + it('falls back to the h3 v1 `path` property', () => { + const v1Event = { path: '/legacy/route?x=1' } as any + + expect(getPath(v1Event)).toEqual('/legacy/route') + }) + + it('prefers the untouched originalUrl over a prefix-stripped path (#22)', () => { + // h3 v1 rewrites `event.path` to the remainder for prefix-mounted + // handlers, which would defeat the `/_nuxt` exclusion. + const v1Event = { path: '/', node: { req: { originalUrl: '/_nuxt/entry.js?v=1' } } } as any + + expect(getPath(v1Event)).toEqual('/_nuxt/entry.js') + expect(shouldCompress(v1Event, {}, {})).toBe(false) + }) + + it('degrades to "/" when neither is present', () => { + expect(getPath({} as any)).toEqual('/') + }) +}) + +describe.runIf(isV2)('nuxt plugin — content type handling (#22)', () => { + it('prefers the header record handed over by the hook', () => { + const event = eventFor() + + expect(getContentType(event, { headers: { 'content-type': 'text/html' } })).toEqual('text/html') + }) + + it('accepts the capitalised header key nitro sometimes uses', () => { + const event = eventFor() + + expect(getContentType(event, { headers: { 'Content-Type': 'application/json' } })).toEqual('application/json') + }) + + it('falls back to the response headers already set on the event', () => { + const event = eventFor() + event.res.headers.set('content-type', 'text/plain') + + expect(getContentType(event, {})).toEqual('text/plain') + }) +}) + +describe.runIf(isV2)('nuxt plugin — shouldCompress (#22)', () => { + it('skips nuxt internal routes by default', () => { + for (const path of DEFAULT_EXCLUDE) { + const event = eventFor(`${path}/entry.js`) + + expect(shouldCompress(event, { headers: { 'content-type': 'text/javascript' } }, {})).toBe(false) + } + }) + + it('compresses a normal html route', () => { + const event = eventFor('/about') + + expect(shouldCompress(event, { headers: { 'content-type': 'text/html' } }, {})).toBe(true) + }) + + it('skips content types outside the allow list', () => { + const event = eventFor('/logo.png') + + expect(shouldCompress(event, { headers: { 'content-type': 'image/png' } }, {})).toBe(false) + }) + + it('covers every default content type', () => { + for (const type of DEFAULT_CONTENT_TYPES) { + const event = eventFor('/x') + + expect(shouldCompress(event, { headers: { 'content-type': type } }, {})).toBe(true) + } + }) + + it('treats a missing content type as compressible (object body → JSON)', () => { + const event = eventFor('/api/items') + + expect(shouldCompress(event, {}, {})).toBe(true) + }) + + it('compresses every content type when the list is emptied', () => { + const event = eventFor('/logo.png') + + expect(shouldCompress(event, { headers: { 'content-type': 'image/png' } }, { contentTypes: [] })).toBe(true) + }) + + it('honours a custom exclude list instead of the defaults', () => { + const options: NuxtCompressionOptions = { exclude: ['/private'] } + + expect(shouldCompress(eventFor('/private/x'), {}, options)).toBe(false) + // The defaults no longer apply once overridden. + expect(shouldCompress(eventFor('/_nuxt/x'), {}, options)).toBe(true) + }) + + it('returns false when disabled', () => { + expect(shouldCompress(eventFor('/about'), {}, { enabled: false })).toBe(false) + }) +}) + +describe.runIf(isV2)('nuxt plugin — applyCompression (#22)', () => { + it('compresses an allowed response with the negotiated encoding', async () => { + const event = eventFor('/about') + const response: { body: unknown; headers: Record } = { + body: html, + headers: { 'content-type': 'text/html' }, + } + + await applyCompression(event, response, {}) + + expect(event.res.headers.get('content-encoding')).toEqual('gzip') + expect(zlib.gunzipSync(response.body as Buffer).toString()).toEqual(html) + }) + + it('leaves an excluded response untouched', async () => { + const event = eventFor('/_nuxt/entry.js') + const response: { body: unknown; headers: Record } = { + body: html, + headers: { 'content-type': 'text/javascript' }, + } + + await applyCompression(event, response, {}) + + expect(event.res.headers.get('content-encoding')).toBeNull() + expect(response.body).toEqual(html) + }) + + it('skips bodies below the threshold', async () => { + const event = eventFor('/about') + const response: { body: unknown; headers: Record } = { + body: 'tiny', + headers: { 'content-type': 'text/html' }, + } + + await applyCompression(event, response, { threshold: 1024 }) + + expect(event.res.headers.get('content-encoding')).toBeNull() + expect(response.body).toEqual('tiny') + }) + + it('compresses bodies at or above the threshold', async () => { + const event = eventFor('/about') + const response: { body: unknown; headers: Record } = { + body: html, + headers: { 'content-type': 'text/html' }, + } + + await applyCompression(event, response, { threshold: 8 }) + + expect(event.res.headers.get('content-encoding')).toEqual('gzip') + expect(zlib.gunzipSync(response.body as Buffer).toString()).toEqual(html) + }) + + it('uses the stream path when encoding is "stream"', async () => { + const event = eventFor('/about') + const response: { body: unknown; headers: Record } = { + body: html, + headers: { 'content-type': 'text/html' }, + } + + await applyCompression(event, response, { encoding: 'stream' }) + + expect(event.res.headers.get('content-encoding')).toEqual('gzip') + expect(zlib.gunzipSync(await readStream(response.body as ReadableStream)).toString()).toEqual(html) + }) + + it('picks brotli on the stream path only when enabled', async () => { + const withoutFlag = eventFor('/about', 'br, gzip') + await applyCompression(withoutFlag, { body: html, headers: { 'content-type': 'text/html' } }, { encoding: 'stream' }) + expect(withoutFlag.res.headers.get('content-encoding')).toEqual('gzip') + + const withFlag = eventFor('/about', 'br, gzip') + const response: { body: unknown } = { body: html } + await applyCompression(withFlag, { ...response, headers: { 'content-type': 'text/html' } }, { encoding: 'stream', brotli: true }) + expect(withFlag.res.headers.get('content-encoding')).toEqual('br') + }) + + it('honours a forced method over negotiation', async () => { + const event = eventFor('/about', 'gzip, deflate') + const response: { body: unknown; headers: Record } = { + body: html, + headers: { 'content-type': 'text/html' }, + } + + await applyCompression(event, response, { method: 'deflate' }) + + expect(event.res.headers.get('content-encoding')).toEqual('deflate') + expect(zlib.inflateSync(response.body as Buffer).toString()).toEqual(html) + }) +}) + +describe.runIf(isV2)('nuxt plugin — hook wiring (#22)', () => { + function fakeNitro() { + const hooks: Record unknown> = {} + return { + hooks: { hook: vi.fn((name: string, fn: any) => { hooks[name] = fn }) }, + registered: hooks, + } + } + + it('attaches to both hooks by default', () => { + const nitro = fakeNitro() + + createCompressionPlugin({})(nitro) + + expect(Object.keys(nitro.registered).sort()).toEqual(['beforeResponse', 'render:response']) + }) + + it('skips beforeResponse when routeRules is off', () => { + const nitro = fakeNitro() + + createCompressionPlugin({ routeRules: false })(nitro) + + expect(Object.keys(nitro.registered)).toEqual(['render:response']) + }) + + it('registers nothing when disabled', () => { + const nitro = fakeNitro() + + createCompressionPlugin({ enabled: false })(nitro) + + expect(nitro.hooks.hook).not.toHaveBeenCalled() + }) + + it('compresses through the render:response hook', async () => { + const nitro = fakeNitro() + createCompressionPlugin({})(nitro) + + const event = eventFor('/about') + const response: { body: unknown; headers: Record } = { + body: html, + headers: { 'content-type': 'text/html' }, + } + + await nitro.registered['render:response'](response, { event }) + + expect(event.res.headers.get('content-encoding')).toEqual('gzip') + expect(zlib.gunzipSync(response.body as Buffer).toString()).toEqual(html) + }) + + it('compresses through the beforeResponse hook (cached routes, /server/api)', async () => { + const nitro = fakeNitro() + createCompressionPlugin({})(nitro) + + const event = eventFor('/api/items') + const response: { body: unknown } = { body: { items: [1, 2, 3], message: html } } + + await nitro.registered.beforeResponse(event, response) + + expect(event.res.headers.get('content-encoding')).toEqual('gzip') + expect(JSON.parse(zlib.gunzipSync(response.body as Buffer).toString())).toEqual({ items: [1, 2, 3], message: html }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index e35bde3..99fbf80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,14 +6,24 @@ "esnext", "dom" ], - "moduleResolution": "node", + "moduleResolution": "bundler", "esModuleInterop": true, "strict": true, "strictNullChecks": true, "resolveJsonModule": true, "skipLibCheck": true, "skipDefaultLibCheck": true, - "types": ["node"] + "types": [ + "node" + ], + "paths": { + "h3-compression": [ + "./src/index.ts" + ] + } }, - "include": ["src", "test"] + "include": [ + "src", + "test" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 1ec9336..68e063d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,14 @@ +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' export default defineConfig({ + resolve: { + alias: { + // `src/runtime/` self-references the package so the shipped + // `dist/runtime/` files resolve; point that back at the source in tests. + 'h3-compression': fileURLToPath(new URL('./src/index.ts', import.meta.url)), + }, + }, test: { setupFiles: ['./test/_setup'], coverage: {