Browser quickstart
Build the package (Browser SDK has the two
commands), serve dist/ over HTTP, and open a page.
What the first run needs
Two things, and neither comes from the page:
- An anchor, reachable over HTTPS.
- A bootstrap credential per player, which that anchor issues.
NET runs a public anchor at https://anchor.ai2070.net. It admits any game id
from any page, with no setup, and keeps each site's games apart: a game is keyed on the
page's origin plus its id, so two sites that both call their game chess never
meet. Use it and skip to Connect below.
To run your own anchor instead, use the CLI. It needs a certificate browsers
trust, your page's origin, a pre-shared key file and an issuer key file
(net-mesh identity generate --out issuer.toml):
net-mesh anchor serve --psk-file psk.hex \
--url https://anchor.example.com --tls-cert cert.pem --tls-key key.pem \
--allow-origin https://game.example.com \
--issuer-identity issuer.toml --game my-gameEach player then asks it for an anonymous credential — see Connect below.
Without --game the anchor serves no enrollment, and a page's connect() fails
with session: rpc: the call's deadline elapsed after a perfectly good TLS
handshake. One anchor can serve several games (repeat --game) and keeps them
apart: a player never discovers or reaches another game's players.
tests/rtc_browser/run.sh (run.ps1 on Windows) is the other one: the CI
harness, on Chromium and Firefox, which issues its own CA and trusts it per
engine rather than disabling certificate checking.
Connect
import { connect, rememberedIdentity, requestCredential } from '@net-mesh/browser';
// An anonymous credential for this player, from the public anchor (or your own).
const { credentialB64, bootstrapUrl } = await requestCredential({
anchorUrl: 'https://anchor.ai2070.net',
game: 'my-game',
});rememberedIdentity() keeps the player the same across visits (the secrets live
in localStorage; one player per browser profile). A credential binds to the
first player that uses it.
const node = await connect({
...rememberedIdentity(),
credentialB64, // the whole `net-bootstrap:…` string
bootstrapUrl, // the anchor that issued the credential
});
console.log(node.nodeIdHex(), node.anchorIdHex());connect() resolves once the session is established and enrollment has run —
it awaits that exchange for you. bootstrapUrl is the anchor's base URL: the
leaf appends /rtc/anchor, /rtc/offer and /rtc/trickle to it.
node.isEnrolled() is the honest read on whether the anchor admitted this leaf.
false means the session is still provisional, and a call will die on its
deadline — check it before blaming a rpc-timeout on a slow anchor.
Subscribe, announce, call
node.on('channel_message', (event) => render(event.payload));
await node.subscribe('jobs');
await node.announce(['transcribe']);
const reply = await node.call('summarise', new TextEncoder().encode('…'), 5_000);
const workers = await node.query('transcribe');
// [{ nodeId, entityId, capabilities, rtcAddr, noisePubkey, version }]subscribemakes the leaf deliver a channel's messages aschannel_messageevents. The leaf only delivers channels this node subscribed to.announcepublishes the capability tags other nodes discover you by. An announcement is a lease, not a registration — a peer that looks a few seconds later finds nothing unless you re-announce. On a session, declaringcapabilitiesinopenSessionmakes a new leader re-announce them for you.callis nRPC and resolves the reply. It rejects with a typedRpcError—rpc-refused,rpc-timeout,session-lost,leader-lost,rpc-indeterminate,rpc-malformed— and never retries silently. A call whose session or leader went away is surfaced so the caller decides.queryresolves parsed descriptors.nodeIdis decimal;peerIdHex(descriptor.nodeId)is the 16-hex spellingconnectPeertakes.
Streams
const stream = node.openStream({ reliability: 'fireAndForget' });
await stream.send(frame);
for await (const payload of stream) consume(payload); // Uint8Arrayreliability is required, not defaulted. At the wasm boundary an absent key
and a misspelled one are the same thing, so { reliabilty: 'fireAndForget' }
would silently produce a reliable stream; making the field required turns that
typo into a compile error.
reliable retransmits and reorders by seq. fireAndForget does neither, which
is the point of it: a dropped frame stays dropped and the consumer sees the gap.
A lossy stream — openStream({ reliability: 'fireAndForget', lossy: true }) —
rides a second, unordered DataChannel with no retransmits, for high-rate state
where only the newest value matters (positions, inputs). Each packet arrives
promptly or not at all, and none delays anything else; one that would queue
behind a backed-up buffer is dropped instead. lossy: true with 'reliable'
throws, and it is connect()-only: a session refuses it. A peer or anchor that
opens no lossy channel still gets the packets, on the reliable one.
Netcode is built on it.
A stream is identified by (peer, streamId), not by its id alone — a stream
id is an application label scoped to a session, so the same label against two
peers is two streams. openStream({ peer }) addresses a peer in 16 hex digits;
absent, the stream addresses the anchor.
Events
node.on('stream_data', (event) => { /* event.payload is Uint8Array */ });
node.onEvent((event) => log(event.type)); // every event
for await (const event of node.events()) { /* … */ } // async iterableTags are the leaf's vocabulary verbatim — channel_message, not
channelMessage. connected, disconnected, channel_message, stream_data,
announcement, signal, rpc_response, dropped, rtc_failure and
leader_changed are typed; an unknown tag arrives as
{ type: 'unknown', tag, raw } rather than being dropped, so a newer leaf never
goes silent against an older page.
Two invariants worth knowing:
- 64-bit ids are exact decimal strings, never JS numbers.
JSON.parserounds integer literals above 2^53, which would silently mis-route a page filtering a channel by hash. - Byte payloads are already decoded for you: standard base64 on the wire,
Uint8Arrayin the event.
A listener that throws is reported to the console and skipped. It neither takes down its siblings nor unwinds into the wasm frame that called it.
Close
node.close();close() ends the iterators it handed out — a for await loop leaves the loop,
and an awaiting iterator.next() resolves { done: true }. That is the normal
end of iteration, so a loop written to handle "the stream ended" needs nothing.
Opening a stream on a closed node is a typed SessionError, not a dead handle.
Loading the wasm differently
The default lookup is relative to the entry point. Override it when your deployment puts the wasm somewhere else:
await connect({ credentialB64, wasmUrl: '/assets/net_leaf_bg.wasm' });
await connect({ credentialB64, wasm: await import('/assets/net_leaf.js') });Sizes
The package ships a size report, because a page pays for all of it:
npm run size # table + machine-readable SIZE lines
node scripts/size.mjs --assert # non-zero exit if the wasm exceeds 1.5 MB gzMeasured on the current build: net_leaf_bg.wasm is 225 369 B gzipped and
the wasm-bindgen glue 8 684 B, so a page that takes the single-file bundle
downloads about 240 kB in total.