Skip to main content

JavaScript SDK

The JavaScript SDK gives you a practical API for room connection, synchronized state updates, replay-aware reads, and collaboration signals. Use this page as your implementation baseline for browser or JS runtime clients.

Install

npm install nodalmerge-sdk-js

Minimal setup

import { createNodalMergeSdk } from "nodalmerge-sdk-js";

const sdk = await createNodalMergeSdk({
  wsUrl: "ws://127.0.0.1:7878/ws/runtime",
  roomId: "demo-room",
  transport: { mode: "auto" },
  reconnect: {
    enabled: true,
    initialDelayMs: 500,
    maxDelayMs: 8000
  }
});

await sdk.room.connect();

Read and write state

sdk.sync.set("profile/name", "alice");
sdk.sync.set("profile/role", "engineer");
const sent = sdk.sync.push();

const name = sdk.sync.get("profile/name");
const canonicalName = sdk.sync.getCanonical("profile/name");
Call sdk.sync.push() after local mutations to flush updates. push() returns a booleantrue if a pack was actually sent, false if there was nothing pending. It sends only the delta accumulated since the last successful push (tracked internally), not a full state export. sdk.sync.get() returns the speculative/intent-lane view (including your own unconfirmed writes); sdk.sync.getCanonical() returns the authoritative canonical-lane view — the same intent-vs-canonical distinction described for text reads below. Push acknowledgement and retry. The server responds to each push() with a pack-ack message reporting accepted/rejected counts. The SDK handles this automatically: if any nodes are rejected, it reverts the corresponding local optimistic state and resends. You don’t need to handle pack-ack directly unless you’re building custom sync-status UI — see protocol/websocket-messages and api-reference/websocket-commands#pack-ack-server-client. Pack size limit. A single pushed pack is capped at 60 KiB of base64 (61,440 characters). Pushes exceeding this are rejected client-side — the SDK reverts the pending marks and emits an "error" event rather than sending an oversized frame. Batch large sets of writes into multiple smaller push() calls if you hit this.

Text operations

Use positional helpers for collaborative text edits:
sdk.sync.insertTextAt("doc/title", 0, "Hello");
sdk.sync.insertTextAt("doc/title", 5, " world");
sdk.sync.deleteTextAt("doc/title", 5, 1);
sdk.sync.push();

Text range anchors

When your editor needs stable positions under concurrent edits, use anchor-based helpers. Anchors identify where to insert or delete relative to the document structure rather than a raw integer offset that may shift under concurrent changes. Insert anchors (TextInsertAnchor):
ShapeMeaning
{ kind: "offset", pos: number }Insert at character offset pos
{ kind: "start" }Insert at the very beginning
{ kind: "end" }Append to the end
{ kind: "after", lamport, author }Insert after a specific RGA node
Delete anchors (TextDeleteAnchor):
ShapeMeaning
{ kind: "offset", pos: number }Delete starting at character offset pos
{ kind: "start" }Delete from the beginning
{ kind: "after", lamport, author }Delete starting after a specific RGA node
// Append to end
sdk.sync.insertTextRange("doc/title", { kind: "end" }, " world");

// Prepend
sdk.sync.insertTextRange("doc/title", { kind: "start" }, "Hello ");

// Delete 1 char at offset 5
sdk.sync.deleteTextRange("doc/title", { kind: "offset", pos: 5 }, 1);

// Insert after a known RGA node (useful in rich-text editors that track op IDs)
sdk.sync.insertTextRange("doc/title", {
  kind: "after",
  lamport: 42,
  author: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}, "!");

sdk.sync.push();
Prefer anchor-based APIs in editors that maintain cursor/selection as logical positions rather than integer offsets; they survive concurrent insertions at the same position without extra rebasing logic.

Text reads and attribution

For attribution UIs (per-glyph author), read the visible RGA sequence:
const glyphs = sdk.sync.getTextSequence("notes/demo/body");
// [{ lamport, author, ch }, ...] — tombstones omitted

