Quickstart
Build a node, put something on it, and prove the node accepted it. That is the whole of this page. It is deliberately smaller than a first program usually is, because the first thing worth knowing about Net is which of two node types you are holding.
Two node types, and picking the wrong one costs an afternoon
Every binding gives you both. They are not layers of each other and the names are close enough to be worth stating plainly:
- A bus node is in-process. It ingests events into a local ring buffer, counts them, and hands them back when you poll or subscribe. No network, no peers, no keys. This is what you build to check your install and what most tests use.
- A mesh node speaks encrypted UDP to other nodes. It carries capabilities, tools and nRPC, and it is the node the rest of this spine (announce onward) is about.
If you are here to move events inside one process, you want a bus node and you can stop after the first snippet. If you are here to have two programs find and call each other, you want a mesh node, and you need the next two sections.
A mesh node needs a shared key and a handshake
Two facts that are easy to skip and expensive to skip:
Every mesh node in a mesh uses the same pre-shared key. It is 32 bytes. The bindings disagree about whether you hand them raw bytes or a 64-character hex string, and that disagreement is real rather than cosmetic — the fragment below says which one yours takes.
Peers do not find each other by magic. A mesh node has to be connected to
another mesh node before anything folds between them: one side accepts, the other
connects, and both start. Until that happens, announce announces to nobody and
discover returns an empty list — which looks exactly like a bug in your
capability filter and is not one.
This is the single most common way a first mesh program fails: it is correct, and it is talking to itself.
Acceptance is not delivery
Whatever you put on a node, the call that accepts it is telling you one thing: the local node took it. It is not telling you a subscriber ran, a peer received it, or the work is done. That distinction has a page of its own — Submitted Is Not Completed — and it is the model, not an implementation detail to be optimized away later.
The first program therefore verifies a local counter rather than claiming a round trip. Every fragment below ends by asserting the node's own ingested count, because that is the strongest claim the smallest program can actually make.
Why your first program appears to hang
The default transport is memory, and memory discards events after counting them. A first program that publishes and then waits to read the same event back will not error. It will sit there, waiting for something that is never coming.
Reading events back needs either an adapter that retains them (Redis, JetStream) or the mesh transport between two nodes. That is a deliberate second decision, not a default you were supposed to have found.
Where this goes next
The seven pages of this spine are one journey, in this order: build a node → announce a capability → discover it from somewhere else → invoke it → watch what it emits → move artifacts too large for the bus → and handle the ways it fails.
Those links are language-neutral: each one states the objective and hands you the four lenses. Once you are inside a language the Next control keeps you there — it will not walk a Python reader into Go.
Build it — TypeScript
npm install @net-mesh/sdk @net-mesh/coreInstall both. @net-mesh/sdk is the ergonomic layer; @net-mesh/core is the
native binding it sits on, and several surfaces on this spine are reached only
through core. Pin them to the same version.
A bus node
import { NetNode } from '@net-mesh/sdk';
const node = await NetNode.create({ shards: 4 });
node.emit({ sensor: 'lidar', range_m: 12.5 });
node.emitRaw('{"sensor":"radar","range_m":45.0}');
node.emitBatch([{ a: 1 }, { a: 2 }, { a: 3 }]);
await node.flush();
await node.shutdown(); // explicit — Node finalizers are non-deterministicemit returns synchronously with a Receipt, or throws. A drop under
backpressure reaches you as a thrown error, not as a null return — the native
ingestRawSync returns an error and the wrapper dereferences it unconditionally.
A producer that neither catches nor reads stats().eventsDropped is silently
lossy. Use fire() when you genuinely want fire-and-forget.
shutdown() is not optional housekeeping. Node's finalizers run at a time nobody
promises, so a process that exits without it can lose whatever the drain worker
was still holding.
A mesh node
import { MeshNode } from '@net-mesh/sdk';
const psk = '42'.repeat(32); // 64 hex characters = 32 bytes
const node = await MeshNode.create({ bindAddr: '127.0.0.1:0', psk });TypeScript takes the PSK as a 64-character hex string, the same
representation Python uses — not the raw Uint8Array Rust takes. Passing bytes
fails twice over, at compile time and again at the native boundary:
error TS2322: Type 'Uint8Array<ArrayBuffer>' is not assignable to type 'string'.
Error: Failed to convert JavaScript value `Object {...}` into rust type `String`
on MeshOptions.psk { code: 'StringExpected' }A wrong length fails when the node is created, not when the first peer disagrees with you.
The handshake
const HOST_ADDR = '127.0.0.1:9001';
const host = await MeshNode.create({ bindAddr: HOST_ADDR, psk });
const agent = await MeshNode.create({ bindAddr: '127.0.0.1:9000', psk });
// Start the responder, then await it — do NOT await it on the line above.
const accepted = host.accept(agent.nodeId());
await agent.connect(HOST_ADDR, host.publicKey(), host.nodeId());
await accepted;
await host.start();
await agent.start();The missing await on that first line is the whole point. accept()
resolves only once an initiator has connected, so await host.accept(...)
followed by agent.connect(...) never reaches the second line: the handshake
needs both halves in flight at once. Calling accept without awaiting it
starts the responder and hands you the promise to settle after connect,
which is what tokio::join! does on the Rust page and what a second thread
does on the Python one.
Two more things the shape of that snippet is telling you. start() is async in
TypeScript — forgetting the await gives you a node that is not started yet and
no error saying so. And localAddr() is how a :0 bind becomes connectable:
port 0 asks the OS to pick a free port, and the port it picked is knowable only
by asking the node back.
Replace the first two lines of the snippet above with these three, and leave the rest of it alone:
const host = await MeshNode.create({ bindAddr: '127.0.0.1:0', psk });
const agent = await MeshNode.create({ bindAddr: '127.0.0.1:0', psk });
const HOST_ADDR = host.localAddr(); // e.g. '127.0.0.1:54417'Hard-coding the port, as the snippet above does, is fine when you chose the
number and both sides already agree on it. :0 plus localAddr() is what you
want when you did not — in tests, and anywhere a fixed port would collide.
Node ids are 64-bit, so they come back as bigint rather than number. That
matters the moment you use one as an object key or compare it with === against a
literal — 1n === 1 is false.
Verify it worked
const stats = node.stats();
if (stats.eventsIngested !== 1) throw new Error('the bus did not accept the event');
console.log(`accepted: ingested=${stats.eventsIngested}`);Expect one line, accepted: ingested=1, and a clean exit. The counter is a
producer-side count: accepted, not received and not stored.
Next: Announce a capability.