From 360678e6260d3496cf0a4f211ed1f499fd865b56 Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Thu, 6 Aug 2026 10:41:27 +0200 Subject: [PATCH 1/2] feat: ship a Nuxt module on the h3-compression/nuxt subpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using this in Nuxt meant hand-writing a Nitro plugin and getting four things right: which hook to attach to, that cached (swr/isr) routes and /server/api go through `beforeResponse` rather than `render:response`, that `/_nuxt` and `/__nuxt` must be skipped or the error page breaks, and that binary assets need a content-type guard. That collapses to: export default defineNuxtConfig({ modules: ['h3-compression/nuxt'], }) with everything configurable under the `compression` key — enabled, encoding ('zlib' | 'stream'), brotli, zstd, method, contentTypes, exclude, routeRules and threshold. The module generates a one-line Nitro plugin into the build dir that calls `createCompressionPlugin(resolvedOptions)`, importing the runtime through the new `./nuxt-runtime` subpath. Baking the options in at build time keeps the runtime free of `useRuntimeConfig` / virtual-module lookups and of `defineNitroPlugin`, all of which depend on Nitro behaviour that has moved between majors. Importing via the published subpath rather than an absolute path means Nitro resolves and bundles it like any other dependency instead of leaving a machine-specific path in the output. `getPath` prefers `req.originalUrl` over `event.path` on h3 v1: a prefix-mounted handler rewrites `event.path` to the remainder, which silently defeated the `/_nuxt` exclusion. Caught by an end-to-end test, not a unit one. Supporting changes: - `minSize` option on `compress()` / `compressResponse()`, surfaced as the module's `threshold`. It belongs in core because the buffer is already computed there — measuring in the plugin would stringify object bodies twice. - `cloneResponse` no longer forces a Content-Encoding, so the paths that decide *not* to compress can still rebuild the response. They have to: `response.arrayBuffer()` has already drained the original by then. - `compress` / `compressStream` are exported publicly, mirroring the existing `compressResponse` / `compressResponseStream`. - `@nuxt/kit` as an optional peer dependency — non-Nuxt users must not be forced to install it. - `moduleResolution: "bundler"`, without which TypeScript cannot see `@nuxt/kit`'s exports-based types at all. - playground switched from the hand-written plugin to the module, plus a `/server/api` route so the `beforeResponse` path is exercised. Verified end to end against a real `nuxt build`: SSR page and /server/api compressed, /_nuxt excluded, `identity` untouched, brotli negotiated, no server errors. Closes #22 --- README.md | 76 ++++- build.config.ts | 13 + package.json | 20 +- playground/README.md | 15 + playground/nuxt.config.ts | 8 + playground/package.json | 3 + playground/server/api/items.get.ts | 5 + playground/server/plugins/compression.ts | 10 - pnpm-lock.yaml | 413 ++++++++++++++++++++--- src/compression.ts | 2 +- src/helper.ts | 45 ++- src/index.ts | 2 + src/nuxt.ts | 73 ++++ src/runtime/nitro-plugin.ts | 174 ++++++++++ src/runtime/types.ts | 87 +++++ test/compression-v1.test.ts | 36 ++ test/nuxt-plugin.test.ts | 291 ++++++++++++++++ tsconfig.json | 16 +- vitest.config.ts | 8 + 19 files changed, 1203 insertions(+), 94 deletions(-) create mode 100644 playground/server/api/items.get.ts delete mode 100644 playground/server/plugins/compression.ts create mode 100644 src/nuxt.ts create mode 100644 src/runtime/nitro-plugin.ts create mode 100644 src/runtime/types.ts create mode 100644 test/nuxt-plugin.test.ts 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..d958444 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 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..22947fd 100644 --- a/playground/package.json +++ b/playground/package.json @@ -8,6 +8,9 @@ "preview": "nuxt preview", "postinstall": "nuxt prepare" }, + "dependencies": { + "h3-compression": "workspace:*" + }, "devDependencies": { "@nuxt/devtools": "^0.8.2", "nuxt": "^3.7.0" 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: { From d42bd0566b52f8bee4b3f44f50e9f3aacc5bc4e9 Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Thu, 6 Aug 2026 10:44:23 +0200 Subject: [PATCH 2/2] fix(playground): drop the install-time nuxt prepare hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `postinstall`/`prepare` run during `pnpm install`, which is always before `pnpm build` — so `nuxt prepare` tried to load `h3-compression/nuxt` from a `dist/` that did not exist yet and failed every CI job. Renamed to the non-lifecycle `nuxt:prepare`. --- playground/README.md | 2 +- playground/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/README.md b/playground/README.md index d958444..bf465d6 100644 --- a/playground/README.md +++ b/playground/README.md @@ -72,7 +72,7 @@ so build the playground with h3 v1 installed at the root, otherwise Nitro and ```bash pnpm add -D h3@^1.8.0 --ignore-scripts # in the repo root pnpm build # rebuild dist/ -cd playground && pnpm build +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/package.json b/playground/package.json index 22947fd..c2fb89d 100644 --- a/playground/package.json +++ b/playground/package.json @@ -6,7 +6,7 @@ "dev": "nuxt dev", "generate": "nuxt generate", "preview": "nuxt preview", - "postinstall": "nuxt prepare" + "nuxt:prepare": "nuxt prepare" }, "dependencies": { "h3-compression": "workspace:*"