const atLamport = sdk.sync.getTextAtLamport("notes/demo/body", 42);
const glyphsAt = sdk.sync.getTextSequenceAtLamport("notes/demo/body", 42);
getTextAtLamport / getTextSequenceAtLamport replay only DAG nodes with transaction.lamport <= maxLamport (tombstones included). The speculative read (getText) returns the intent-lane view; getTextCanonical returns the canonical lane view. Try the interactive surface: Collab text playground (http://127.0.0.1:4177, default room collab-text).

Presence and ephemeral collaboration

Presence is for live ephemeral state, not durable business data:
sdk.presence.set({ cursor: { x: 120, y: 220 } }, { ttlMs: 5_000 });
Use presence for cursor/typing/view state. Persist durable facts via sync state instead.

Blob and file storage patterns

For file-like payloads, use the SDK CAS APIs and keep sync state as references:
const bytes = new Uint8Array([1, 2, 3, 4]);
const hash = sdk.cas.setBlob("files/avatar.png", bytes);
sdk.sync.set("world/files/avatar", hash);
sdk.sync.push();
Later, resolve bytes by hash:
const hash = sdk.sync.get("world/files/avatar");
if (hash) {
  const payload = sdk.cas.getBlob(hash);
}
When a referenced blob is missing locally, request retrieval:
sdk.cas.requestMissingBlobs();
This keeps large binary payloads out of normal key/value state while preserving deterministic references.

Runtime events

Subscribe to runtime messages for observability and integration hooks:
const stop = sdk.on("runtime-message", (msg) => {
  console.log("runtime event", msg.type);
});

// later
stop();
Use this stream for diagnostics and advanced UX instrumentation.

Offline and persistence

SDK can queue and flush writes across reconnect boundaries. For peer-local durability, enable persistence explicitly:
const sdk = await createNodalMergeSdk({
  wsUrl: "ws://127.0.0.1:7878/ws/runtime",
  roomId: "demo-room",
  persistence: {
    enabled: true,
    adapter: "indexeddb",
    dbName: "nodalmerge-peer-local"
  }
});
Validate restart and reconnect behavior with realistic offline drills before shipping.

Intent vs canonical pattern

Model local optimism and authoritative outcomes in separate namespaces:
  • intent/** for speculative writes
  • world/** for canonical accepted state
This keeps your UI responsive while preserving deterministic refinement behavior.

Replay range read

Retrieve a paginated history of key mutations from the server, useful for activity feeds, audit trails, and undo stacks:
const result = await sdk.query.readReplayRange({
  keyPrefix: "world/",
  fromLamport: 0,
  limit: 100
});

// result.type === "replay.read-range.result"
// result.items — array of mutation records
// result.next_cursor — pass as `cursor` to page forward
For local-only history (offline/unsent ops), use sdk.sync.readLocalReplayRange which returns synchronously from the WASM store.

Query and projection helpers

Runtime query APIs support canonical-lane analytics and read-model workflows. Use sdk.query when you need structured projections over room state rather than direct key access. Register a query spec (once per descriptor version):
await sdk.query.registerSpec({
  querySpecId: "q.world",
  version: "v1",
  descriptor: { prefix: "world/" }
});
Build a projection at a specific checkpoint:
const build = await sdk.query.buildProjection({
  projectionId: "p.world",
  querySpecId: "q.world",
  targetCheckpoint: { selector: "latest" }
  // other selectors: { selector: "seq", canonical_seq: 42 }
  //                  { selector: "hash", canonical_hash: "<hex>" }
  //                  { selector: "frontier", frontier: ["seq:42"] }
});
Read paginated results:
if (build.type === "projection.build.completed") {
  const page = await sdk.query.readProjection({
    projectionId: "p.world",
    limit: 50
  });
  console.log(page.rows, page.digest, page.next_page_token);
}
Invalidate or list projections:
await sdk.query.invalidateProjection({ projectionId: "p.world", reason: "schema change" });

const list = await sdk.query.listProjections({ querySpecId: "q.world", stateFilter: "active" });

Transport options

transport.mode commonly uses:
  • "auto": allow negotiated transport enhancements when available
  • "ws-only": force WebSocket-only behavior
Start with auto, switch to ws-only only when environment constraints require strict transport control.

Error and rejection handling

Treat rejection/error signals as first-class UX and ops inputs:
  • Surface user-meaningful failure states
  • Log structured rejection context
  • Avoid silent rollback paths
In policy-governed systems, rejection handling is part of normal operation.

Configuration reference

All options passed to createNodalMergeSdk:
OptionTypeDefaultDescription
wsUrlstringWebSocket endpoint URL (required)
roomIdstringRoom identifier (required)
authorKeyUint8ArrayEd25519 signing key bytes for this peer
tokenunknownAuth token forwarded in hello payload
wasmModuleRequestInfo | URL | ...Custom WASM module source; defaults to bundled
tickIntervalMsnumberserver defaultHow often local tick loop fires (ms)
maxOpsPerTicknumberserver defaultMax ops batched per tick flush
reconnect.enabledbooleanfalseEnable automatic reconnect
reconnect.initialDelayMsnumber500First reconnect delay (ms)
reconnect.maxDelayMsnumber8000Reconnect backoff ceiling (ms)
reconnect.factornumber2Backoff multiplier
reconnect.maxAttemptsnumberunlimitedStop reconnecting after N failures
offline.persistenceKeystringlocalStorage key for outbox persistence
transport.mode"auto" | "ws-only""ws-only"Transport negotiation policy
persistence.enabledbooleanfalseEnable peer-local graph persistence
persistence.adapter"indexeddb" | PeerLocalPersistenceAdapter"indexeddb"Persistence backend
persistence.dbNamestring"nodalmerge-peer-local"IndexedDB database name
persistence.dbVersionnumber1IndexedDB schema version
persistence.debounceMsnumber500Debounce interval for auto-save writes (ms)
persistence.migrateLegacyDemobooleanfalseMigrate pre-Phase-C demo key formats
offline.persistenceKey controls outbox persistence (localStorage) and is independent of persistence.* which controls graph/node durability (IndexedDB).

Production checklist

  • Durable server storage enabled
  • Reconnect policy configured and tested
  • Local persistence strategy validated
  • Intent/canonical namespaces separated
  • Presence restricted to ephemeral signals
  • Runtime event/rejection telemetry wired

Common mistakes

  • Forgetting sdk.sync.push() after local writes
  • Using presence for durable domain state
  • Storing file/blob bytes directly in sync string keys instead of hash references
  • Mixing optimistic and canonical writes in one keyspace
  • Assuming online-only behavior during QA
  • Ignoring rejection pathways until late integration