MESH ONLINECODENAME: Paranoid
v0.37

The networked store

For a page that has a world rather than a request: one node hosts an authoritative document, others join replicas of it, and the host decides what each caller may see and do.

typescript
import { defineStore, hostStore, joinStore } from '@net-mesh/browser';
 
const host = hostStore({
  definition, store: 'world', transport: node,
  initialState,
  maxEventBytes: 8104,
  authorize: request => request.audience.every(a => a !== 'command'),
  project: (state, audience) =>
    audience.includes('command') ? state : publicPart(state),
  actions,
  inputs,
});
 
const replica = joinStore({
  definition, store: 'world', transport: node, host: hostNodeIdHex,
  audience: ['crew'], key: 'player', maxEventBytes: 8104,
});
await replica.ready();

Define once, host or join

defineStore({ id, version, state, empty, actions, inputs }) is a reusable typed description and carries no state or connection. It holds validators, never authority handlers, so a client bundle that joins a store does not carry the owner's gameplay code.

Two properties are checked at definition time, because their violation is invisible until much later:

  • empty() must itself be a valid state. It is what a replica installs when it loses visibility, so an empty() the schema would reject would fail mid-transition, with the old projection already fenced.
  • id and version must be usable as an incarnation identity. They travel in every envelope, and a blank id or a non-integer version turns a version-mismatch refusal into a confusing parse error at the far end.

Validators are the shape of schema.parse, so Zod, Valibot or a hand-written check all work without the store depending on any of them.

The host is the authority

OptionWhat it decides
definitionThe typed description
storeWhich store of that definition this is; defaults to the definition id
transportThe node it serves over
initialStateThe document as it starts
maxEventBytesThe largest frame your transport carries (the package's own tests and demo use 8104)
authorizeWho may read, act or send an input — handed the authenticated peer
projectWhat a given audience may see
actionsOne handler per declared action
inputsOne handler per declared input

A node may host several stores of one definition — a lobby and a match, three worlds — and each must name itself, because every host store on a node sees every frame and a join names no handle. Two stores answering to one name on one transport is refused at construction, where the mistake is legible, rather than resolved by whichever listener registered first.

An action handler runs inside one synchronous transaction: it stages writes through its context and returns the output, or throws to reject. Returning, throwing or handing back a thenable invalidates the context, so a retained context cannot write from a later microtask.

typescript
fire: (input, context) => {
  const target = context.getState().ships[input.at];
  if (target === undefined) throw new Error('no such ship');
  const hull = Math.max(0, target.hull - 25);
  context.setState({
    ships: { ...context.getState().ships, [input.at]: { ...target, hull } },
  });
  return { hull };
},

context.peer is the caller's mesh node id in 16 lowercase hex, and it is the identity the transport proved. No store frame has an originator field to consult, so a page cannot claim to be someone else.

Actions and inputs are different instruments

  • An action is acknowledged, validated and correlated: replica.act(name, input) returns a promise resolving the handler's output, or rejecting with a typed code.
  • An input is coalesced latest-value intent with no per-call promise: replica.input(name, value) returns a disposition (queued, replaced, or dropped with a reason) describing what happened locally. It is not remote acceptance, and a dropped input may be the last one — the handler is written to tolerate that.

Audiences and projection

A replica asks for an audience (audience: ['crew']) and carries an opaque key its host's authorize reads. The host's project(state, audience) returns a validated S — never a partial — so visibility is expressed in the schema: collections omit invisible entities, individually hidden fields are explicit null or a tagged value, and empty() means absence.

A zero must never be readable as "you cannot see this", and a replica cannot read what it was not given: the withheld part is absent from the frames, not hidden in the renderer. replica.setAudience(names) asks for a different audience and resolves when the new projection is installed.

The transport, and one gate to know about

A store needs a StoreTransport — the structural subset of the node that connect() and openSession() both satisfy. openStream is synchronous on the direct node and a promise on a session, and the store awaits both.

connect() is the supported transport today. A joiner needs a session with its host, which the store installs through connectPeer — and on a session that call is a proxy round trip to the tab holding the lock. The store's own use of a proxied stream on a follower (last-consumer cleanup, the peer/stream lifecycle across a leader change) is not established: a host and a joiner in one tab are exercised, two tabs sharing a leader are not. One tab per node is also what a game usually wants.

A replica of the node it is running on is refused with invalid-data — a node has no session with itself, and refusing at construction beats a no session with 0x… from inside the transport after the subscription was accepted.

An announcement is a lease, so both sides re-announce on a timer; a joiner that looks a few seconds late otherwise reports that the host never announced.

The replica handle

typescript
await replica.ready();                  // a consistent view is installed
replica.getState();                     // ReadonlyState<S>
replica.subscribe(listener);            // state changes
replica.subscribeStatus(listener);      // phase, stale, error
await replica.act('fire', { at });
replica.input('steer', { dx, dz, dt });
await replica.setAudience(['crew']);
await replica.reconnect();              // the session was replaced
await replica.close();

joinStore returns as soon as the join is sent — ready() is what waits, so a caller that wants a loading state does not have to await the world first.

getStatus() is readiness kept out of game state: a phase, a stale flag meaning retained state is no longer known-current (not "empty world"), and the last StoreError.

A replica has no setState. That is a type-level fact rather than a runtime check: writes go through actions and inputs, and only the host's handle carries the setter.

Codes to branch on

Every refusal is a StoreError with a code:

CodeWhat it means
invalid-dataA payload failed its validator
version-mismatchDefinition id or version disagreement
forbiddenThe owner's authorize refused
not-readyThe handle has no live, synchronized view
capacityA declared bound was reached
timeout / abortedThe caller's deadline or signal fired
indeterminateSubmitted, and no response established the outcome
owner-lostThe store incarnation ended — terminal
closedThis handle is unusable: unknown, expired, fenced, or bound to another peer
action-rejectedThe owner refused this action, or a handler broke the transaction contract
result-expiredThis request cannot execute again, and its original result is unavailable

Three of those carry a disposition worth stating plainly:

  • closed is deliberately one code for four causes, so a refusal cannot disclose whether a handle exists. It is terminal for the handle, while the subscription is recoverable by joining afresh — which yields a new handle, not a resumption of the old one.
  • owner-lost is said, not inferred. A closing host sends a farewell to every handle it holds, because silence is not an answer and a replica whose host closed would otherwise learn nothing until its own deadline fired and reported indeterminate.
  • result-expired is never a success receipt. It asserts nothing about whether the original attempt committed; a non-reexecution floor is not evidence of execution.

Next

  • Three — render the replica's entities
  • Errors — the node-level taxonomy underneath