npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@medicomind/rolldown-compression

v2.1.7

Published

Fast native (Rust) gzip / brotli / zstd compression plugin for Rolldown, powered by napi-rs

Readme

@medicomind/rolldown-compression

CI npm version npm downloads node license

Fast, native compression plugin for Rolldown and Vite 8+: compresses emitted assets with gzip, brotli and zstd at build time. The compression core is written in Rust (napi-rs + rayon) — one batched FFI call per build, fanned out across all CPU cores, without ever blocking the JS event loop.

3x faster builds in a real project: switching a production app from node:zlib-based (node v26.4.0) compression to this plugin cut total build time from 3:50 to 1:13 (740% → 688% CPU utilization) — see real-world results.

API ergonomics mirror vite-plugin-compression2; see differences.

Install

npm install -D @medicomind/rolldown-compression

Prebuilt binaries are installed automatically — no Rust toolchain needed (see platform support).

Quick start

// rolldown.config.ts
import { defineConfig } from 'rolldown'
import { compression } from '@medicomind/rolldown-compression'

export default defineConfig({
  input: 'src/main.ts',
  plugins: [
    // gzip + brotli with defaults
    compression(),
  ],
})

Full configuration:

import { compression, defineAlgorithm } from '@medicomind/rolldown-compression'

compression({
  include: [/\.(js|mjs|css|html|svg|json|wasm)$/],
  exclude: [/\.(png|jpe?g|webp|woff2?)$/],
  threshold: 1024,
  algorithms: [
    'gzip', // string shorthand with default level
    defineAlgorithm('brotli', { quality: 11 }),
    defineAlgorithm('zstd', { level: 19 }),
  ],
  filename: '[path][base].gz', // or (fileName, algorithm) => string
  deleteOriginalAssets: false,
  skipIfLargerOrEqual: true,
  concurrency: 0, // 0 = number of logical CPUs
  chunkSize: 0, // 0 = compress everything in one batch
  stream: false, // true = on-demand disk-based compression in writeBundle
  logLevel: 'info',
})

Usage with Vite

Vite 8+ uses Rolldown as its bundler, so the plugin works there out of the box:

// vite.config.ts
import { defineConfig } from 'vite'
import { compression } from '@medicomind/rolldown-compression'

export default defineConfig({
  plugins: [compression()],
})
  • The plugin declares apply: 'build', so it only runs for vite build — the dev server is untouched.
  • On Vite 6/7 use the rolldown-vite package (aliased as vite) to get the Rolldown-based build.
  • All options are the same as with plain Rolldown, and mirror vite-plugin-compression2 — for most projects it's a drop-in replacement (see differences).

Options

| option | type | default | description | |------------------------|-------------------------------------------------|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | include | string \| RegExp \| Array<string \| RegExp> | /\.(html\|xml\|css\|json\|js\|mjs\|svg\|yaml\|yml\|toml\|txt\|wasm)$/ | Files to compress. Strings are picomatch globs, matched against bundle-relative file names (@rollup/pluginutils createFilter semantics). | | exclude | string \| RegExp \| Array<string \| RegExp> | — | Files to skip. Wins over include. | | threshold | number | 0 | Minimum original size in bytes for a file to be compressed. | | algorithms | Array<AlgorithmName \| DefineAlgorithmResult> | ['gzip', 'brotli'] | Algorithms to run. Aliases gz, br, brotliCompress, zstandard normalize to gzip / brotli / zstd. | | filename | string \| (fileName, algorithm) => string | '[path][base]' + ext | Name of the emitted artifact. Tokens: [path] (directory incl. trailing /), [base], [name], [ext] (with dot), [hash] (8-char sha256 of the content). Default extensions: .gz, .br, .zst. | | deleteOriginalAssets | boolean | false | Remove the original from the bundle after all algorithms processed it. Errors if filename resolves to the source name. | | skipIfLargerOrEqual | boolean | true | Don't emit artifacts whose compressed size is >= the original. | | concurrency | number | 0 | Native worker threads. 0 = number of logical CPUs. | | chunkSize | number | 0 | Max source bytes buffered per native compression batch. 0 = one batch for the whole build. A positive value (e.g. 64 * 1024 * 1024) caps the plugin's peak memory overhead at roughly one batch of source copies plus its compressed outputs; a single file larger than chunkSize still forms its own batch. The bundler keeps the original bundle in memory regardless. In stream mode 0 falls back to a 4 MB batch instead of one big batch. | | stream | boolean | false | Compress from disk in writeBundle (order post) instead of in memory in generateBundle: files are read on demand in bounded batches (chunkSize bytes, default 4 MB), and assets written to disk by other plugins' writeBundle hooks are compressed too (see stream mode). | | logLevel | 'silent' \| 'error' \| 'warn' \| 'info' | 'info' | Plugin log verbosity; info prints a per-algorithm summary at build end. | | enableInWatchMode | boolean | false | The plugin is a no-op in watch/dev mode unless enabled (see watch mode). |

