The client

meshSync gives you a status signal, a presence roster, per-path merge policies, automatic reconnection with rebase, and a peer-to-peer variant. The store stays a plain store the whole time.

@mmstack/meshnpm

Status and registration

#

mesh.status() is 'connecting' | 'waiting' | 'live' | 'reconnecting' | 'ejected'. Reconnection is automatic with exponential backoff. On reconnect the client resumes from a delta when it can, and re-applies any writes made while offline on top of whatever the room moved to. A relay restart is detected through a room epoch, so a stale sequence number never corrupts state. With register: 'track' the store also joins the nearest transition scope, so a reconnect shows up as pending to a boundary.

const mesh = meshSync(board, {
  room: 'board-42',
  writer: currentUserId,
  transport: webSocketTransport('wss://sync.example.com'),
  register: 'track', // a reconnect surfaces as pending to a <mm-suspense> boundary
});

mesh.status(); // 'connecting' | 'waiting' | 'live' | 'reconnecting' | 'ejected'

Conflict policies

#

The default is last-writer-wins, decided by a hybrid logical clock. Attach a merge policy per path for anything else: reconcile a list by item identity with keyedArray, or keep both sides of a clash as data with preserve. A custom merge can also wrap another CRDT such as Yjs for rich text. The package README has the pattern.

import { keyedArray, preserve } from '@mmstack/primitives';

meshSync(board, {
  room, writer, transport,
  policies: [
    { path: 'todos', merge: keyedArray((t) => t.id) },
    { path: 'title', merge: preserve },
  ],
});

Offline and durable outbox

#

Writes made while disconnected are held locally and sent on reconnect. That queue lives in memory by default, so a reload loses any write the room never acknowledged. Pass outbox to persist it to any AsyncStore, the same interface persist takes. On boot the client restores the queue and resends the unacknowledged writes, each under the origin it was recorded with. New writes go out under a fresh origin, since every boot mints its own. The restored writes then rebase onto whatever the room moved to.

The saved queue is a single-writer slot, and a Web Lock on the key elects its writer. crossTab sets what a second tab on the same key does. 'queue' (the default) waits with status() reading 'waiting' until the first tab closes, then takes over. 'ephemeral' goes live at once on its own origin and never touches the saved queue, so its unacknowledged writes are lost if it closes before the room acknowledges them. 'off' skips the lock so you can coordinate ownership yourself.

The outbox persists your unacknowledged writes, not a full snapshot. For a meshed store, use it in place of wrapping the store in persist. The two race on boot, and the outbox is the one that rebases offline edits onto the room.

import * as idbKeyval from 'idb-keyval';

meshSync(board, {
  room, writer, transport,
  outbox: { key: 'board-42', store: idbKeyval }, // survives a reload
});

Assemble a base before connecting

#

Pass whenReady to hold the connection until a local base is in place. meshSync awaits it before it connects and before it restores the outbox, so a store filled from another source is ready when the room welcome arrives and rebases your pending writes on top. This is the boot order for a worker-owned, meshed, persisted graph: the worker hydrates the base, the outbox restores this device's offline writes, then the room welcome supersedes the base and rebases those writes. Each source runs in turn instead of racing.

meshSync(graph, {
  room, writer, transport,
  outbox: { key: 'graph-7', store: idbKeyval },
  whenReady: () => baseReady, // resolves once the base is filled
});

Multiple tabs

#

Run tabSync and meshSync on the same store to share it across a user's tabs while one connection carries it to the room. The outbox lock elects the leader, so only one tab holds the relay connection and the others share state over tabSync. A write in any tab reaches the room through the leader, and a room write reaches every tab through tabSync. When the leader closes, another tab takes over, resends the persisted writes and continues under its own origin. Each layer is a separate reader on the store's op stream, so a follower's meshSync stays idle until it holds the lock and never opens a second connection.

import { store, tabSync } from '@mmstack/primitives';
import { meshSync, webSocketTransport } from '@mmstack/mesh';

const board = store<Board>(initialBoard());
tabSync(board, { id: 'board-42' }); // share across this user's tabs
meshSync(board, {
  room: 'board-42',
  writer: currentUserId,
  transport: webSocketTransport('wss://sync.example.com'),
  outbox: { key: 'board-42', store: idbKeyval }, // one tab leads the connection
});

Presence

#

An ephemeral side channel. Never persisted, never conflicts, drops when a peer leaves. Shape the payload however you like: cursors, selection, who is here, or an agent's current activity.

mesh.setPresence({ cursor: [x, y], section: 'pricing' });

const others = mesh.peers(); // [{ writer, origin, data }, ...]

Trust

#

Pass a policy (and a ctx when it reads claims) and the client validates each write before it hits the wire, matching the relay's own check. An honest client never emits an op the relay would reject, so the tripwire only fires on a broken or hostile peer, and an agent can be given a narrower write surface than a human.

meshSync(store, {
  room, writer, transport,
  policy: myOpPolicy,
  ctx: { kind: 'human', claims: { role: 'editor' } },
});

Agents

#

An agent acts under the same protocol as a person. mesh.fork() gives it a branch of the synced store and its writes stay off the room until a person approves: ops() is the staged change as data, commit() emits it to the room, and discard() drops it. The commit cites what the fork observed, so an edit that lands mid-review stays a concurrent value the merge policy decides rather than being overwritten by the approval.

const proposal = mesh.fork(); // the agent's branch, off the room
runAgent(proposal.store);      // it writes here

proposal.ops();      // StoreOp[] for the reviewer to see
proposal.commit();   // approve: emits as concurrent writes; a mid-review room edit is never steamrolled
// proposal.rebase();  // re-observe the room, then commit on top
// proposal.discard(); // reject: drops the staged writes

An agent can also join the room directly, scoped by the relay ACL through its ctx and policy. A live agent inherits the same conflict rules as everyone else, so a fast agent can win a last-writer-wins race on a shared field. Reach for the branch when a write should be seen before it lands.

meshSync(board, {
  room, writer: agentId, transport,
  ctx: { kind: 'agent', claims: { scope: 'pricing' } },
  policy: pricingScopeOnly,
});

See Agents for when to use each and how attribution works.

Peer to peer

#

webRtcMesh runs the same convergence over WebRTC data channels, using the relay only for signaling and membership. Peers exchange watermarks when a channel opens and catch each other up pairwise. It takes an injectable connector, defaulting to an RTCPeerConnection adapter with perfect-negotiation handling built in.

import { webRtcMesh, webSocketTransport } from '@mmstack/mesh';

const mesh = webRtcMesh(store, {
  room: 'call-7',
  writer: currentUserId,
  signaling: webSocketTransport('wss://sync.example.com'), // data flows peer to peer
});

Reachability. The library ships no ICE servers. Without any, browsers hide host addresses behind mDNS names, so links only form on one network segment. Pass an RTCConfiguration to rtcPeerConnector, or let the relay issue a list in every welcome through its iceServers option, which replaces the static list. The option also takes a function of the room and origin, called at each welcome, for minting short-lived TURN credentials.

Recovery. A link that does not open within fifteen seconds, loses a channel, or fails ICE twice (the connector restarts ICE once first) is dropped and rebuilt after a jittered backoff that doubles from one second up to thirty, for as long as the relay lists the member. Either side may rebuild; the other follows. Links are built only after the welcome of the connection they signal over, so every link carries that welcome's ICE servers. links.stalled() lists the members whose link was lost and has not opened again.