feat: ship a Nuxt module on the h3-compression/nuxt subpath - #23
Merged
Conversation
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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #22
What
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/apigo throughbeforeResponserather thanrender:response, that/_nuxtand/__nuxtmust be skipped or the error page breaks, and that binary assets need a content-type guard.Everything is configurable under the
compressionkey:enabledtrueencoding'zlib''zlib'buffers the body;'stream'pipes it through a transformbrotlifalse'stream'— the zlib path already prefers itzstdfalsemethodcontentTypesContent-Type.[]compresses everythingexclude['/_nuxt', '/__nuxt']routeRulestruebeforeResponsethreshold0How it's wired
The module generates a one-line Nitro plugin into the build dir:
Two deliberate choices there:
useRuntimeConfig, virtual modules anddefineNitroPlugin— all of which depend on Nitro behaviour that has moved between majors.defineNitroPluginis an identity function anyway; a plain default-exported function is the whole contract../nuxt-runtimesubpath, not an absolute path fromcreateResolver. 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
getPathoriginally readevent.path. On h3 v1 a prefix-mounted handler (app.use('/prefix', handler)) rewritesevent.pathto the remainder — so for/_nuxt/entry.jsit reads/, and the exclusion silently does nothing. Onlyreq.originalUrlkeeps the untouched path:getPathnow prefers h3 v2'sevent.url.pathname, thenreq.originalUrl, thenevent.path. Covered by tests on both majors.Supporting changes
minSizeoncompress()/compressResponse(), surfaced as the module'sthreshold. 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.cloneResponseno longer forces aContent-Encoding. The paths that decide not to compress now rebuild the response instead of returning the original — they have to, becauseresponse.arrayBuffer()has already drained it. The pre-existing zero-length early return had the same latent problem and is fixed along the way.compress/compressStreamare exported publicly, mirroringcompressResponse/compressResponseStream.@nuxt/kitas 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 madedefineNuxtModuleresolve toany./server/apiroute so thebeforeResponsepath is actually exercised.Verification
Unit — 26 new tests for path resolution, content-type detection,
shouldCompressfiltering,applyCompressionand hook wiring, plus 3 h3-v1 tests driving the plugin through a real v1 app. Full suite across all four matrix combinations:End-to-end — a real
nuxt buildof the playground, then curl against the built server:/(SSR,Accept-Encoding: gzip)Content-Encoding: gzip, decompresses to the page/api/items(beforeResponsepath)Content-Encoding: gzip, decompresses to the JSON/_nuxt/entry.*.jsContent-Encoding— excluded/withAccept-Encoding: identityContent-Encoding/withAccept-Encoding: br, gzipContent-Encoding: brZero server errors in the log.
One note from that exercise, documented in
playground/README.md: the workspace symlinksh3-compressionto the repo root, so it resolvesh3from 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'sgetRequestHeadergets handed an h3 v1 event. This is a workspace artifact only; a real install resolvesh3from the consuming app.pnpm lintclean (3 remaining warnings are the pre-existingvue/one-component-per-filefalse positives oncreateApp),pnpm buildclean from a wipeddist/,tsc --noEmitclean acrosssrc/.