All options are validated when compression() is called — invalid levels (e.g. brotli quality 12), unknown algorithm names or malformed filters throw immediately, not mid-build.

Per-algorithm options

| algorithm | option | range | default | |-----------|---------------|-------------|---------| | gzip | level | 0–9 | 6 | | brotli | quality | 0–11 | 11 | | brotli | windowBits | 10–24 | 22 | | brotli | sectionSize | ≥ 1 (bytes) | 2^windowBits B | | zstd | level | 1–22 | 19 |

defineAlgorithm('gzip', { level: 9 })
defineAlgorithm('brotli', { quality: 7, windowBits: 22 })
defineAlgorithm('zstd', { level: 12 })

sectionSize is the target number of bytes each brotli worker thread compresses when a large input is split across the native worker pool; inputs at least four times sectionSize take the multithreaded path. It defaults to one window (2^windowBits bytes) — 4 MiB and multithreading from 16 MiB at the default window — because sections much smaller than the window lose too many cross-section matches. Smaller sections finish large files faster at a slight cost in compression ratio.

How it works

  • The plugin hooks generateBundle, while all chunks and assets are still in memory — no filesystem round-trip. Eligible files (filter + threshold) are sent to the native module as one batched FFI call per build; results are emitted with emitFile.
  • Compression runs on a rayon thread pool inside the native module (AsyncTask), parallel across files and algorithms, scheduled most-expensive-first so one large brotli file can't stretch the batch tail. The JS event loop keeps ticking throughout (covered by a test).
  • Buffers cross the FFI boundary without base64/string round-trips, and the compression working set lives in native memory — a 500 MB asset does not pressure the JS heap.
  • A failing task never aborts the batch: per-task errors are aggregated and fail the build with one message.
  • Already-compressed artifacts (.gz, .br, .zst — ours or pre-existing) are never re-compressed, so chaining plugin instances can't produce app.js.gz.br.

Limitation of the default mode: assets written to disk by other plugins in writeBundle/closeBundle (i.e. after generateBundle) are not seen. This matches how vite-plugin-compression2 handles the in-bundle pass. Set stream: true to remove it.

Stream mode

With stream: true the plugin skips the in-memory generateBundle pass entirely and instead runs at the end of writeBundle (hook order post), after the bundle — and any extra assets other plugins wrote in their own writeBundle hooks — is on disk:

compression({
  stream: true,
  chunkSize: 64 * 1024 * 1024, // optional: batch by source bytes instead of by file count
})
  • The output directory is scanned recursively and matching files are read on demand, never all at once: a batch is flushed to the native module whenever it reaches chunkSize source bytes, defaulting to 4 MB when chunkSize is 0. Peak memory overhead is one batch of sources plus its compressed outputs, regardless of build size.
  • Compressed artifacts are written straight to the output directory (emitFile is not available after write); deleteOriginalAssets unlinks the originals from disk.
  • The same filter, threshold and re-compression guards apply, and everything already in the output directory that matches them is compressed — including files produced by other plugins after generateBundle, the default mode's limitation. Assets written in closeBundle (after all writeBundle hooks) are still not seen.
  • Trade-off: files the bundler already had in memory are re-read from disk, and per-batch FFI calls replace the single big batch — for small builds the default in-memory mode is faster.

Watch / dev mode

The plugin declares apply: 'build' (honored by Vite / rolldown-vite) and checks this.meta.watchMode at generateBundle time, making it a no-op under rolldown --watch. Set enableInWatchMode: true to compress in watch builds anyway.

Serving pre-compressed assets

nginx (gzip_static / brotli_static / zstd_static):

location / {
  gzip_static on;     # serves foo.js.gz when the client accepts gzip
  brotli_static on;   # requires ngx_brotli
  zstd_static on;     # requires zstd-nginx-module
}

Caddy:

