Retrigger exists so a Vite or webpack (or Rspack, Rollup, or esbuild) dev server does
not rebuild when a file's bytes did not change — a formatter on save, a generator that
reran, a branch switch that restored identical contents. It watches through the platform
backend (inotify, FSEvents, ReadDirectoryChangesW), hashes every changed file with
XXH3-64, and withholds the event from the bundler when the digest matches.
Raw per-event latency is not the product. Measured against Chokidar on the same machine, Retrigger is slower at delivering a single filesystem event; it trades a few milliseconds of watch latency for skipping entire rebuilds. That is the right trade for dev-server rebuild suppression. The watcher, hashing API, plugins, snapshots, and optional daemon are supporting machinery for that thesis — not co-equal headline features.
A pure-JavaScript fallback takes over where no native binary exists, so that require()
works on every platform.
Install it from npm.
npm install @retrigger/core- Architecture
- Usage
- Installation
- Measured Performance
- Verification
- The Optional Daemon
- Building from Source
- Platform Support
- Known Limitations
- Who Should Not Be Here
- Reporting a Problem
- License
Each layer below does one job.
src/core— C implementing XXH3-64 with runtime SIMD dispatch across AVX-512, AVX2, SSE2, NEON and scalar. The kernel is chosen fromcpuid/xgetbvat first use, and only kernels the architecture can host are compiled in at all.src/daemon/retrigger-core— Rust wrapping the C engine in FFI, with layout assertions on both sides.src/daemon/retrigger-system— Rust carrying the watcher itself, reaching inotify, FSEvents andReadDirectoryChangesWthroughnotify.src/daemon/retrigger-daemon— Rust for the optional standalone daemon, which speaks HTTP/JSON and SSE.src/bindings/nodejs— Rust and JavaScript together, carrying the N-API addon, the bundler plugins, and the JavaScript fallback engine.src/bindings/nodejs/wasm-xxh3— Rust compiled to a prebuilt WebAssembly module, so the JavaScript fallback hashes with the same algorithm rather than a lookalike.
Watching happens in-process by default.
The daemon exists only for sharing one watcher between several processes; nothing requires it, and the npm package does not install it.
Construct a watcher, subscribe to the events worth acting on, and start it.
const { Retrigger } = require('@retrigger/core');
const watcher = new Retrigger({
paths: ['./src'],
include: ['**/*.{ts,tsx,js}'],
exclude: ['**/node_modules/**'],
debounceMs: 10,
});
watcher.on('add', (path) => console.log('added', path));
watcher.on('change', (path) => console.log('changed', path));
watcher.on('unlink', (path) => console.log('removed', path));
watcher.start();Every event says whether the bytes actually changed, so a write that rewrote a file with the contents it already had can be told apart from an edit:
watcher.on('change', (path, event) => {
if (event.contentChanged === false) return; // a formatter on save; nothing to rebuild
rebuild(path);
});That comparison is a path against its own previous digest, so it works on both engines even though
they hash with different algorithms. contentHashing: false turns it off; events are then delivered
without the field.
Hashing is exposed directly, and is the same engine the watcher uses.
const {
hashFileSync,
hashBytesSync,
getEngineInfo,
} = require('@retrigger/core');
hashBytesSync(Buffer.from('abc')); // '78af5f94892f3950' on the native engine
hashFileSync('./src/index.ts'); // { hash: '…', size: 1234 }
getEngineInfo(); // which engine loaded, and whyThat digest is canonical XXH3-64 whichever engine loaded: the JavaScript engine computes the same
algorithm through a prebuilt WebAssembly module rather than a different one, so a digest from one
engine is comparable to a digest from the other. getEngineInfo().hashAlgorithm says which engine
you have, but both report "xxh3-64".
A file system that misbehaves has controls too, each off until asked for: backend: { mode: 'poll' } for network mounts whose kernel events cannot be trusted, with compareContents to
hash on every poll and catch a same-size, same-mtime rewrite; awaitWriteFinish to hold a path
until its size and modification time stop moving; and atomicWriteNormalization to fold an
editor's write-temp-then-rename into one change. For a process that was not running to see the
events at all, snapshot() inventories a tree and Retrigger.diffSnapshots() recovers what
changed between two inventories.
webpack takes the plugin as a constructor from the @retrigger/core/webpack subpath.
// webpack.config.js
const { RetriggerWebpackPlugin } = require('@retrigger/core/webpack');
module.exports = {
plugins: [new RetriggerWebpackPlugin({ watchPaths: ['./src'] })],
};Vite takes it as a factory from the @retrigger/core/vite subpath.
// vite.config.js
import { createRetriggerVitePlugin } from '@retrigger/core/vite';
export default {
plugins: [createRetriggerVitePlugin({ watchPaths: ['./src'] })],
};Both plugins hash before they invalidate. A write that did not change a file's bytes is not
reported to webpack and does not reach Vite's HMR pipeline, so a formatter on save, a generator
that reran, or a branch switch that restored the same contents costs nothing. contentHashing: false restores the ordinary behaviour of rebuilding on every write. The count of writes each
plugin suppressed is metrics.eventsUnchanged, which Vite serves at /__retrigger_stats.
Next.js is supported in webpack mode via RetriggerWebpackPlugin. Turbopack is not — it
exposes no public watcher or pre-rebuild veto seam; see
docs/upstream/turbopack-watcher-request.md.
Integration there is deferred until Vercel ships an API. Rspack works with the same webpack
plugin; Rollup and esbuild use @retrigger/core/rollup and @retrigger/core/esbuild. Full
options, Astro, Watchman, and the chokidar adapter live in
the package README.
The failure mode this package works hardest to avoid is an install that throws.
require()never throws — if no native binary matches the platform, the JavaScript engine (fs.watchand the same XXH3-64 through WebAssembly) takes over, prints one warning line, and keeps going.RETRIGGER_SILENT=1suppresses it, andgetEngineInfo().nativeAttemptsexplains what was tried and why each candidate was rejected.- No runtime dependencies — the published tarball is 96.5 KiB across 34 entries and
contains no native binary. The addon arrives through one of eleven platform packages
listed as
optionalDependencies, so a platform without one degrades instead of failing. - Both engines are held to one test suite — the JavaScript fallback, a mock addon, and the real compiled addon each run the same parity suite, so the fallback is a substitute rather than an aspiration.
npm run test:pack performs the whole thing end to end: pack the tarball, install it into
an empty directory, require it in a clean subprocess, watch a file, and confirm the process
still exits on its own.
Numbers below come from this repository's performance lab
(tools/benchmarks/), measured on Apple M4 Max · darwin arm64 ·
Node v22 · native engine · XXH3-64. They are not universal guarantees. Reproduce with:
cd tools/benchmarks && npm install && npm run bench:allReal Vite and webpack watch builds, same machine, same fixtures. Rebuilds are counted from
the bundler (handleHotUpdate / compiler.watch), not guessed from watcher events.
| Scenario | Retrigger | Stock watcher |
|---|---|---|
| Vite, 8 byte-identical writes | 0/8 rebuilds (0 ms rebuild wall) | 8/8 (~804 ms rebuild wall) |
| webpack, 8 byte-identical writes | 0/8 rebuilds (0 ms rebuild wall) | 8/8 (~386 ms rebuild wall) |
| Vite / webpack, 4 real edits | 4/4 rebuild correctly | 4/4 rebuild correctly |
| Burst (identical + real) | Only the real edits rebuild | Stock rebuilds for identical writes too |
On that run, identical writes saved 100% of rebuild-attributable wall time versus stock (0 vs ~804 ms Vite, 0 vs ~386 ms webpack). Observation/hash-confirm time is separate from rebuild wall — the product metric is rebuilds that never ran.
Same-run raw FS-event latency (p50), native Retrigger vs Chokidar:
- Chokidar — ~0.31 ms
- Retrigger — ~11.80 ms
Retrigger trades a few milliseconds of per-event latency for skipping entire rebuilds. If your bottleneck is noticing a change rather than rebuilding, stay with a lighter watcher; if your bottleneck is rebuilds that produce identical output, that trade is the point.
Also measured on the same machine class, in one run of npm run bench:scenarios:
- Snapshot crawl of a 2,000-file tree (2,081 entries with directories) in ~7.2 ms, and of
a 10,160-file monorepo (12,321 entries, nested
src/+test/across 80 packages) in ~65 ms - Event storm of 2,000 writes with 0 dropped — on the 2,000-file tree, on the 10,160-file monorepo, and through the polling backend as well
- The chokidar-compatible adapter reaches
readyon that tree in ~21 ms where chokidar itself takes ~125 ms, at ~64 MB peak RSS against ~106 MB — the same 200-write storm delivered completely by both - Peak RSS in the webpack rebuild lab ~151 MB
make -C src/core bench measures XXH3-64 through NEON on this class of machine (warm
cache). Below roughly 16 KiB, end-to-end file hashing is dominated by open/read, not
by the hash — which is why hashing every event is affordable when a rebuild costs tens or
hundreds of milliseconds.
make verify runs the whole gate: lint, every test suite, the C engine under ASan/UBSan,
the fuzz targets' type check, the packaged-install proof, and a build from a pristine copy
of the tree.
Linux is proven from a macOS workstation rather than taken on faith, on both architectures.
docker build --platform linux/arm64 -f deploy/docker/Dockerfile.test -t retrigger-test .
docker run --rm --platform linux/arm64 retrigger-testThe build context excludes host build outputs, so nothing that passes inside the container
can be a macOS artifact that rode along. Every suite currently passes on both linux/arm64
and linux/x86-64.
- C hash engine — passes under NEON on arm64 and under AVX2 on x86-64.
- C under ASan/UBSan — passes on both.
- Rust workspace — passes on both.
- Native addon artifact — passes on both.
- JavaScript, 434 tests — passes on both; the cases that do not apply to the platform skip rather than being counted as proof.
- Packaged install, 15 checks — passes on both.
The C suite runs a differential test that hashes the same inputs through every SIMD level the CPU offers and compares them against scalar, so "AVX2 is enabled" is a measurement rather than an assumption.
Published XXH3-64 vectors are checked from C, from Rust, from the Node addon, and from the JavaScript fallback's WebAssembly module — the same algorithm, the same vectors, four independent call paths.
What the two Node engines are held to jointly is more than the content-change decision now: one suite runs against the compiled addon, a mock addon, and the JavaScript engine and checks that all three agree on which writes changed a file's bytes, and a separate cross-engine suite hashes a shared corpus through both real engines and asserts the digests themselves are equal, byte for byte — not merely that each engine agrees with its own earlier digest.
CI runs more than the suites above, on every push and every pull request, with no leg allowed to be skipped or estimated.
- Both engines, separately — one leg deletes the native artifacts and requires the whole suite to pass with no addon present at all; another forces the fallback's alternate one-watch-per-tree strategy, so the degraded path is exercised rather than assumed.
- Real bundlers, not mocks — a leg boots vite@7, Rspack, and an Astro dev server against the plugins.
- Real Watchman — the Linux legs install Watchman and the
fb-watchmanclient, so the optional third engine's suite runs against the live service instead of skipping itself. - musl as well as glibc — a
linux-x64-muslleg builds and tests on Alpine. - The Rust floor — a leg reads
rust-versionfrom the workspace manifest and builds with exactly that toolchain, so 1.88 is enforced rather than aspirational. - Sanitizers, and fuzz targets that still compile — the C engine under ASan/UBSan on every push, not only during a campaign.
- Dependencies —
cargo auditandnpm audit. - Node 18, 20 and 22 across five OS/arch legs, plus FreeBSD — the version floor is tested rather than declared. Windows runs 20 and 22; see the package README's limitations.
Beyond the example tests, three tiers push the code the way a hostile file system would. They share seeds and durations so every failure is replayable rather than a one-off.
make test-adversarial # bounded, seeded, deterministic — safe for the PR gate
make test-chaos # storms, fault injection, and repeated-run flake hunts
make test-fuzz # time-budgeted libFuzzer plus a high-iteration proptest passtest-adversarialis the bounded subset: C metamorphic, contract, and adversarial-I/O proofs plus a shared-library load check; Rust queue/cache/watcher state-machine properties and real-filesystem race suites; and the JavaScript glob, bounded-container, content-change, and chunked-hash properties. It also runs insidemake test, because the suites auto-discover their files; this target is the focused way to iterate on them alone.test-chaosruns the heavier storms and the#[ignore]-marked fault-injection cases, then repeats the bounded tierCHAOS_ITERATIONStimes to hunt flakes.test-fuzzruns the C libFuzzer targets forFUZZ_SECONDSeach and aPROPTEST_CASES-deep proptest pass.
Every knob is an environment variable, so a campaign is one line and a failing seed replays exactly:
FUZZ_SECONDS=120 PROPTEST_CASES=65536 CHAOS_ITERATIONS=25 make test-chaos test-fuzz
# Replay a specific proptest counterexample (fast-check and proptest both print the seed):
PROPTEST_CASES=1 cargo test -p retrigger-system --lib properties
cd src/bindings/nodejs && npx vitest run test/properties.test.mjs # seed is fixed in the fileThe same campaigns run on demand in CI through the campaign workflow
(workflow_dispatch), which accepts the seed and duration inputs and uses only the free
standard runners — there is no scheduled job.
The daemon installs separately and is driven from the command line.
npm install -g @retrigger/daemon
retrigger config --output retrigger.toml
retrigger startIt speaks HTTP with JSON bodies and streams events over SSE, including
GET /snapshot for a self-describing tree inventory (the same shape as the in-process
snapshot() / watchWithSnapshot() APIs).
retrigger validate checks a config file before the daemon tries to run it, and
retrigger status reports on a running one.
The npm package ships a launcher that resolves the binary from a platform package or from the
package's own bin/, and explains how to build from source when neither exists rather than
failing the install. Building from source is the path today: no daemon platform package is
published yet. See the daemon README.
Three targets cover the build, the tests, and the full gate.
make all # C engine, Rust workspace, Node addon
make test # every suite
make verify # the full gateBuilding requires a C compiler, Rust 1.88 or newer, and Node 18.17 or newer.
libclang is deliberately not required — the FFI declarations are hand-written and guarded
by layout assertions on both sides, so building does not depend on bindgen.
Retrigger runs wherever Node does, though not every platform gets a native binary. The Node package ships eleven platform optionalDependencies; each has a release job. Verification tiers (what CI/release actually prove):
- Executed native — Linux x64/arm64 (gnu + musl), macOS x64/arm64, Windows x64,
FreeBSD x64 (
vmactions/freebsd-vm). Full suite on GitHub-hosted OS/arch legs; FreeBSD runs the Node package build + test + pack on a real FreeBSD guest. - Cross-built, executed under QEMU — Linux armv7 (
linux-arm-gnueabihf) and ppc64le. Built with a free cross toolchain, thenverify-artifact(and post-publish install) under QEMU. - Cross-built, not executed — Windows arm64. No free arm64 Windows runner; the
release job emits a
::warning::and skips the smoke test (same honesty bar as before). - Anywhere else — the
fs.watchfallback, degraded but never broken.
See the package README for the full triple → package suffix table.
Stated the same way as the rest of this document.
- No documented production adoption yet — this repository and the published
@retrigger/corepackage are not yet used by any documented independent production deployment, case study, or notable npm dependent. That is an absence of evidence, not a claim about fitness. - Windows arm64 is cross-built, not executed —
aarch64-pc-windows-msvcships as a release artifact, but there is no free arm64 Windows CI runner, so the binary is never smoke-tested on real hardware. See Platform Support. - BSD-family native support is FreeBSD only — of the BSD-family and adjacent Unixes,
only FreeBSD x64 gets a native addon (
freebsd-x64). NetBSD, OpenBSD, illumos, and similar platforms have no native binary and fall back to the JavaScript/fs.watchengine.
This repository is the source, and most readers want the published package instead.
- Using Retrigger in an application — install
@retrigger/coreand read the package README, which carries the full API, the options, and the differences between the two engines. - Sharing one watcher between several processes — read the daemon README, because nothing else needs the daemon.
- Wanting the lowest possible raw watch latency — Retrigger is not that product; see Measured Performance. Use it when skipping no-op rebuilds is the win.
Everything published out of this repository is tracked in one place.
- A bug in any package — open an issue on
the issue tracker, since
@retrigger/core,@retrigger/daemonand the platform packages are all built from this tree. - A vulnerability — follow the security policy, which reports privately through GitHub Security Advisories rather than the issue tracker, and sets out what is in scope.
- Slow first-event delivery — Retrigger is not racing Chokidar on per-event latency; see Measured Performance. If a real edit is missed, that is a bug — open an issue.
MIT, in LICENSE.
