A fast, seedable, forkable PRNG for Deno/JSR — Zig's Xoshiro256++ compiled to WebAssembly.
Built for simulations, property tests, and anything else that has to produce the same numbers twice.
import { Prng } from "jsr:@nullstyle/urand";using rng = Prng.create(12345n);
rng.u64(); // uniform 64-bit unsigned integer
rng.f64(); // uniform float in [0, 1)
rng.u32Range(1, 100); // uniform integer in [1, 100]
using network = rng.fork("sim.network");
network.u64(); // an independent, deterministic substreamInstances hold a slot in a shared WASM pool. A using declaration releases it
at scope exit; otherwise call destroy() yourself.
const rng = Prng.create(42n);
rng.f64();
rng.destroy();import { urand } from "jsr:@nullstyle/urand";
using rng = urand(12345n);
using node = rng.fork("node:alpha");
console.log(rng.f64(), node.u64());The numbers this package emits are part of its API. Any change to them is a
breaking change, recorded in CHANGELOG.md and reflected in
STREAM_VERSION:
import { STREAM_VERSION } from "jsr:@nullstyle/urand";Store it alongside anything you persist that came out of a urand stream, so a future mismatch is loud rather than silent.
The Zig toolchain tracks master, so this guarantee comes from golden vectors
checked in CI on every push and nightly — not from a pinned compiler. If an
upstream change ever moves the stream, CI goes red before a release can.
fork() derives an independent child stream from a parent's identity plus a
label. It does not consume the parent, so the same parent and label always yield
the same child no matter how much the parent has drawn:
using root = Prng.create(2024n);
using a = root.fork("sim.network");
root.u64(); // does not affect what `a` produces
using b = root.fork("sim.network"); // identical stream to `a`Labels may be a string, a bigint, or a Uint8Array. Each is type-tagged and
length-framed before hashing, so labels of different types never collide and
fork("ab").fork("c") is distinct from fork("a").fork("bc").
Strings are hashed as UTF-8 with no Unicode normalization — NFC "é" and NFD "é" are different labels. Strings containing unpaired UTF-16 surrogates are rejected rather than silently folded onto U+FFFD.
Each stream carries a 256-bit chaining key, derived as
BLAKE3(parentKey ‖ tag ‖ length ‖ label) and used to seed all four words of
the generator state. BLAKE3 is here for domain separation and collision
resistance in the label space, not to make any part of this package
cryptographically secure — see below.
path reports a stream's lineage for logging. It takes no part in derivation:
root.fork("sim").fork("node:1").path; // "/sim/node:1"const snapshot = rng.getState(); // 32 bytes
rng.u64();
rng.setState(snapshot); // rewoundurand is a deterministic simulation and testing PRNG. It is not a cryptographically secure random number generator.
Its output is fully predictable from the seed, low-entropy seeds are recoverable from a single observed value, and Xoshiro256++ state can be reconstructed algebraically from a handful of consecutive outputs. Never use it for tokens, session IDs, nonces, keys, password resets, shuffles that must resist manipulation, or anything else an adversary should not be able to predict.
For those, use
crypto.getRandomValues().
| Runtime | Status |
|---|---|
| Deno | ≥ 2.1 |
| Node | ≥ 24 (or ≥ 22 with --experimental-wasm-modules) |
| Bun | Unsupported — .wasm falls back to Bun's file loader |
| Browsers | Unsupported — no shipping browser implements instance-phase WASM/ESM imports |
All failures are typed, and each extends the built-in it specialises:
| Error | Raised when |
|---|---|
PrngSeedError |
Seed is not a usable integer, or a number above 2^53 |
PrngRangeError |
u32Range bounds are outside [0, 2^32) or min > max |
PrngLabelError |
Fork label has a bad type, bad UTF-16, or a detached buffer |
PrngDestroyedError |
A method was called on a destroyed instance |
PrngStateError |
A state snapshot is the wrong size or could not be applied |
PrngAllocationError |
The instance pool could not grow |
Creates a PRNG. bigint seeds reduce mod 2^64. number seeds must be safe
integers — above 2^53 a number can no longer name a distinct seed, so those
are rejected rather than silently aliased.
A uniform 64-bit unsigned integer.
A uniform float in [0, 1).
A uniform integer in the inclusive range [min, max]. Both bounds must be
integers in [0, 2^32) with min <= max.
Derives a deterministic child stream. See Forking.
Captures and restores a stream's 32-byte position.
Releases the instance. Idempotent, and also bound to Symbol.dispose for
using.
The fork lineage, and whether the instance has been released.
The same generator behind a minimal Disposable interface.
Deprecated. The WASM module is instantiated eagerly at import, so there is
nothing to await. Use create().
Requires mise, which pins the toolchain:
mise install
deno task build
deno task verifydeno task verify runs the full gate: build, lint, format, type check, doc
lint, tests, and a publish dry run.
The .wasm binaries are built from src/prng.zig and are not checked in — CI
builds them from source on every run and on publish. To change the emitted
stream deliberately, run deno task golden and bump STREAM_VERSION in the
same commit.
MIT
0 comments
log in to comment.