Skip to content

Latest commit

 

History

History
711 lines (568 loc) · 34.2 KB

README.md

File metadata and controls

711 lines (568 loc) · 34.2 KB

@retrigger/core: Skip Byte-Identical Rebuilds

npm version downloads install size node license

Skip rebuilds when a file's bytes did not change. That is the product.

Retrigger plugs into webpack, Vite, Rspack, Rollup, and esbuild so a formatter on save, a generator that reran, or a branch switch that restored identical contents does not trigger HMR or a full rebuild. It watches through the platform backend (inotify, FSEvents, ReadDirectoryChangesW), hashes every changed path with XXH3-64, and withholds the event when the digest matches.

It is not the fastest raw watcher. Measured against Chokidar on the same machine, Retrigger trades a few milliseconds of per-event latency for skipping entire rebuilds — the right trade for dev-server rebuild suppression. The hashing API, snapshots, Watchman engine, chokidar adapter, and optional daemon support that thesis; they are not co-equal headline features.

A zero-dependency JavaScript fallback keeps it working on platforms with no native build.

Contents

Installation

Install it from npm.

npm install @retrigger/core

require('@retrigger/core') never throws.

If the native addon for your platform is missing or fails to load, the package transparently falls back to a pure-JavaScript engine that implements the same interface, prints one warning line, and tells you exactly what happened through getEngineInfo().

There is no build step, no daemon to start and no post-install script.

Quick Start

webpack

webpack takes the plugin as a constructor.

// webpack.config.js
const { RetriggerWebpackPlugin } = require('@retrigger/core');

module.exports = {
  plugins: [new RetriggerWebpackPlugin({ verbose: true })],
};

Vite

Vite takes it as a factory.

// vite.config.js
import { createRetriggerVitePlugin } from '@retrigger/core';

export default {
  plugins: [createRetriggerVitePlugin({ verbose: true })],
};

Subpath imports are also available, so you can pull in only what you need.

import RetriggerWebpackPlugin from '@retrigger/core/webpack';
import { createRetriggerVitePlugin } from '@retrigger/core/vite';

Standalone Watcher

Outside a bundler, construct a watcher directly and close it when you are done. Prefer the bundler plugins when the goal is rebuild suppression; the standalone watcher is the same content-hash gate without a bundler seam.

const { createRetrigger } = require('@retrigger/core');

const watcher = createRetrigger({ paths: ['./src'], exclude: ['**/*.log'] });
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.on('error', (err) => console.error(err));
watcher.start();
// ...
watcher.close();

Measured Performance

From this repository's lab (tools/benchmarks/), measured on Apple M4 Max · darwin arm64 · Node v22 · native engine · XXH3-64. Not universal guarantees. Reproduce:

cd tools/benchmarks && npm install && npm run bench:all

Flagship — byte-identical writes must not rebuild:

Scenario Retrigger Stock
Vite, 8 identical writes 0/8 rebuilds (0 ms rebuild wall) 8/8 (~804 ms)
webpack, 8 identical writes 0/8 rebuilds (0 ms rebuild wall) 8/8 (~386 ms)
4 real edits (Vite and webpack) 4/4 rebuild 4/4 rebuild
Burst (identical + real) Only real edits rebuild Stock also rebuilds for identical writes

Honesty — raw watch latency trails Chokidar (same-run p50): Chokidar ~0.31 ms vs Retrigger ~11.80 ms. A few milliseconds of per-event latency buys skipping entire rebuilds.

Supporting, from one bench:scenarios run: snapshot crawl of a 2,000-file tree in ~7.2 ms and of a 10,160-file monorepo in ~65 ms; storms of 2,000 writes with 0 dropped on both trees and through the polling backend; the chokidar adapter ready in ~21 ms where chokidar itself takes ~125 ms (~64 MB peak RSS against ~106 MB); peak RSS in the webpack rebuild lab ~151 MB.

Which Engine Am I Running?

getEngineInfo() answers that, and says why.

