ripgrep packaged as a single cross-platform WASI binary, published to npm as ripgrep. The primary artifact is one .wasm file, inlined into a JS module — no native binaries ship in the npm package.
cargo zigbuild (from cargo-zigbuild) is the bridge: it runs cargo normally but points Rust's linker at zig cc, so the C toolchain and libc come from Zig instead of the system. That's what makes WASI (and the optional native targets) build reproducibly from any host.
vendor/ripgrep/— upstream ripgrep pinned as a git submodule. Never edit; all overrides are applied from outside.build.zig— thin wrapper that invokescargo zigbuildas astd.Build.Step.Run. Does not compile any Zig or Rust code itself.build.zig.zon— minimal manifest (name, version, fingerprint)..zig-cache/cargo-target/<triple>/— per-target cargo target directory, isolated so parallel cross builds don't clobber each other's fingerprints.dist/rg-<triple>[.exe|.wasm]— install outputs. The published package only ships the wasm one.build.ts— inlinesdist/rg-wasm32-wasip1.wasmintolib/_rg.wasm.mjs(brotli + z85) and stamps the wasm hash intolib/_rg.mjsfor disk-cache invalidation. Run withnode build.ts.
lib/index.mjs— ESM entry. Exportsripgrep(args, options)andrgPath. Delegates wasm loading and WASI runtime creation to_rg.mjs.lib/rg.mjs— CLI bin entry (bin: { rg, ripgrep }). CallsenableCompileCache()fromnode:modulefor faster repeated runs, then forwardsprocess.argvtoripgrep()and exits with its code.lib/_rg.mjs— orchestration layer between the wasm blob and WASI runtimes:getRgWasmModule()— memoizes thePromise<WebAssembly.Module>. Decompresses the blob viabrotliDecompressSyncand caches the raw.wasmbytes to disk (os.tmpdir()/ripgrep-wasm-<hash>.wasm) so subsequent runs skip decompression entirely.createWasiRuntime(options)— picks the WASI backend: usesnode:wasiby default on Node (suppressesExperimentalWarning), falls back to the custom shim ifnode:wasiimport fails or if customstdout/stderrstreams without a numericfdare provided. On Bun and Deno the custom shim is always used.
lib/_wasi.mjs— minimal WASI preview1 shim. Implements only the ~23 syscalls ripgrep actually imports (fd_read,fd_write,fd_readdir,fd_seek,fd_tell,fd_close,fd_fdstat_get,fd_fdstat_set_flags,fd_filestat_get,fd_prestat_*,path_open,path_filestat_get,path_readlink,args_*,environ_*,clock_time_get,random_get,proc_exit,sched_yield,poll_oneoffstub). Backed bynode:fssync APIs so it works on Node, Bun, and Deno uniformly.proc_exitthrows aWASIExitthatstart()catches to return the exit code.lib/_rg.wasm.mjs— auto-generated bybuild.ts. ExportsgetCompressedBytes()which z85-decodes the blob into raw brotli-compressed bytes. The encoded blob lives inside agetEncoded = () => "…"function so V8 lazy-parses the large string literal and only allocates it on first call, not at import time.lib/index.d.mts— hand-written types. Includes anRgFlagunion of known ripgrep long/short flags for autocomplete onripgrepargs (still accepts arbitrary strings viaRgArg = RgFlag | (string & {})). Overloadsripgrep()for{ buffer: true }→RipgrepBufferedResult(withstdoutandstderrstrings).package.json—name: "ripgrep",type: module,files: ["lib"],bin: { rg, ripgrep }→./lib/rg.mjs, exports only./lib/index.mjs.
- Default preopens map
.→process.cwd(); absolute paths passed as args are auto-added as preopens so they work without extra configuration. - ripgrep's TTY auto-detection doesn't work through WASI preview1 (it always sees a non-TTY), so
ripgrepauto-injects--color=ansiwhenprocess.stdout.isTTYand the caller hasn't specified a color flag and no customstdoutstream is provided. Detection checks--color,--color=…, and--no-color. - WASI backend selection: On Node,
node:wasiis used by default (theExperimentalWarningis silently suppressed). On Bun and Deno, the custom shim is used. Override withnodeWasi: true/falseorRIPGREP_NODE_WASI=1/0. Ifnode:wasiimport fails at runtime, it falls back to the custom shim automatically. When customstdout/stderrstreams without a numeric.fdproperty are provided, the custom shim is forced regardless. - Wasm disk cache: The decompressed wasm bytes are cached in
os.tmpdir()/ripgrep-wasm-<hash>.wasm. On subsequent runs, the cached file is read directly viareadFileSync, skipping z85 decode + brotli decompression. The hash in the filename changes when the wasm binary is rebuilt. - Buffered mode:
ripgrep(args, { buffer: true })captures stdout/stderr into strings returned asresult.stdout/result.stderr. Custom streams take precedence over buffering for their respective fd. start()returns0on clean exit or the exit code fromWASIExit;node:wasi'sstart()returnsundefinedon success, so the adapter coerces with?? 0. The publicripgrep()function wraps this into a{ code }result object.
test/ripgrep.test.mjs— vitest tests covering the programmatic API (ripgrep()with buffered/non-buffered/custom streams),rgPathexport, and CLI execution viaspawn.test/fixture/— test data:hello.txt,subdir/nested.txt,link.txt→ symlink tohello.txt.- Run:
pnpm vitest run test/ripgrep.test.mjs.
bench/cli.mjs— cold-start comparison: nativergbinary vsnode lib/rg.mjs(both viaexecFileSync). Usesmitata.bench/api.mjs— warm comparison: nativeexec(rg)vsripgrep()API (wasm module pre-warmed, measures per-call overhead). Also usesmitata.- Both benchmark against
vendor/ripgrep/cratessearching forfn main. - Run:
node bench/cli.mjsornode bench/api.mjs.
There is only one cargo profile: cross-compile with the smallest-size settings. No mode option, no native install, no sub-steps for tweaking.
The cargo profile is release-lto (defined in vendor/ripgrep/Cargo.toml: fat LTO, codegen-units=1, panic="abort"), with size tuning layered on via cargo --config: opt-level="z", debug=false, strip="symbols".
For wasm targets, SIMD is enabled via RUSTFLAGS="-C target-feature=+simd128" — this unlocks memchr's simd128 vectorized search paths in the wasm binary.
pnpm build— build wasm + inline into JS (equivalent tozig build wasi && node build.ts)zig build wasi— buildwasm32-wasip1→dist/rg-wasm32-wasip1.wasm(default step)zig build native— cross-compile all native targets →dist/rg-<triple>[.exe](not shipped to npm; kept for local use / benchmarks)zig build— same aszig build wasinode build.ts— inline the built wasm intolib/_rg.wasm.mjsand stamp hash intolib/_rg.mjs. Must be re-run any time the wasm changes.node lib/rg.mjs <rg args…>— run the packaged CLI directly from source.pnpm test— run vitest testspnpm fmt— format with oxfmt
Defined in build.zig as two groups:
wasi_triples (the one that ships):
wasm32-wasip1
native_triples (built on demand, not published):
aarch64-apple-darwinx86_64-unknown-linux-gnuaarch64-unknown-linux-gnux86_64-pc-windows-gnu
Each requires the matching Rust std:
rustup target add wasm32-wasip1
# plus the natives if you want `zig build native`:
rustup target add \
x86_64-unknown-linux-gnu \
aarch64-unknown-linux-gnu \
x86_64-pc-windows-gnuBinary extensions are chosen by binExt in build.zig: .exe for Windows, .wasm for WASI/wasm targets, empty otherwise.
The install loop uses .{ .custom = "../dist" } as the install directory — this resolves relative to the Zig install prefix (zig-out), so outputs land in a repo-root dist/ instead of under zig-out/.
- Wasm is inlined, not a separate file.
lib/_rg.wasm.mjsships the compressed bytes inside an ESM module so the npm package is pure JS — no.wasmasset resolution, no postinstall step. The tradeoff is a larger JS file and a one-time decode on first call. - Two-layer decompression.
_rg.wasm.mjsexportsgetCompressedBytes()(z85 decode only), and_rg.mjshandles brotli decompression + disk caching. This separation keeps the generated blob module simple and the caching logic in hand-written code. - Disk cache in tmpdir. The decompressed wasm is written to
os.tmpdir()/ripgrep-wasm-<hash>.wasmon first run. Subsequent runs load the cached file directly viareadFileSync, skipping z85 decode and brotli decompression. The<hash>is the first 16 hex chars of the wasm's SHA-256, stamped bybuild.ts. - Lazy string literal. The z85-encoded blob is wrapped in
const getEncoded = () => "…"specifically so V8 doesn't eagerly parse/allocate it at import time. Don't inline it back into a top-levelconst. - Wasm module is cached, instances aren't.
getRgWasmModule()memoizes thePromise<WebAssembly.Module>; eachripgrepcall still creates a freshWebAssembly.Instancebecause WASI state (memory, fds) is per-instance. - node:wasi ExperimentalWarning suppression.
createNodeWasitemporarily replacesprocess.emitWarningwith a no-op while constructing the WASI instance, then restores it. This avoids the warning spam without--no-warnings. - Stream-based stdio forces custom shim.
node:wasionly accepts numeric fd values for stdout/stderr. If a custom stream without a.fdproperty is passed,createWasiRuntimeautomatically uses the custom shim instead. - Custom shim grants all rights in
fd_fdstat_get. ripgrep only reads, so over-grantingfs_rights_base/fs_rights_inheriting(~0n) is harmless and avoids tracking precise capability bits. poll_oneoffis stubbed toNOTSUP. ripgrep only uses it for stdin-driven modes, which the shim doesn't support anyway (stdin reads return 0 / EOF).path_openENOENT handling. Only ENOENT is swallowed whenO_CREATis set; everything else propagates, so bad paths surface as real errors instead of silent creates.- WASM SIMD.
build.zigsetsRUSTFLAGS="-C target-feature=+simd128"for wasm targets, enabling memchr's simd128 vectorized paths for faster search. - TOML-quoted
--configvalues.cargo --configparses values as TOML, so string values must include the quotes:opt-level="z"notopt-level=z. - Env-var form doesn't work for hyphenated profile names.
CARGO_PROFILE_RELEASE_LTO_OPT_LEVELis ambiguous — cargo parses it as profilereleasewith keylto_opt_leveland fails. Use--configinstead. - Per-target
CARGO_TARGET_DIR. Sharing one target dir across triples causes cargo to constantly rebuild dependencies. Keeping them separate under.zig-cache/cargo-target/<triple>/gives proper caching. - Cargo's output path is
$CARGO_TARGET_DIR/<triple>/release-lto/rg[.exe|.wasm]— for custom profiles the profile dir equals the profile name. - ripgrep is still a Rust project.
rustcdoes all the code generation; Zig only acts as the C compiler and linker. A purezig buildof ripgrep would require rewriting it. enableCompileCache.rg.mjscallsnode:module'senableCompileCache()for faster cold starts on Node. The call is wrapped in try/catch since some Node-compatible runtimes don't support it.
zig(tested with 0.15.2)rustc+cargo(tested with 1.94.1)cargo-zigbuild(cargo install cargo-zigbuild)- Rust std for
wasm32-wasip1(plus the native targets if building those) - Node.js 18+ / Bun / Deno at runtime (for
WebAssembly.compile,node:fssync APIs, andnode:zlibbrotli)
vitest— test runnermitata— benchmarkingoxfmt— code formatter@vitest/coverage-v8— coverage