@nice-code/realm
v0.58.0
Published
Readme
@nice-code/realm
Docs: nicecode.io — guides, integrations, and the full API surface. Working with an AI assistant? Point it at nicecode.io/llms-realm.txt (just this package) or nicecode.io/llms.txt (the whole stack) — the complete, current docs flattened into plain text.
A schema-defined, authoritative shared-state engine: many clients continuously observe (and
optimistically write) one server-owned state tree, over a binary patch protocol riding your app's
one secure connection — @nice-code/action shares that connection when you use actions; the realm
carries it alone when you don't.
Realm is the state plane; actions are the action plane. Rule of thumb: realm for state that many parties watch; actions for things that happen. One-shot commands with results, reliability tiers, offline outboxes, progress streaming — those stay actions. Live positions, presence, inventory, lobby state — that's a realm.
The documented API is the supported API. The root export (plus /platform/cloudflare,
/react, /testing) matches the docs site and follows semver — see
Stability & Versioning for the pre-1.0 posture,
lockstep-versioning, and the wire-format skew promise; @nice-code/realm/internals (frame codec,
rule trie, slicing machinery) is explicitly unstable.
The one definition
Everything derives from a single defineRealm block — types, wire token map, schema hash, rule
trie, and the client prediction bundle:
import { defineRealm, t } from "@nice-code/realm";
export const gameRealm = defineRealm({
id: "game_realm",
avatars: {
player: { persistentId: t.string() },
spectator: { persistentId: t.string() },
},
state: {
config: t.object({ roundActive: t.boolean() }),
players: t.record(t.id("playerId"), t.object({ x: t.number(), y: t.number() })),
},
rules: (r) => [
r.path("config.*").view(r.everyone).alter(r.serverOnly),
// Owner-writable, everyone sees it — a direct-write fast lane for absolute-set values.
r.path("players.$playerId.{x,y}")
.view(r.everyone)
.alter(({ avatar, params }) => avatar.persistentId === params.playerId || myErr.fromId("not_yours")),
],
intents: (i) => ({ /* named, atomic, settle-only server mutations — see below */ }),
});This module ships to clients. Never close over secrets in a realm definition. Server-only data enters at serve time via
createRealmServerEngine(realm, { ctx }), reachable only insider.serverChecked(...)rules and intentapplybodies (declare its type withserverContext: t.serverContext<MyCtx>()).
Serving (Cloudflare Durable Object)
A realm-only DO hosts its hibernatable sockets with serveWireDurableObject — the server twin
of createWireClient — with no @nice-code/action import, no ActionRuntime, and no channel.
One connection, one handshake, one identity:
import { serveWireDurableObject } from "@nice-code/wire/platform/cloudflare";
import { hostRealm, serveRealmDurableObject } from "@nice-code/realm/platform/cloudflare";
const realms = serveRealmDurableObject(this.ctx, {
realms: {
game_realm: hostRealm(gameRealm, {
avatarType: "player", // identity defaults from the authenticated socket
engine: {
ctx: serverCtx,
initialState: (info) => makeGenesis(info.instanceId),
onAvatarDetach: (avatar) => pruneEphemeral(avatar), // e.g. delete presence rows
},
}),
},
});
const server = serveWireDurableObject(this.ctx, {
identity: backendCoord.specify({ insId: this.ctx.id.toString() }), // the handshake identity
keyPrefix: "realm-ws:", // DO-storage namespace for the crypto identity + TOFU pins
protocols: [realms.protocol], // ← the whole integration
});
// fetch => server.fetch · webSocketMessage => server.receive · close/error => server.drop
// alarm => this.realms.alarms?.alarm()A DO that also serves actions registers the same realms.protocol on @nice-code/action's
serveDurableObject instead — both planes then share the sockets (the action host rides the same
wire acceptor underneath):
import { serveDurableObject } from "@nice-code/action/platform/cloudflare";
const server = serveDurableObject(this.ctx, gameChannel, {
runtime, clientEnv,
protocols: [realms.protocol],
channelCases: { /* the channel's actions */ },
});Persistence is automatic: every commit lands in the DO's SQLite patch log; snapshots are written
on a cadence; a cold start is snapshot + tail replay. A changed schema hash refuses to boot
unless you pass a migrate(oldBlob, oldHash) hook — persisted state is never silently
reinterpreted. Hibernation wake reconciles presence (vanished clients get a synthesized detach)
and clients resume via patch replay, not re-downloads.
Outside Cloudflare, createRealmServerEngine + createRealmAcceptor are host-agnostic — anything
that can register a wire frame protocol can serve a realm.
Connecting (browser / React Native)
A realm-only app opens the connection with createWireClient — no actions, no ActionRuntime:
import { createWireClient, wsCarrier } from "@nice-code/wire";
import { connectRealm, realmConnection } from "@nice-code/realm";
const client = createWireClient({
identity: myCoord, // this client's authenticated identity
peer: backendCoord, // the server it dials
storage, // durable — holds the crypto identity + TOFU pins
transports: [{ carrier: wsCarrier(() => ({ url: "wss://api.example.com/realm/ws" })) }],
});
const realm = connectRealm(gameRealm, {
avatar: { type: "player" }, // identity defaults from the connection's authenticated coordinate
connection: realmConnection(client),
onMutationRejected: ({ error, rolledBackPaths }) => toastRollback(error, rolledBackPaths),
});
realm.store // @nice-code/state Store — read/subscribe/React
realm.update((draft) => { draft.players[me].x = 412; }); // optimistic; returns a settle handle
realm.intents.purchase({ purchaseId: newId(), itemId }); // settle-only lever (no data result)
realm.pending // { count, has(prefix), subscribe } — "saving…" UIWith actions? Pass the
connectChannel(...)connector torealmConnectioninstead of acreateWireClient— the realm then shares that same secure socket. Everything below is identical.
Every write API returns a settle handle — a lazy thenable safe to ignore (fire-and-forget
cursor moves produce zero unhandled rejections), with status/error, and a read-your-writes
guarantee: after await handle, the store already reflects the authoritative outcome.
React bindings (@nice-code/realm/react): useRealm, useRealmValue, useRealmStatus,
useRealmPending, useRealmIntent.
Connection lifecycle & liveness
await realm.whenLive()resolves on first hydrate (rejects on attach failure/timeout);realm.subscribeStatus(listener)fires on everyconnecting ↔ live → detachedtransition — including quiet link drops that touch no state (useRealmStatusrides it).- Staleness probe (on by default,
staleProbeAfterMs: 25_000): a live handle that hears no realm frame for the window re-hellos with itslastKnownVersion+ log epoch — current clients get an ~10 B empty replay, stale ones exactly the missed patches, and a wiped/re-created server forces a clean full hydrate (the epoch names the history, so version numbers from a different past never replay as a diff). Lost wake broadcasts, missed caps, and suspended tabs all self-heal through it — but note this is a property of an already-live handle: the probe re-syncs what an attached realm sees, it never establishes a connection. Getting (and keeping) the link up is the connector's job —connect()failures arm a keep-alive backoff redial ladder, observable viaonLinkEvent(see the resilient client page). - Server limits are advertised in the hydrate: an over-limit coalesced flush splits into
consecutive ≤
maxPatchesPerFrameframes (each part server-atomic; every handle settles with the last part, so read-your-writes holds — a split flush is atomic per part, not as a whole), andlimits_exceededrejects retry (re-split / backoff) instead of settling fatally.
Imperative renderers: the patch feed
realm.listenToPatches(listener) emits the projected-store delta per flush (name-space
immer patches): confirmed frames verbatim when there's no optimistic overlay, an
identity-pruned diff over rebases, and [{ op: "replace", path: [] , value }] on a hydrate
(the rebuild signal). Apply it to a typed array / canvas / scene graph instead of re-diffing
the whole state per flush — the pixel demo's board mirror is exactly this in ~30 lines.
The write contract (read this one section)
A direct write (realm.update) is LWW with absolute-set semantics: the server applies
your literal values in arrival order. "Cursor is at 412" is a direct write. Read-modify-write
composed locally races — "stock − 1" from two clients loses one decrement, silently. Anything
read-modify-write, multi-path atomic, or reading hidden state is an intent:
intents: (i) => ({
purchase: i.define({
allow: ["player"],
input: t.object({ purchaseId: t.string(), itemId: t.string() }), // client-supplied id (D6)
optimistic: ({ view, avatar, input }) => { /* optional prediction on the caller's view */ },
apply: ({ state, avatar, input, ctx }) => {
if (state.purchases[avatar.persistentId]?.[input.purchaseId] != null) return true; // idempotent retry
if (state.stock[input.itemId] < 1) return myErr.fromId("sold_out");
state.stock[input.itemId] -= 1; // authoritative read-modify-write
/* …atomic multi-path writes… */
return true; // or a NiceError — the draft is then discarded entirely
},
}),
}),Intents are settle-only (no data results — outcomes live in state; await the handle, then
read the store at your client-supplied keys). Choosing between them:
| Situation | Use |
| --- | --- |
| High-frequency absolute-set values (cursor, position, toggle you own) | realm.update |
| Read-modify-write, counters, balances, multi-path invariants | intent |
| Needs a data response back | a nice-action action |
| Rare single-path write that must not clobber concurrent edits | realm.update + touchedSince CAS in a serverChecked rule |
| High-stakes / low-confidence mutation | intent without optimistic — await the handle (spinner UX, zero rollback) |
| Server-driven mutation (ticks, fulfilment) | engine.update() |
Visibility
view rules are coordinate-only (they see { avatar, params }, decided per leaf,
most-specific-pattern-wins, default-deny). Owner visibility is a state-shaping pattern: key
records by the owning coordinate (orders.$consumerId.…) and denormalise per audience inside the
intent that writes both. Prefer keyed t.records over arrays (arrays are atomic leaves on the
wire). Broadcasts are sliced per view signature and encoded once per group — a hidden branch's
bytes never leave the server.
Replicas are an avatar rule (connectRealmReplica): a Worker holding a live read copy is just
another avatar type whose view rules say "everything" (or a partial slice) — plain object +
patches callback, no Store/React stack, no special authority.
Security
A realm inherits its connection's security level and can declare a minimum — security:
"authenticated" (default) or "encrypted"; there is no "none" (a realm always requires an
identity-bound connection). The floor is enforced fail-closed on both ends: the server refuses
to hydrate an under-secured attach (insufficient_security, on the first hello and every post-wake
re-hello), and the client refuses to emit even its first hello below the bar — so avatar data
never leaves the device as app-layer plaintext. hostRealm / connectRealm security overrides
can only raise the floor; one connection carries several realms at the max of their minimums.
Caveats the Security guide covers in full: encrypted
is in-flight only (DO SQLite is cleartext at rest — prefer persistence: "ephemeral" /
migrate: "wipe" for sensitive state); a rejection's message + context are wire-visible (never
close a rule/intent error over secrets); authenticated is trust-on-first-use, not a CA; replayed
frames are neutralized but keep intents idempotent (the purchaseId pattern above); and
bounding writable-record cardinality is an app rule today (no maxStateBytes knob yet).
Testing your realm
@nice-code/realm/testing runs the whole loop deterministically — no network, no Cloudflare:
const world = createTestRealm(gameRealm, { ctx, initialState });
const alice = world.asAvatar({ type: "player", persistentId: "alice" });
alice.holdServerFrames(); // observe the optimistic window
const h = alice.realm.update((d) => { d.players.alice.x = 5; });
expect(h.status).toBe("pending");
alice.pump();
expect(alice.realm.store.state).toEqual(world.serverSlice(alice.avatar));disconnect()/reconnect() script the reconnect matrix; world.serverSlice(avatar) is the
oracle every avatar's projection should equal.
Docs trail
Guides live on the docs site (packages/documentation, the nice-realm/ section — also
flattened into /llms-realm.txt for AI assistants). Deferred work lives in the root
FUTURE.md registry. The finished design records are archived in
docs/finished_features/nice-realm/:
PLAN-v1-initial.md (the locked
spec — the plan §… references in source comments point here),
INITIAL-REVIEW.md (the deep review
and road to release — the review … references point here),
FINALIZE.md (the F1–F8 completion
record), and
PLAN-security.md (the connection-level
security hardening — the PLAN-security … references in source comments point here). Earlier
documents live in git history, see INITIAL-REVIEW §D.4.