const { getEngineInfo } = require('@retrigger/core');
console.log(getEngineInfo());
// {
//   engine: 'javascript',
//   backend: 'polling',
//   reason: "native addon unavailable (Cannot find module '@retrigger/core-linux-x64-gnu')",
//   hashAlgorithm: 'xxh3-64',
//   simd: 'scalar',
//   platform: 'linux-x64',
//   nativeAttempts: [ ... ]
// }

Set RETRIGGER_SILENT=1 to suppress the fallback warning, RETRIGGER_FORCE_JS=1 to always use the JavaScript engine, or RETRIGGER_NATIVE_PATH=/path/to.node to load a specific addon build.

Engine Differences

The two engines differ in five ways, stated plainly.

  • Mechanism — the native engine uses OS APIs (inotify, FSEvents, ReadDirectoryChangesW, kqueue), while the JavaScript fallback uses fs.watch with one watcher per directory.
  • backend() — native returns the OS backend name, and the fallback returns "polling".
  • Renames — native may report renamedFrom and renamedTo with a correlation cookie, while the fallback reports them as deleted plus created and cookie is always null.
  • Symlinked directories — native follows them per OS behaviour, and the fallback does not traverse them.
  • Startup window — the native stream is live when start() returns, whereas on macOS the fallback's fs.watch brings its FSEvents stream up asynchronously, so a write in the first few milliseconds after start() can be missed.

The startup window matters only if you write to a watched tree the instant you start watching. Both bundler plugins handle it by reading the tree once on the first compile rather than relying on the event stream for that moment.

Hash Comparability

Hashes are canonically comparable across engines: both compute XXH3-64, so the same bytes and the same seed produce the same 16-character digest whichever engine ran.

The native engine calls the C engine directly; the JavaScript fallback calls the same XXH3-64 implementation compiled ahead of time to a 20 KB WebAssembly module (lib/xxh3.wasm, built from the xxhash-rust crate, matching official reference vectors — see hash.test.mjs), rather than a hand-written, roughly two-orders-of-magnitude-slower BigInt reimplementation, or a plausible-looking near-miss with a different algorithm. require() never touches a toolchain: the module ships prebuilt, and instantiating it needs nothing from the host beyond linear memory.

event.contentChanged was always comparable across engines, and now the digest behind it is too: the decision compares a path against its own earlier digest, taken by the same engine in the same process, but that digest is also equal to what the other engine would have computed for the same bytes. One test suite runs against the compiled addon, a mock addon, and the JavaScript engine, and all three must reach the same answer.

Content Changes

Every event carries contentChanged, and the plugins act on it.

watcher.on('change', (path, event) => {
  if (event.contentChanged === false) return; // rewritten with the bytes it already had
  rebuild(path);
});

The decision table is the same one the Rust daemon uses, so an in-process watcher and the daemon cannot disagree.

  • A file whose digest differs from the cached one — true, and event.hash is the new digest.
  • A file whose digest matches — false.
  • A file that could not be read — true, with a null hash. Unknown is not the same as unchanged.
  • A file deleted or renamed away — true, and the cached digest is forgotten.
  • A directory created, deleted or renamed — true; modified or metadata is false, because directory mtime churn is not a content change.
  • A rescan signal — true.

Hashing a small file runs on the drain loop, because a source file costs tens of microseconds and is worth it against a rebuild there. A file larger than maxHashBytes is hashed off that loop instead — chunked and I/O-driven, on both engines — so a large build artifact changing cannot delay delivery of every other event that tick; it is still hashed, just asynchronously, and its event is emitted once the digest resolves. maxConcurrentHashes bounds how many of those run at once, and getStats().asyncHashesInFlight/asyncHashesQueued report the current load. The digest cache is bounded the same way every other map in this package is, and forgetting an entry costs one re-hash and a redundant rebuild — never a missed change.

API

