MESH ONLINECODENAME:
v0.34

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

shell
npm install @net-mesh/sdk @net-mesh/core

Install 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

typescript
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-deterministic

emit 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

typescript
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:

text
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

typescript
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:

typescript
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

typescript
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.

§ parity · Event bus — ingest + pollfrom the capability record
Rust supportedNode / TS supportedPython supportedGo supportedC supported · poll