Session — one node per origin
A browser origin runs exactly one Net node. Tabs contend for a Web Lock, the
holder runs the node on the main thread — RTCPeerConnection does not exist in a
worker — and every other tab attaches over a BroadcastChannel and gets the same
API.
import { openSession } from '@net-mesh/browser';
const session = await openSession({
credentialB64,
capabilities: ['transcribe'], // re-announced whenever this tab leads
subscriptions: ['jobs'], // restored whenever this tab leads
});
session.role(); // 'leader' | 'follower' — same surface either way
session.generation(); // exact decimal, moves on every handoffUse openSession unless you know you want otherwise. Two tabs calling connect()
with one identity (a rememberedIdentity() already persisted, or the same
injected secrets) are two nodes contending for it — which is what the election exists to prevent. Without
one, each tab's connect() is a separate node.
Declare what a new leader must restore
capabilities and subscriptions are options on the session rather than calls
because a new leader has to re-publish them without being asked:
- the leader keeps the union of every follower's
subscriptionssubscribed, so a tab that only calledsubscribe()after a handoff would leave a window where its channel was not subscribed by anybody; capabilitiesare re-announced on promotion, because an announcement is a lease.
Calling announce() or subscribe() yourself as well is fine and additive.
The same surface, with three promises
The difference from connect() is scope, not capability, with two exceptions:
a session refuses the lossy stream (openStream({ lossy: true }), which
netcode rides), and a
store over a follower's proxied session is not
established. Games use connect(). Otherwise everything works on both; three
methods are promises on a session because on a follower the work happens in
another tab:
BrowserNode | MeshSession | |
|---|---|---|
openStream | synchronous | promise |
counters | synchronous | promise |
isEnrolled | synchronous | promise |
Failures are the same taxonomy, re-typed by the same mapper, so a proxied refusal and a direct one arrive as the same class.
Direct peers
connectPeer(peerIdHex) takes this node from a routed session with another
node (through the anchor) to a direct DataChannel with it; the other side calls
acceptPeer(peerIdHex). Both resolve a typed outcome: direct, iceTimeout,
udpBlocked, noAnnouncement, handshakeFailed or superseded. Both exist on
connect()'s node and on a session.
connectPeer on a pair that is already direct and open is a no-op that
resolves direct with the live dialog, so calling it "to be sure" before
opening a stream is safe. A second offer would replace the working link and
close it under the peer. (A follower whose leader runs an older release still
re-offers.)
Nor does it cancel an attempt still under way. A connectPeer while another
for the same peer is connecting resolves with that attempt's outcome, and one
while an acceptPeer for the peer is answering waits for it. When that answer
settles the pair (direct, iceTimeout, udpBlocked, or superseded by a
newer attempt) it is the call's outcome too; only after an inconclusive one does
the call offer. Concurrent acceptPeer calls share one answer.
So a lobby, a store and netcode can each call connectPeer for the same host
without knocking out each other's attempt. This holds per node: another tab's
node is outside it.
Two pages, one offerer
Two pages may call connectPeer on each other at the same time. Left alone,
each page's answer would retire its own offer, both would run out their ICE
deadline, and the pair would stay relayed. So the lower node id offers. On the
higher id, connectPeer answers an offer from the peer that arrived in the last
10 seconds instead of offering back, and answers an offer that crosses its own
while it is under way. On either side, acceptPeer while the node's own
connectPeer for that peer is under way takes that call's outcome. Offers are
learned from the node's signal events, so no page code is needed, and both
pages may simply call connectPeer again after an attempt ended.
Someone must still answer when only one side offers.
The answering page calls acceptPeer by state, not by memory: whenever
peerAttempt(peer) shows the pair is neither direct nor connecting, whoever
offered before. With no offer waiting, acceptPeer gives up after a few seconds
and changes nothing, so calling it on a timer is safe. Gating it on flags such
as "we offered once" or "it was direct once" leaves every later offer
unanswered.
peerAttempt(peer) | The pair is | What to do |
|---|---|---|
direct: true | direct | nothing |
state gathering or open, not direct | connecting | nothing: a new offer would cancel it |
state iceTimeout, udpBlocked or failed | ended, relayed | either page may connectPeer again; the other answers |
| throws | never attempted | the page reaching out offers |
peerAttempt is on connect()'s node only.
Play over the relay first
The routed session is up long before ICE finishes, and most direct links land
after the first second or so. Don't hold the player on a loading screen for
them: race connectPeer against a short timer, open the stream over the relay,
and record the real outcome when it lands.
const attempt = node.connectPeer(peer);
attempt
.then((outcome) => { if (outcome.type === 'direct') markDirect(peer); })
.catch(() => {});
await Promise.race([
attempt.catch(() => undefined),
new Promise((resolve) => setTimeout(resolve, 1500)),
]);
const stream = node.openStream({
reliability: 'fireAndForget', peer, label, lossy: true,
});When the pair goes direct, the stream opened on the relayed session goes stale
and its send rejects with session. Reopen it with the same peer and
label.
A player that comes back
A game's page usually does not hold a credential of its own. It asks a game anchor for a short-lived anonymous one, and keeps the same identity across visits:
import { connect, requestCredential, rememberedIdentity } from '@net-mesh/browser';
const { credentialB64, bootstrapUrl } = await requestCredential({
anchorUrl: 'https://anchor.ai2070.net', game: 'my-game',
});
const node = await connect({ credentialB64, bootstrapUrl, ...rememberedIdentity() });https://anchor.ai2070.net is NET's public anchor: it admits any game id,
and keeps each site's games apart. Read an override from the page URL, so the
same build can run against a local anchor during development:
const anchorUrl =
new URLSearchParams(location.search).get('anchor') ?? 'https://anchor.ai2070.net';requestCredential is POST /credential on an anchor run with --game or
--open-games (see CLI); it fails with a typed
CredentialRequestError. rememberedIdentity() keeps this origin's player
secrets in localStorage, so the same player is the same node on every visit
(pass another key for a second player on one origin).
Remembering has a cost: a reloaded page comes back as the node it was, while a
host may still hold that node's old session. A game whose players need not
persist can leave rememberedIdentity() out. Each load is then a new player,
and a reload never collides with the session it left behind.
Background tabs
A page left in the background can lose its session with the anchor: the browser throttles or freezes it, and the anchor drops it. The node then neither sees other players' lobbies nor is seen hosting one, and nothing in the page's state says so.
- Record
disconnectedevents fromnode.onEvent. - Probe the rest. Call a service nobody serves: the anchor refuses it, so an
RpcErrorwithkind === 'rpc-refused'proves the session is alive (except status 4, backpressure, which is the leaf's own full call table). A timeout only means no answer came in time (a delayed packet does that too), so take the session for gone after two unanswered probes in a row. Aquerycannot tell you: the node answers it from what it last heard. - Probe when the page becomes visible again, and before listing or hosting lobbies if the last probe is older than about 20 seconds. Run one check at a time, and have everything that asks wait on it.
- If the session is gone, close the node and connect a new one, as a reload would. Leave a node that is in a match alone, and have a backgrounded page send a heartbeat so the other players do not take it for gone.
let node = await connect({ credentialB64, bootstrapUrl });
let inMatch = false; // true while a match runs on this node
let checking: Promise<void> | null = null; // one check at a time
type Heard = 'alive' | 'silent' | 'unsent';
// One probe. 'alive': the anchor answered (a refusal is an answer).
// 'silent': nothing came back in time, inconclusive on its own. 'unsent':
// the leaf's own call table was full (status 4), so no probe left the page.
function probe(): Promise<Heard> {
return Promise.race([
node.call('my-game.alive', new Uint8Array(0), 5000).then(
(): Heard => 'alive',
(error): Heard => {
if (error?.kind !== 'rpc-refused') return 'silent';
return error.failure?.status === 4 ? 'unsent' : 'alive';
},
),
new Promise<Heard>((resolve) => setTimeout(() => resolve('silent'), 5500)),
]);
}
async function recheck(): Promise<void> {
if (inMatch) return;
// Gone only after two silent probes in a row; an unsent one proves nothing,
// so the check ends and runs again next time.
if ((await probe()) !== 'silent' || (await probe()) !== 'silent') return;
try { node.close(); } catch { /* already gone */ }
const { credentialB64, bootstrapUrl } =
await requestCredential({ anchorUrl, game: 'my-game' });
node = await connect({ credentialB64, bootstrapUrl });
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
checking ??= recheck().catch(() => {}).finally(() => { checking = null; });
});Lifecycle
session.onLifecycle((event) => log(event.type));| Event | What it means |
|---|---|
leader_changed | The Web Lock changed hands; this tab's generation moved |
subscription_restored | A channel subscription was (re-)established by the leader |
leader_lost | The leader stood down and the work of a generation was failed |
generation_fenced | This tab refused a message from a superseded generation |
not_leader | This tab discovered it was superseded, and stood down |
generation_fenced and not_leader are the two sides of the same fence, and
both are surfaced rather than swallowed: a silent drop looks exactly like a lost
message, and a page debugging a resumed or restored tab needs to see which side
of the fence it is on.
The generation — not lock loss — is what fences a resumed tab. A tab that
presents a stale generation is refused, and that refusal is typed:
NotLeaderError (kind: 'not-leader') rather than a silent no-op.
A frozen leader, and why the error is rpc-indeterminate
A tab can also be frozen by the browser. Two facts matter, and only one of them is a measurement:
- Measured (Chromium 152, two real tabs driven over CDP): a frozen tab
keeps its Web Lock. No successor is elected while it sleeps, and nothing in
this tab's view changes —
role()still says'follower',generation()does not move, and noleader_lostorleader_changedarrives. - Decisive regardless of the transport: a frozen document's task queues do
not run. The leaf's pump, the DataChannel handler and the
BroadcastChanneldelivery carrying a follower's request are all queued tasks in that document, so nothing is processed and nothing is answered — not the calls, and not the cheap control messages either.
The follower arms its own deadline over the proxy round trip, and what that
deadline produces is rpc-indeterminate, not rpc-timeout. The distinction is
the whole disposition: a deadline on this tab cannot cancel work that may
already have been admitted on another, so the honest answer is "the remote may
have executed this".
So the signal is: rpc-indeterminate, role() === 'follower', an unchanged
generation(), and no reply to anything including the control chatter.
Do not retry on it. On resume the backlog flushes and the frozen tab is still
the legitimate leader holding a valid generation, so those calls may execute
late, after the caller already saw rpc-indeterminate. A page that retries can
cause the effect twice. Neither the follower's timer nor the package re-issues
anything: surface it, or wait.
session-lost and leader-lost follow the same rule — they are never retried
for you.
Close
session.close() stands the tab down and releases the lock. On the leader that is
what lets a follower promote — re-bootstrapping under the same identity with a
fenced new generation; on a follower it detaches this tab, and the node the other
tabs are using keeps running.
Every stream the session handed out is ended first, so no consumer is left waiting for a payload that will never come. A page that navigates away without calling it leaves the handoff to the browser's own lock release.