Twelve exports make up the API.

  • createRetrigger(options?) — a function returning a Retrigger, which it does not start.
  • Retrigger — a class extending EventEmitter; see the events below.
  • hashBytesSync(data, seed?) — a function returning 16 lowercase hex characters.
  • hashFileSync(path) — a function returning { hash, size }.
  • hashFile(path) — a function returning a Promise of { hash, size }.
  • benchmarkHash(size, iterations) — a function reporting measured throughput, with no baselines.
  • getSimdSupport() — a function reporting the active SIMD level.
  • getCpuLevel() — a function reporting the highest level the CPU supports.
  • getAvailableLevels() — a function reporting all compiled levels.
  • getEngineInfo() — a function reporting which engine you got, and why.
  • RetriggerWebpackPlugin — a class implementing the webpack 5 plugin.
  • createRetriggerVitePlugin(options?) — a function returning the Vite 5/6/7 plugin.

Three further integrations are reachable only as subpaths, because each pulls in a seam the core does not need: @retrigger/core/rollup, @retrigger/core/esbuild, and @retrigger/core/chokidar. Each has its own section below. ./webpack and ./vite mirror the two plugins already listed, for importing one without loading the other.

Instance Methods

A Retrigger carries the watch, the digest cache, and the counters, so everything below reads or moves that one state.

  • start() / stop() / close() — each returns this; stop() and close() are safe to call repeatedly and before start().
  • isRunning — a getter, not a method.
  • add(target, recursive?) — registers another root after construction; watch() is an alias.
  • unwatch(target) — drops a root and forgets its cached digests.
  • snapshot(target) / watchWithSnapshot(target, recursive?) — see Snapshots / Change-Since.
  • Retrigger.diffSnapshots(oldEntries, newEntries) — static, because comparing two snapshots needs no watcher.
  • getStats() — the queue counters, content (digest-cache totals), metrics, and the async-hash gauges in one object.
  • hasContentChanged(target, kind?) — asks this watcher's digest cache whether a path another watcher reported really changed. This is what makes a second, un-disableable watcher (Vite's own chokidar under legacyWatcher: true) idempotent with Retrigger rather than merely redundant: whichever source sees the write first records the digest, and the other is then told false. Answers true whenever it cannot tell, and true unconditionally when contentHashing is off, because a redundant rebuild is the cheaper mistake.
  • getEngineInfo() / getSimdLevel() — the module-level answers, from the instance.
  • changesSince(target, clock) — Watchman engine only; see Watchman Engine.

Watcher Options

Every option carries a default, so an empty call is valid.

createRetrigger({
  paths: ['./src'], // string | string[]
  recursive: true,
  include: [], // globs; empty means "everything"
  exclude: [], // globs; exclusion always wins over inclusion
  debounceMs: 0, // coalesce events per path
  capacity: 8192, // bounded queue; overflow emits `rescan`
  pollIntervalMs: 5, // how often the engine queue is drained
  engine: 'auto', // 'auto' | 'native' | 'javascript' | 'watchman'
  emitDirectories: false,
  unref: false, // unref the poll timer
  contentHashing: true, // report `contentChanged` on every event
  maxHashBytes: 4194304, // above this, hashing moves off the drain loop onto the async path
  maxConcurrentHashes: 4, // ceiling on async hashes running at once
  // Network / remote FS: force portable polling (native engine). JS engine always polls.
  backend: { mode: 'auto', pollIntervalMs: 1000, compareContents: false },
  // Hold a path until size+mtime stop changing (chunked / NFS-style writes).
  awaitWriteFinish: undefined, // or { pollIntervalMs: 100, stabilityThresholdMs: 2000 }
  // Fold editor write-temp-then-rename into one `change`. Default false on Retrigger.
  atomicWriteNormalization: false,
});