example.com {
  root * /srv/dist
  file_server {
    precompressed zstd br gzip
  }
}

Benchmark

Real-world results

Switching a production app's build from node:zlib-based compression to this plugin (same algorithms and levels):

before: npm run build  675.82s user 4.63s system 294% cpu 3:50.92 total
after:  npm run build  537.24s user 5.59s system 740% cpu 1:13.29 total

4.03x faster wall clock. Compression stops being serialized behind the libuv thread pool (default UV_THREADPOOL_SIZE=4) and runs on all cores instead — CPU utilization jumps from 235% to 784%.

Synthetic benchmark

npm run bench (or node benchmark/index.mjs --quick) generates a dist-shaped fixture set — 202 files / ~85 MB with a long-tail size distribution, including two monolithic >16 MiB bundles that exercise the multithreaded brotli path — and compresses it with the native core vs node:zlib driven at full parallelism via Promise.all (the reference plugin's best case). Both sides always use the same levels.

Results on an Apple M1 Pro (10 cores), Node 26, default UV_THREADPOOL_SIZE:

With PGO

| scenario | output | native (rust) | node:zlib | speedup | |-----------------------------------|----------|---------------|-----------|---------| | gzip+brotli (ref. defaults: 9/11) | 15.06 MB | 14.63s | 35.48s | 2.43x | | gzip (level 9) | 9.62 MB | 0.29s | 0.78s | 2.69x | | gzip (level 6) | 9.86 MB | 0.18s | 0.36s | 1.99x | | brotli (quality 11) | 5.45 MB | 14.27s | 35.37s | 2.48x | | brotli (quality 6) | 9.88 MB | 0.22s | 0.35s | 1.59x | | zstd (level 19) | 5.54 MB | 7.42s | 12.99s | 1.75x |

Without PGO

| scenario | output | native (rust) | node:zlib | speedup | |-----------------------------------|----------|---------------|-----------|---------| | gzip+brotli (ref. defaults: 9/11) | 15.06 MB | 14.79s | 35.50s | 2.40x | | gzip (level 9) | 9.62 MB | 0.26s | 0.79s | 3.07x | | gzip (level 6) | 9.86 MB | 0.18s | 0.36s | 1.98x | | brotli (quality 11) | 5.45 MB | 14.37s | 35.46s | 2.47x | | brotli (quality 6) | 9.88 MB | 0.22s | 0.35s | 1.58x | | zstd (level 19) | 5.54 MB | 7.34s | 13.09s | 1.78x |

Reading these numbers honestly:

  • gzip and zstd are faster per core (zlib-rs, ~2.4x faster per core than node's bundled zlib in our measurements; libzstd) and use every core, while node:zlib is capped at UV_THREADPOOL_SIZE (default 4) threads. The two >16 MiB bundles temper the headline numbers: neither algorithm has a sectioned mode, so each giant file occupies a single thread on both sides and that tail runs at the per-core ratio rather than the thread-count ratio.
  • brotli at quality 11 is the bound on the combined number: the Rust brotli crate is at per-core parity with node's C brotli (we measured a 1.01 single-thread ratio), so the speedup is parallelism — every core against UV_THREADPOOL_SIZE threads across the many small files, plus the sectioned worker pool (4 x 4 MiB sections) on the >16 MiB bundles that node:zlib has to compress one thread per file.
  • The speedup grows with core count and shrinks if you raise UV_THREADPOOL_SIZE for the JS side — the benchmark prints both so runs are comparable.

PGO / BOLT builds

npm run build:pgo (scripts/pgo/build.mjs) produces a profile-guided release build:

  1. baseline release build → target/pgo/baseline.node
  2. instrumented build (-Cprofile-generate)
  3. training run over a static corpus (scripts/pgo/corpus.mjs: JS bundles, JSON, CSS, HTML, SVG sprites, source maps, base64 blobs, incompressible noise — every algorithm at fast/default/max levels)
  4. llvm-profdata merge (uses the rustup llvm-tools component; rustup component add llvm-tools if missing)
  5. optimized rebuild (-Cprofile-use) → target/pgo/pgo.node, also installed as the platform binding in the repo root
  6. on Linux ELF targets with llvm-bolt/merge-fdata on PATH, a BOLT post-link pass (instrument → retrain → -reorder-blocks=ext-tsp layout optimization) → target/pgo/bolt.node. BOLT does not support Mach-O/PE, so this step is skipped on macOS and Windows.

npm run bench:pgo (or with --quick) then benchmarks baseline vs PGO(+BOLT) on the same dist-shaped fixtures as npm run bench, with interleaved iterations and median timings:

| scenario | what it measures | |----------------|------------------------------------------------------------| | baseline | plain --release (fat LTO, codegen-units = 1) | | pgo / pgo+bolt | same flags plus -Cprofile-use (and BOLT layout on Linux) |

Expect modest gains: the baseline already ships fat LTO with codegen-units = 1, so PGO adds a few percent on the compression-heavy scenarios (~1.1x on the brotli quality 11 run on an M1 Pro) and is within noise on sub-second ones. Sub-second scenario deltas in the table are measurement noise, not regressions.

The release workflow builds every published binary with PGO. Cross-compiled targets run the training workload through an emulation layer — x64 Node under Rosetta 2 for x86_64-apple-darwin, an arm64 Node container under QEMU for aarch64-unknown-linux-gnu, and an Alpine container for musl — so each target trains on its own instrumented binding.

Platform support

Prebuilt binaries are published for:

| platform | triple | |---------------------|-----------------------------| | macOS arm64 | aarch64-apple-darwin | | macOS x64 | x86_64-apple-darwin | | Linux x64 (glibc) | x86_64-unknown-linux-gnu | | Linux arm64 (glibc) | aarch64-unknown-linux-gnu | | Linux x64 (musl) | x86_64-unknown-linux-musl | | Windows x64 | x86_64-pc-windows-msvc |

Node.js >= 22.14.0 (since v2; v1.x supports Node.js >= 18).

WebAssembly fallback

A WASI build (@medicomind/rolldown-compression-wasm32-wasi) is published for platforms without a prebuilt native binary. The loader falls back to it automatically when no native binding can be loaded — expect several times slower compression than native (~5x in a quick local benchmark).

Package managers skip optional dependencies whose cpu field doesn't match the host, so on an unsupported platform the wasm package must be opted into:

  • npm: npm install --cpu wasm32 @medicomind/rolldown-compression-wasm32-wasi (or add it as a regular devDependency).
  • yarn: add supportedArchitectures: { cpu: ["current", "wasm32"] } to .yarnrc.yml.
  • pnpm: add supportedArchitectures: { cpu: ["current", "wasm32"] } under pnpm in package.json.

Differences from vite-plugin-compression2

  • Native speed: compression runs in Rust on all cores, one FFI batch per build, instead of node:zlib calls through the libuv thread pool.
  • No custom JS algorithms: algorithms accepts only the built-in gzip / brotli / zstd (function-form algorithms can't cross the FFI boundary). defineAlgorithm returns an opaque object, not a [name, options] tuple — treat it as such.
  • No tarball plugin: out of scope.
  • gzip default level is 6 (zlib default), not 9 — measurably faster for a ~1% size difference. Pass defineAlgorithm('gzip', { level: 9 }) to match the reference.
  • zstd everywhere: zstd is compiled in, with no dependency on the Node runtime's zstd support (node >= 22.15).
  • Extra options: concurrency (native thread cap), chunkSize (memory cap per compression batch), stream (on-demand disk-based compression in writeBundle) and enableInWatchMode.

Implementation decisions

  • Rolldown target: developed and tested against [email protected] (peer range ^1.0.0), using the Rollup-compatible generateBundle/emitFile plugin API.
  • gzip backend: flate2 with the pure-Rust zlib-rs backend — as fast as or faster than zlib-ng in our runs, with no cmake/C toolchain requirement for contributors.
  • Publishing: public npm (--access public), versioned with changesets. PRs include a changeset (npx changeset); the Version workflow keeps a chore: release PR up to date, and merging it tags the release and runs the full napi build matrix before napi prepublish + npm publish. Run the Release workflow via workflow_dispatch for a dry-run that builds all platform artifacts without publishing.

Development

See CONTRIBUTING.md for the full contributor guide (setup, tests, changesets, PR workflow).

npm install          # install JS deps
npm run build        # release native build + TS bundle
npm test             # vitest (unit + integration)
cargo test           # Rust core tests
npm run bench        # benchmark vs node:zlib
COMPRESSION_TEST_LARGE=1 npx vitest run __tests__/integration/large-file.test.ts  # 150 MB asset test
npx changeset        # add a changeset describing your change (required for releases)

License

MIT