Skip to content

feat: ship a Nuxt module on the h3-compression/nuxt subpath - #23

Merged
CodeDredd merged 2 commits into
mainfrom
feat/nuxt-module
Aug 6, 2026
Merged

feat: ship a Nuxt module on the h3-compression/nuxt subpath#23
CodeDredd merged 2 commits into
mainfrom
feat/nuxt-module

Conversation

@CodeDredd

Copy link
Copy Markdown
Owner

Closes #22

What

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['h3-compression/nuxt'],
})

That replaces the hand-written Nitro plugin the README used to teach, along with the four things it required you to get 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.

Everything is configurable under the compression key:

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 transform
brotli false Consider brotli when negotiating. Only meaningful for 'stream' — the zlib path already prefers it
zstd false Consider zstd when negotiating. Ignored on Node < 22.15
method Force one method instead of negotiating
contentTypes text, JSON, JS, XML, SVG Prefix match against Content-Type. [] compresses everything
exclude ['/_nuxt', '/__nuxt'] Path prefixes to skip
routeRules true Also attach to beforeResponse
threshold 0 Skip bodies below this size

How it's wired

The module generates a one-line Nitro plugin into the build dir:

import { createCompressionPlugin } from 'h3-compression/nuxt-runtime'

export default createCompressionPlugin({ /* resolved options */ })

Two deliberate choices there:

  • Options baked in at build time, rather than read at runtime. That keeps the runtime free of useRuntimeConfig, virtual modules and defineNitroPlugin — all of which depend on Nitro behaviour that has moved between majors. defineNitroPlugin is an identity function anyway; a plain default-exported function is the whole contract.
  • Imported through the published ./nuxt-runtime subpath, not an absolute path from createResolver. An absolute path survives into the build output and breaks on deploy; a bare specifier lets Nitro resolve and bundle it like any other dependency.

A bug the unit tests did not catch

getPath originally read event.path. On h3 v1 a prefix-mounted handler (app.use('/prefix', handler)) rewrites event.path to the remainder — so for /_nuxt/entry.js it reads /, and the exclusion silently does nothing. Only req.originalUrl keeps the untouched path:

{"path":"/","_path":"/","reqUrl":"/","originalUrl":"/_nuxt/entry.js"}

getPath now prefers h3 v2's event.url.pathname, then req.originalUrl, then event.path. Covered by tests on both majors.

Supporting changes

  • minSize on compress() / compressResponse(), surfaced as the module's threshold. This belongs in core: the buffer is already computed there, whereas measuring inside the plugin would have to stringify object bodies twice just to size them.
  • cloneResponse no longer forces a Content-Encoding. The paths that decide not to compress now rebuild the response instead of returning the original — they have to, because response.arrayBuffer() has already drained it. The pre-existing zero-length early return had the same latent problem and is fixed along the way.
  • compress / compressStream are exported publicly, mirroring compressResponse / compressResponseStream.
  • @nuxt/kit as an optional peer dependency so non-Nuxt users are not forced to install it.
  • moduleResolution: "bundler" — with "node" (node10) TypeScript cannot see @nuxt/kit's exports-based types at all, which is what made defineNuxtModule resolve to any.
  • Playground switched from the hand-written plugin to the module, plus a /server/api route so the beforeResponse path is actually exercised.

Verification

Unit — 26 new tests for path resolution, content-type detection, shouldCompress filtering, applyCompression and hook wiring, plus 3 h3-v1 tests driving the plugin through a real v1 app. Full suite across all four matrix combinations:

h3 1.8.1 h3 2.0.1-rc.22
Node 22.14 15 passed 58 passed
Node 24.17 15 passed 65 passed

End-to-end — a real nuxt build of the playground, then curl against the built server:

Request Result
/ (SSR, Accept-Encoding: gzip) Content-Encoding: gzip, decompresses to the page
/api/items (beforeResponse path) Content-Encoding: gzip, decompresses to the JSON
/_nuxt/entry.*.js no Content-Encoding — excluded
/ with Accept-Encoding: identity no Content-Encoding
/ with Accept-Encoding: br, gzip Content-Encoding: br

Zero server errors in the log.

One note from that exercise, documented in playground/README.md: the workspace symlinks h3-compression to the repo root, so it resolves h3 from the root devDependency. Nuxt 3 runs Nitro 2 on h3 v1, so the playground has to be built with h3 v1 installed at the root — otherwise two h3 majors land in the same bundle and h3 v2's getRequestHeader gets handed an h3 v1 event. This is a workspace artifact only; a real install resolves h3 from the consuming app.

pnpm lint clean (3 remaining warnings are the pre-existing vue/one-component-per-file false positives on createApp), pnpm build clean from a wiped dist/, tsc --noEmit clean across src/.

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
`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`.
@CodeDredd
CodeDredd merged commit b20715a into main Aug 6, 2026
13 checks passed
@CodeDredd
CodeDredd deleted the feat/nuxt-module branch August 6, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ship a Nuxt module (h3-compression/nuxt) so it can be configured from nuxt.config

1 participant