Watcher contracts worth knowing (explicit, not magic):

  • Polling / network filesystems — backend: { mode: 'poll' } when kernel events cannot be trusted (NFS, some network mounts). Optional compareContents hashes on each poll to catch same-size/same-mtime rewrites.
  • awaitWriteFinish — write stabilization before delivery (same idea as chokidar).
  • Atomic saves — atomicWriteNormalization: true folds temp-then-rename into one change.
  • Symlinks — native follows per OS behaviour; the JavaScript engine does not traverse symlinked directories.
  • Unreadable files during hashing — if a changed path cannot be read for a content hash, the content-hashing layer reports contentChanged: true with a null hash (never silently "unchanged"). That is fail-open on the hash attempt; see Content Changes.
  • Permission-denied watch roots — if a watched path itself cannot be registered because of permissions, watch registration fails. That is not the hashing fail-open above.
  • Async hashing — files above maxHashBytes hash off the drain loop so large bursts do not block the event loop; maxConcurrentHashes caps concurrency.

How debounceMs behaves

Debouncing is leading-edge with a trailing correction, and both halves matter.

The first event for a path is delivered immediately — nothing waits out the window — and further events for that path inside the window are absorbed. If the window absorbed anything, it closes with one modified carrying the file's final size.

The correction is not redundant. Because the first event is delivered at once, it can describe a file that is still being written; a large save fires on its first chunk, and the write that completes it lands inside the window. Without the correction that completing write would never be reported, and the consumer would keep the partial file. So a burst costs two wake-ups, not one, and the second is the one that is definitely current.

With contentHashing on, a correction whose bytes did not actually move since the leading event arrives with contentChanged: false, so it costs a hash rather than a rebuild.

Deletes and renames are never absorbed: they end the window and are delivered on their own. Both engines implement this identically, and the parity suite holds them to it.

Events

A Retrigger emits seven events.

  • add — (path, event), fired for a file created.
  • change — (path, event), fired for a file modified or metadata changed.
  • unlink — (path, event), fired for a file deleted.
  • all — (event), fired for every event, including directory and rescan events.
  • rescan — (event), fired when the queue overflowed; re-read state from disk.
  • error — (error), fired on engine failure, and never thrown.
  • ready — no arguments, fired once per start().

A listener that throws is reported through error and never stops delivery.

webpack Plugin

The plugin replaces webpack's Watchpack-based watchFileSystem with a Retrigger-backed implementation of the same contract. It also works unmodified with @rspack/core, which implements compiler.watchFileSystem compatibly, including with Rspack's persistent cache — npm run test:rspack runs this package's whole webpack test suite against it.

If Retrigger cannot start, every call is delegated to webpack's original watcher and the build proceeds unchanged — the plugin never throws out of a webpack hook.

Its options and their defaults are these.

new RetriggerWebpackPlugin({
  watchPaths: [], // extra roots beyond webpack's own dependency set
  verbose: false,
  debounceMs: 0,
  include: [],
  exclude: ['**/node_modules/**', '**/.git/**'],
  engine: 'auto',
  replaceWatcher: true, // false leaves webpack's watcher in place
  aggregateTimeout: 20,
  capacity: 16384,
  pollIntervalMs: 5,
  contentHashing: true, // skip files rewritten with identical bytes
});

A write that did not change a file's bytes is never reported to webpack: no timestamp is advanced, no watch session is notified, and nothing is held over for the next one. Leaving the recorded timestamp where it was is the truthful answer, since the contents webpack compiled are still the contents on disk, and webpack re-stats anything it was not told about.

plugin.getStats() returns measured counters, or null before the first watch; metrics.eventsUnchanged is how many rebuilds this saved.

Default exclusions matter. node_modules is not recursed into, so edits inside it will not trigger a rebuild while replaceWatcher is on. Pass exclude: [] if you are actively editing a dependency in place.

Next.js

Next.js is supported in webpack mode: add RetriggerWebpackPlugin to webpack in next.config.* the same way you would for a plain webpack project.

Turbopack is not supported. Turbopack exposes no public watcher-substitution or pre-rebuild veto API, so Retrigger cannot attach without an upstream seam. That integration is intentionally deferred until Vercel ships one; see the upstream request note. Do not expect no-op-rebuild suppression under next dev --turbopack.

Vite Plugin

Works with Vite 5, 6 and 7, and with Astro's dev server (which mounts a Vite instance internally) — test/astro.test.mjs boots a real Astro dev server against it. Rspack is a webpack plugin concern; see webpack Plugin.

