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, minted by that anchor's operator and handed to the page out of band.
One caveat worth knowing before you go looking for it: net-mesh anchor serve
does not host a browser. A page's connect() makes an enrollment call while
establishing its session, and that command registers only the anchor directory
and the ICE ledger — nothing answers the enrollment service, and the page fails
with session: rpc: the call's deadline elapsed after a perfectly good TLS
listener and handshake.
The shipped thing that does serve enrollment is the browser demo:
cd net/crates/net
cargo run --release --manifest-path examples/browser-demo/host/Cargo.toml -- \
--headless --seconds 600It prints its page URL and mints a credential per tab:
curl -s "http://localhost:<port>/config?tab=1" | jq -r .credentialB64tests/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 } from '@net-mesh/browser';
const node = await connect({
credentialB64, // the whole `net-bootstrap:…` string
bootstrapUrl: 'https://anchor.example', // optional; the credential carries one
});
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-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 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.