JavaScript
Subscribe to a Ranvier stream from Node, publish one back, and read what a
runtime told your node — over the same implementation the framework itself runs.
Framing, subscription bookkeeping, gap accounting and the control plane's state
machine live once, in Rust, behind the C ABI at
crates/abi. This package is a translation above
them.
import { subscribe } from "@extendedresearch/ranvier";
const gaze = await subscribe("127.0.0.1", 7501, "gaze");
for await (const sample of gaze) {
console.log(sample.sequence, sample.values.length);
}
sample.values is a Buffer of whatever the publisher encoded. This package
does not decode it; the package that owns the schema is the one that can, and
sample.streamRef plus await gaze.announcements() is how you find out which
schema that is.
Three things worth knowing before you write against it
Every 64-bit field is a BigInt. Timestamps, sequence numbers, every
counter. Nanoseconds since an epoch run to about 1.8e18 against a safe integer
limit of 9.0e15, so Number(sample.monotonicTimestampNs) quantises to the
nearest ~256 ns and raises nothing. If you need a number, subtract a bigint
origin first and convert the small difference:
let origin: bigint | undefined;
for await (const sample of gaze) {
origin ??= sample.monotonicTimestampNs;
const millis = Number(sample.monotonicTimestampNs - origin) / 1e6;
}
Match a sample to a publisher by streamRef, never by name. Several streams
may publish under one name — that is deliberate — and each counts its sequence
from 1. Reading them as one series turns two healthy streams into duplicates and
no progress.
Every call that can wait is a promise, and close() is the exception. The C
ABI permits one handle to be used from one thread at a time, with a single
documented exception: closing a subscription while another thread is inside a
blocking read. So each waiting call runs on a thread of its own and the
subscription's counters are asynchronous too — a synchronous counter read would
have to queue behind a wait, and queueing on the event loop is what makes a
native module unusable in a real application.
Errors
Branch on error.code or on the class. Never on the message.
import { subscribe, TimeoutError, NetworkError, AddressError } from "@extendedresearch/ranvier";
try {
await subscribe("tracker.local", 7501, "gaze");
} catch (error) {
if (error instanceof AddressError) throw error; // the string is wrong; retrying will not help
if (error instanceof TimeoutError) return retry(); // the peer has not answered yet
if (error instanceof NetworkError) return later(); // the attempt was answered, in the negative
throw error;
}
error.code is the ABI constant's own name — RANVIER_ERR_TIMEOUT — so it is a
value the specification names rather than a sentence somebody can reword. Every
error is a RanvierError.
One distinction the ABI does not currently make: next() answers null
both when its timeout elapsed and when the subscription was closed, because
RANVIER_ERR_EMPTY covers both and does not say which. Read subscription.closed
to tell them apart — this package knows that for itself and does not invent a
difference the ABI has not made.
A counter this binding does not carry raises
await subscription.counts() answers a snapshot of seven counters. Reading a
name that is not one of them raises a RanvierError naming the ones that are,
rather than answering undefined.
const counts = await gaze.counts();
counts.missed; // 0n — samples the sequence numbers say never arrived
counts.missing; // raises: "Counts carries no `missing` — this binding calls
// that counter `missed` … Python's Counts calls the same
// number `missing`"
undefined is the wrong answer for a counter specifically, which is why
this is narrow. A payload field read under the wrong name breaks on the next
line — sample.value.length raises a TypeError and names it. A counter read
under the wrong name is undefined, and undefined in Number() or a
truthiness test is indistinguishable from zero. Zero on a loss counter is the
answer a researcher hopes for, so the mistake reports a clean recording over one
that lost samples and nothing anywhere says otherwise. This binding records
research data; that is the worst shape a defect can take here.
The rest of the value objects are left alone. Sealing the receive path would put a proxy allocation and a trap on every sample to catch a mistake that already announces itself.
What the seal costs, in full:
| Still works | Now raises |
|---|---|
"missing" in counts → false | counts.missing !== undefined |
Object.keys(counts), { ...counts } | |
JSON.stringify(counts, …), await counts | |
console.log(counts) | |
structuredClone({ ...counts }) | structuredClone(counts), worker.postMessage(counts) |
A proxy is not structured-cloneable, so a counter snapshot cannot be posted to a
worker as it stands; spread it into a plain object first. Feature detection by
reading is the pattern that produced the defect, so it is the pattern that now
fails — in, Object.keys and spreading are three ways to ask the same
question that all still answer.
TypeScript catches counts.missing at compile time already. The seal is for
plain JavaScript, for an any, and for a dynamic counts[name] out of a table
of counter names — which is what a tool comparing two languages is made of, and
where this was found.
Enumerated values are read out of the library, not written down
RefuseReason, StopReason, StoppingReason, Origin and AssertionMethod
are built at load time by looping over the tables the ABI exposes for exactly
that purpose.
import { RefuseReason } from "@extendedresearch/ranvier";
RefuseReason.byName["PORTS"]; // 4
RefuseReason.nameOf[4]; // "REFUSE_REASON_PORTS"
RefuseReason.members.length; // however many the contract declares
This is not a stylistic preference. Two hand-written control planes in this
ecosystem each transcribed four of the six refuse reasons, both stopped at the
same hole — REFUSE_REASON_PORTS, the one that reports a node whose advertised
ports disagree with its manifest — and their tests asserted reasons by number, so
the suites mirrored the hole exactly and could never have gone red.
Decision 0047 §4
is the argument; test/contract.test.ts compares what the loop reports against
proto/ in both directions.
Direction, TransportAction and Stamp have no runtime table in the ABI, so
their values come from its constants. Direction carries the ABI's numbering,
not the wire's: the wire has UNSPECIFIED = 0, IN = 1, OUT = 2 and the ABI
has IN = 0, OUT = 1 with no unspecified. Both about.addPort and
joined.bindings() are ABI calls, so the ABI's numbering is the right one here —
and a value carried between the two schemes unconverted is off by one.
What this package does not do
It does not run a control plane. What is here is the node's end: join a
session, read your bindings, report where you landed, read a refusal completely.
A program that accepts registrations, issues binds, decides to refuse one and
watches heartbeats is crates/runtime/session/src/plane.rs, and the ABI does not
export it. Decision 0047 §7 says so and says which applications are on the far
side of that line.
It does not reconnect, and that is deliberate rather than unfinished.
deferred.md's Redialling a publisher
that went away row says where the work belongs: a redial loop written once per
binding is exactly the per-language client logic decision 0047 exists to end, so
it goes in ranvier-node and gets exported. spec/wire.md's Not yet specified
section is why it is not simply a matter of retrying — the data plane has no
liveness rule, and a new connection re-runs the handshake and continues from
wherever the publisher's counters have reached, so the loss tracker treats the
gap as having missed nothing. runtime.connect is the primitive; call it again,
and know what counts() cannot see.
It does not decode payloads. See the first paragraph.
Where things are
| Path | Holds |
|---|---|
src/ | The napi-rs addon. Calls the C ABI, owns the handles, and does every wait on a thread that is not the event loop's |
ts/ | The public interface. Turns what the addon reports into RanvierError and its subclasses, builds the enumerations from the tables the library reports, seals the counter snapshot so an unknown read raises, and adds what a native class cannot express — a Symbol.asyncIterator, and subscribe |
test/ | The conformance suite. contract.test.ts checks the enumerations against proto/; data.test.ts is the behaviour suite carried over; counters.test.ts holds the seal to its promise, deriving every name it tries from the ABI header, the PyO3 source and the binding corpus; interop.test.ts dials a publisher in another process |
native/, dist/ | Generated. Not committed |
The division between the two halves is the one thing worth knowing before
reading either: the addon reports and the TypeScript raises. napi-rs carries
a JavaScript error's code in a fixed enum of Node-API's own conditions, with no
room for a library's, and an asynchronous function cannot widen it at all — so
the addon spells failures as the ABI constant's name and ts/errors.ts turns
each into the right class. src/status.rs states the format.
Building and testing
npm install
cargo build -p ranvier-node --example publish # the interop test's publisher
npm test
npm test builds the addon in debug, compiles the TypeScript, and runs the
suite. RANVIER_REQUIRE_INTEROP=1 turns an absent publisher from a skip into a
failure, which is what continuous integration sets — a job that builds the
publisher, skips the only test that crosses a process boundary and reports green
is the state that variable exists to close.
The tests that did not survive the move, and where the property lives now
The package this replaces stood up a publisher written in TypeScript, which made
its suite a test of the SDK against its own understanding of the wire. Here the
publisher is runtime.serve and stream.publish, so both ends are the
implementation — which is stronger, and which costs the tests whose whole subject
was bytes a well-behaved publisher never sends. Three did not come across:
| Test | Why not | Where the property is held |
|---|---|---|
| a gap in the sequence is counted | A well-behaved publisher does not skip a sequence number, and the ABI exposes no way to inject one | crates/runtime/node/tests/transport.rs, network_loss_shows_as_a_gap_rather_than_as_renumbering |
| the samples around an unreadable frame still arrive | Needs valid sample frames written by hand beside the bad one, which is a second implementation of the message format | The counting half is here, as a frame that carries nothing readable is counted |
a streamRef whose announcement never crossed still delivers samples | Both streams announce when both are real, so the state cannot be reached from outside | crates/runtime/node/tests/transport.rs |
Five more were framing mechanics — the length prefix, the 16 MiB ceiling, a frame split across reads — and those die with the wire client, which is the point of the move.
Module format
ESM, one export path, node >= 20 — the same as the package this replaces. The
addon is loaded through createRequire, which is how a .node file is reached
from an ES module; none of that is visible to a caller.
Platforms
@napi-rs/cli emits ranvier.<platform>.node and a loader that tries the local
file first and then @extendedresearch/ranvier-<platform> — the optional
dependency per platform that npm resolves to exactly one download. The declared
targets are Windows, Linux and macOS on x64 and arm64.
On a platform with no prebuild the loader throws at import, naming every
candidate it tried. That is the intended failure: a native addon cannot fall back
to a pure-JavaScript path, because there is no second implementation to fall back
to — which is the whole argument for this package existing.
NAPI_RS_NATIVE_LIBRARY_PATH points the loader at a binary you built yourself.