Retrigger becomes the sole file-system event source for Vite's dev server: the plugin's config() hook sets server.watch = null, which Vite documents as disabling its own chokidar, so server.watcher is Vite's inert NoopWatcher — still a real EventEmitter whose identity never changes. Detected changes are replayed onto it with plain server.watcher.emit('add' | 'change' | 'unlink', file) calls, so module-graph invalidation, handleHotUpdate hooks and full-reload decisions all stay with Vite, and any other plugin's own server.watcher.on(...) listener — registered before or after this one runs — keeps working exactly as it would against a real chokidar instance.

Fail-open: disabling Vite's own watcher trades away the safety net a second live watcher would have provided, so this plugin rebuilds an equivalent one on demand instead. If Retrigger cannot start, or degrades mid-session (repeated engine errors, or a caller invoking plugin.api.degrade() directly), a minimal fs.watch-based fallback takes over and keeps relaying events onto server.watcher — HMR does not die because Retrigger did.

Escape hatch: legacyWatcher: true restores the pre-rewrite design — Vite's own chokidar is left running, and the plugin gates its server.watcher.emit calls so a byte-identical write chokidar itself observed does not also reach the module graph — for the rare composability edge where another plugin's config() hook overrides server.watch back to non-null after this one runs.

Its options and their defaults are these.

