rs-semver
A drop-in, Rust-powered replacement for the semver npm package — same API you already use (satisfies, inc, sort, SemVer, Range, ...), backed by a Rust engine instead of regex.
npm install rs-semver
const semver = require('rs-semver')
semver.satisfies('1.2.3', '^1.2.0') // true
semver.inc('1.2.3', 'minor') // '1.3.0'
semver.sort(['1.2.3', '1.0.0', '2.0.0']) // ['1.0.0', '1.2.3', '2.0.0']
If you already use semver in a Node project, this is meant to be a
straight swap — same function names, same arguments, same behavior
(checked against semver's own test suite — see Project status
below).
Why
Most of what a range check does — satisfies, sort, maxSatisfying —
is CPU-bound comparison work, and doing it in Rust instead of
JavaScript is meaningfully faster once you're checking a lot of
versions (think: a package manager resolving a whole dependency tree).
Measured against the real semver package on the same machine, same
data:
| Operation | Speedup |
|---|---|
satisfies, 10,000 checks |
4.2x faster |
sort, 1,000 versions |
11.9x faster |
maxSatisfying, 500 candidates × 50 ranges |
2.0x faster |
(Full numbers, methodology, and one honest exception in Benchmarks below.)
Also available as
- A CLI, matching the
semvercommand-line tool's flags exactly — see CLI. - A Rust crate (
rs-semver-core), if you want the engine without Node at all — see Using it from Rust.
More examples
const semver = require('rs-semver')
// classes, same shape as the original semver package
const v = new semver.SemVer('1.2.3-beta.1')
v.major // 1
v.prerelease // ['beta', '1']
const r = new semver.Range('^1.2.0')
r.test('1.5.0') // true
// import just what you need, same subpaths as `semver`
const valid = require('rs-semver/functions/valid')
const SemVer = require('rs-semver/classes/semver')
CLI
cargo build --release -p rs-semver-cli
./target/release/rs-semver -r "^1.2.0" 1.2.3 2.0.0 1.5.0
# 1.2.3
# 1.5.0
Flags match semver's CLI (-r/--range, -i/--increment, --preid,
-l/--loose, -p/--include-prerelease, -c/--coerce, --rtl/--ltr,
-n <base>) — verified byte-for-byte against the real CLI.
Using it from Rust
use rs_semver_core::{satisfies, Options};
assert!(satisfies("1.2.3", "^1.2.0", Options::default()).unwrap());
rs-semver-core is a standalone, zero-dependency crate — it doesn't
need Node.js at all. See crates/core/src/lib.rs for the full function
list; it mirrors the npm package's API (valid, clean, inc, diff,
the compare family, satisfies and the rest of the range functions,
sort/rsort, coerce), plus SemVer, Comparator, and Range
types.
Project status
This was built from scratch and checked against node-semver's own test suite (the real fixtures from v7.8.5, not hand-picked cases) — parsing, precedence, every range type, version increments, and more. All pass.
A few parts are intentionally simpler than the original and are called
out in the source with a ponytail: comment where they're defined:
coerce()(pulling a version out of a messy string likev1.2.3.4.5) covers the common cases but isn't a full port of the original's regex.subset()andsimplifyRange()handle typical ranges well but not every exotic multi-range combination the original supports.intersects()won't catch one rare edge case (two ranges that only overlap via a shared prerelease tag).
Not built yet: a browser/WASM build, prebuilt binaries for every OS (see CI/CD), and publishing the Rust crates to crates.io.
Contributor / internals details (repo layout, building from source, benchmarks, CI/CD)
Repo layout
crates/
core/ rs-semver-core — the actual semver engine, zero dependencies
cli/ rs-semver-cli — CLI binary (crates.io deps: none beyond core)
napi/ rs-semver-napi — NAPI-RS bindings, thin wrapper over core
packages/
rs-semver/ npm package: classes/, functions/ (per-symbol require paths,
matching node-semver's layout) + index.js (full API surface)
Building from source
# Rust library + CLI
cargo build --release
cargo test
cargo bench -p rs-semver-core
# Node bindings
cd packages/rs-semver
npm run build # cargo builds the addon, copies it to rs-semver.node
node -e "console.log(require('.').satisfies('1.2.3', '^1.2.0'))"
CI/CD
.github/workflows/ci.yml— runs on every push/PR tomain:cargo build/test/clippyacross the workspace, plus a Node smoke test that builds the addon in debug mode and exercises it..github/workflows/release.yml— triggered by pushing a version tag (git tag v0.1.1 && git push origin v0.1.1). Builds the NAPI addon for macOS (arm64), Linux (x64 + arm64, glibc), and Windows (x64), publishes each as its ownrs-semver-<platform>-<arch>npm package, then publishes the mainrs-semverpackage with those asoptionalDependencies— the same prebuilt-binary pattern used bysharp,esbuild,@swc/core, etc. Needs anNPM_TOKEN(npm "Automation" access token) added as a repo secret before it'll work.- macOS x64 (Intel) isn't built — GitHub's hosted Intel Mac runner
pool (
macos-13) sat queued for 25+ minutes with no runner assigned on the first real release run, so that leg was dropped rather than block every future release on it. Apple hasn't sold an Intel Mac since 2023; add the leg back (see therelease.ymlhistory) if it turns out to matter for real users. - Only builds glibc Linux binaries; musl (Alpine) isn't covered.
- crates.io publishing isn't wired up —
cargo publishmanually if you want the Rust crates available outside this repo.
Benchmarks
Measured on a single Apple Silicon (arm64) dev machine, one run each,
cargo build --release. Not a controlled, multi-machine, statistically
rigorous benchmark suite — treat these as directional, and reproduce them
yourself before relying on them (commands below).
Pure Rust core (cargo bench -p rs-semver-core, criterion, 100
samples each):
| Benchmark | Median time |
|---|---|
| Parse 10,000 random versions | 1.10 ms |
satisfies on 10,000 (version, range) pairs |
11.2 ms |
| Sort 1,000 versions | 322 µs |
maxSatisfying, 500 candidates × 50 ranges |
2.88 ms |
Node.js: rs-semver (via NAPI) vs. the real semver package
(benchmarks/compare.js, same generated data for both, best-of-5,
identical process):
| Benchmark | rs-semver | node-semver | speedup |
|---|---|---|---|
| Parse 10,000 versions | 6.05 ms | 2.95 ms | 0.5x (slower) |
satisfies, 10,000 pairs |
9.37 ms | 38.95 ms | 4.2x |
| Sort 1,000 versions | 0.42 ms | 4.93 ms | 11.9x |
maxSatisfying, 500 × 50 |
4.84 ms | 9.81 ms | 2.0x |
The parse result is the honest finding here, and it matches a risk
called out in the original design doc ("NAPI-RS binding overhead
negates gains"): calling parse 10,000 times each returns a full
SemVer class instance across the FFI boundary, and that per-call
marshaling cost currently outweighs what the Rust parser saves — the
original package's regex-based parser is faster end to end for this
specific access pattern. satisfies/sort/maxSatisfying win because
they return a primitive (bool/string/array) per call and do more work
internally, so the FFI cost is amortized. A real integration (e.g.
resolving npm's whole dependency graph, which does exactly this kind of
bulk parse-then-compare) would need to batch calls or avoid re-parsing
already-parsed versions to see a win across the board — that's the
concrete next step if this gets pushed further, not a caching layer for
its own sake.
Reproduce:
cargo bench -p rs-semver-core
cd packages/rs-semver && npm run build && cd ../../benchmarks
npm install
node compare.js
Design notes
- No regex, no backtracking parser.
SemVer::parseis a single-pass hand-written parser straight into theSemVerstruct. - Ranges compile to concrete comparators at parse time.
^,~, x-ranges, and hyphen ranges all expand into plain>=/</<=/=comparators up front, so matching a version is just a linear scan — no regex rewriting happens at match time. - Prerelease exclusion matches node-semver exactly, including the
synthetic
-0prerelease tag node-semver attaches to compiled range bounds (verified directly against the reference implementation, not just from memory of the source) so that plain numeric comparison correctly excludes/includes prereleases without needing a same-tuple check on every comparator.
License
MIT