createRetriggerVitePlugin({
  watchPaths: [],
  include: [],
  exclude: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/.vite/**'],
  verbose: false,
  debounceMs: 0,
  engine: 'auto',
  capacity: 8192,
  pollIntervalMs: 5,
  stats: true, // mounts GET /__retrigger_stats
  contentHashing: true, // skip files rewritten with identical bytes
  legacyWatcher: false, // true restores the pre-rewrite shared-watcher design; see above
});

A write that did not change a file's bytes is never replayed onto server.watcher, so the module graph is not invalidated and the browser is not reloaded. /__retrigger_stats reports the count as metrics.eventsUnchanged.

Rollup / esbuild (Manual Rebuild)

Neither Rollup nor esbuild exposes a public seam to veto a rebuild a watch has already started — Rollup's RollupWatcher (watchChange) and esbuild's context(...).watch() are both observation-only. @retrigger/core/rollup and @retrigger/core/esbuild therefore do not call either tool's own watch API at all: Retrigger owns the watch, and a build is a plain one-shot rollup()/ctx.rebuild() call, driven by a shared scheduler (lib/rebuild-driver.js) only when the content-hash gate reports a real byte change. This is the direct replacement for rollup --watch or esbuild --watch when the goal is skipping no-op rebuilds.

const rollupWatcher = require('@retrigger/core/rollup');

const build = await rollupWatcher
  .createRetriggerRollupWatcher({
    input: { input: './src/index.js' },
    output: { file: './dist/bundle.js', format: 'esm' },
    watchPaths: ['./src'],
  })
  .start(); // builds once immediately, then watches
// ...
await build.close();
const esbuildWatcher = require('@retrigger/core/esbuild');

const build = await esbuildWatcher
  .createRetriggerEsbuildWatcher({
    entryPoints: ['./src/index.js'],
    outfile: './dist/bundle.js',
    bundle: true,
    watchPaths: ['./src'],
  })
  .start();
// ...
await build.close();

Both factories share the same scheduling semantics:

  • Coalescing — a burst of real edits inside coalesceMs (default 20) collapses into one rebuild, not one per file.
  • Backpressure — at most one rebuild runs at a time; a change that lands while one is running is not dropped, but it does not start a second concurrent rebuild either — it schedules exactly one follow-up once the current one settles, coalescing anything else that arrives before then.
  • Errors — a build that throws is reported through onError/the returned driver's 'error' event and never crashes the watch; the next real change tries again.
  • Shutdown — close() stops watching, waits for an in-flight build to settle, and (for esbuild) calls ctx.dispose().

build.buildCount, build.getStats() and build.driver.getStats() report measured counters — driver.getStats().eventsUnchanged is how many rebuilds this saved, the same number the webpack and Vite plugins report as metrics.eventsUnchanged.

Snapshots / Change-Since

snapshot(path) crawls a tree into a self-describing envelope (safe to persist as JSON; algorithm / version tell a reader whether it still matches). watchWithSnapshot(path) registers the watch first, then crawls, so creations during the crawl are not lost. Both are methods on a Retrigger instance — supporting change-since workflows around the same content-hash gate, not a separate product surface.

const { createRetrigger } = require('@retrigger/core');

const watcher = createRetrigger({ paths: ['./src'] });
const snap = await watcher.watchWithSnapshot('./src');
// snap.entries, snap.algorithm ('xxh3-64'), …

Retrigger.diffSnapshots(oldEntries, newEntries) recovers what changed between two of those envelopes, in the same event vocabulary on('all', …) reports: a path only in the new entries is created, one only in the old is deleted, one in both is modified when its size or modifiedNs differs and silent when they agree. It answers the question a restarted process cannot answer from events it was not running to receive.

const before = JSON.parse(await fs.readFile('snapshot.json', 'utf8')); // persisted last run
const after = await watcher.snapshot('./src');
for (const event of Retrigger.diffSnapshots(before.entries, after.entries)) {
  console.log(event.kind, event.path); // 'created' | 'modified' | 'deleted'
}

It is static because the comparison is pure data — no watcher, no watch, no I/O. When the native addon is loaded it calls that addon's own implementation, so there is one classification code path and no drift risk between engines; without an addon an equivalent pure-JavaScript comparison runs instead. Either way it accepts snapshots from either engine, including ones persisted by the other.

The optional daemon exposes the same inventory over GET /snapshot. With engine: 'watchman', changesSince(path, clock) adds Watchman clock-backed deltas (see below).

Watchman Engine (Optional)

A third engine talks to Watchman, Meta's own file watching service, when it is explicitly requested and actually present. Watchman is never a dependency and never auto-selected — engine: 'auto' still only ever picks native or JavaScript.

const watcher = createRetrigger({ paths: ['./src'], engine: 'watchman' });

One contract detail differs under it, because Watchman settles before it speaks: a burst that debounceMs would absorb can be coalesced by Watchman's own settle period first, so the leading event already describes the settled file and no correction follows it. Nothing is lost either way — whichever event arrives last describes the file as the writes left it — but the two-wake-up shape the native and JavaScript engines guarantee is not guaranteed here.

If neither the optional fb-watchman client nor the watchman binary on PATH is available, this falls back to the native → JavaScript path with one documented warning line (suppressed by RETRIGGER_SILENT=1, same as the native fallback). getEngineInfo().watchman reports { available, kind: 'fb-watchman' | 'cli' | null, reason } unconditionally, whether or not you asked for Watchman.

Beyond the shared engine contract, a Watchman-backed watcher exposes one Watchman-specific method for change-since queries backed by Watchman's own clock, complementing the walk-based snapshot() / Retrigger.diffSnapshots() pair every engine has:

const first = await watcher.snapshot('./src'); // establishes a Watchman clockspec
// ...later...
const since = await watcher.changesSince('./src', first.clock); // since.files, since.clock

Chokidar-Compatible Adapter

@retrigger/core/chokidar exports a chokidar v5-shaped watch(paths, options) for codebases already written against chokidar's API.

const chokidarAdapter = require('@retrigger/core/chokidar');

const watcher = chokidarAdapter.watch(['./src'], { ignoreInitial: true });
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.on('ready', () => console.log('initial scan complete'));

It supports add()/unwatch() (both array-accepting), getWatched(), the all/ready/error events, and the options ignored, ignoreInitial, cwd, awaitWriteFinish (mapped to the watcher's own write stabilizer), followSymlinks, and atomic (mapped to atomicWriteNormalization; unlike Retrigger itself, this adapter defaults atomic: true to match real chokidar). It also accepts Retrigger's own contentHashing for no-op-write suppression, which real chokidar has no equivalent for.

Documented divergences from real chokidar:

  • No glob support inside add() — pass concrete paths or directories, same as Retrigger.
  • No raw event; nothing here fabricates chokidar's internal fs.watch event names.
  • awaitWriteFinish's pollInterval/stabilityThreshold map onto this package's own stabilizer rather than a re-implementation, so timing characteristics track that stabilizer, not chokidar's.

Requirements

The package has two requirements.

  • Node.js — 18.17 or newer.
  • Native addon — published for the platforms below. Everywhere else, including arches with no binary, the JavaScript/fs.watch engine takes over.

Native platform matrix

Every row is a real optionalDependency with a release job that produces a .node artifact. Verification tiers are what the release workflow actually does — not aspirational coverage.

Target triple Package suffix Verification
x86_64-unknown-linux-gnu linux-x64-gnu Executed native (GitHub-hosted)
aarch64-unknown-linux-gnu linux-arm64-gnu Executed native (GitHub-hosted)
x86_64-unknown-linux-musl linux-x64-musl Executed native (Alpine container)
aarch64-unknown-linux-musl linux-arm64-musl Executed native (Alpine container)
x86_64-apple-darwin darwin-x64 Executed native (GitHub-hosted)
aarch64-apple-darwin darwin-arm64 Executed native (GitHub-hosted)
x86_64-pc-windows-msvc win32-x64-msvc Executed native (GitHub-hosted)
aarch64-pc-windows-msvc win32-arm64-msvc Cross-built, not executed (no free arm64 Windows runner)
x86_64-unknown-freebsd freebsd-x64 Executed native (FreeBSD VM via vmactions/freebsd-vm)
armv7-unknown-linux-gnueabihf linux-arm-gnueabihf Cross-built, executed under QEMU
powerpc64le-unknown-linux-gnu linux-ppc64-gnu Cross-built, executed under QEMU

Linux gnu/musl: the loader detects libc and tries the other build if it guesses wrong. FreeBSD uses kqueue and is supported through the native engine only: Node's fs.watch cannot back the JavaScript fallback reliably on BSD (no recursive mode, and directory watches do not deliver filenames or content-modification events), so a FreeBSD deployment must run on the native addon, which ships for freebsd-x64. The JavaScript engine works on every other platform Node supports.

Known Limitations

Stated plainly, alongside the platform matrix above.

  • Windows arm64 is cross-built, not executed — win32-arm64-msvc (aarch64-pc-windows-msvc) ships, but CI never executes it: there is no free arm64 Windows runner. See the matrix row above.
  • BSD-family native support is FreeBSD only — only freebsd-x64 has a native binary. NetBSD, OpenBSD, illumos, and similar platforms have no native addon and fall back to the JavaScript engine.
  • Windows CI skips Node 18 — the Windows test matrix runs Node 20 and 22; Linux and macOS also run Node 18 legs. The package still requires Node 18.17 or newer.
  • Adversarial campaign workflow skips Windows — the on-demand chaos/fuzz campaign (workflow_dispatch) runs its matrix on linux-x64 and macos-arm64 only.
  • Adoption — no documented independent production deployments or case studies yet; see the repository README.

Who Should Not Be Here

This package watches in-process, which is the right shape for a single dev server that wants to skip byte-identical rebuilds.

  • Several processes watching one tree — install @retrigger/daemon instead, so that one watcher and one hash serve all of them.
  • Wanting the lowest raw watch latency — Retrigger trails Chokidar on per-event p50; see Measured Performance.
  • Next.js with Turbopack — unsupported until upstream exposes a seam; use webpack mode or wait. See Next.js.
  • Reading the C and Rust layers, or building from source — go to the repository.

Reporting a Problem

Every package built from this tree is tracked in one place.

  • A bug — open an issue on the issue tracker.
  • A vulnerability — follow the security policy, which reports privately rather than through the issue tracker.
  • A platform with no native build — that is expected rather than a bug, and getEngineInfo() reports what was tried and why.

License

MIT