nusm axolotlnusm
Core Concepts

Authenticated synchronization

Share identified store state across browser contexts with bounded HMAC-authenticated events.

nusm can synchronize an identified store across browser contexts through an authenticated BroadcastChannel source. Synchronization is disabled unless an application configures a source explicitly.

Configure a source

Create a source with a namespace and stable runtime-provisioned key material. Do not generate a new key independently in each tab. nusm imports byte keys as non-extractable HMAC-SHA-256 keys. You can also provide a matching non-extractable CryptoKey with sign and verify usage:

import { configureNusm, createBroadcastChannelSyncSource } from "nusm";

const keyBytes = Uint8Array.from(
  atob(await fetch("/sync-key").then((response) => response.text())),
  (character) => character.charCodeAt(0),
);
const key = await crypto.subtle.importKey(
  "raw",
  keyBytes,
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign", "verify"],
);
const source = createBroadcastChannelSyncSource({
  namespace: "my-app",
  key,
});

configureNusm({
  sync: {
    source,
    validator: (candidate) => {
      if (
        typeof candidate !== "object" ||
        candidate === null ||
        Array.isArray(candidate)
      )
        return false;
      return typeof (candidate as { count?: unknown }).count === "number";
    },
  },
});

Every configured source requires a validator. The validator receives the candidate and { storeId, source, kind, revision }; return false to reject it, or { ok: true, value } to transform it before memory or persistence. Store-specific sync.validator values override the global validator.

Global sources auto-enable stores with an explicit storeId. Use sync: false to opt out one store, or provide a store-specific source and validator. Set optimistic: true when local hydration may complete before peer convergence. Persistence and synchronization always require a stable storeId; Devtools display names never provide identity. Namespace and key provisioning should be owned by the same authorized runtime boundary; do not generate independent keys in each browser context.

Authentication and limits

Each store derives an opaque channel capability with HMAC over its namespace and storeId. Every event signs fixed-order headers and its exact SuperJSON payload. The key and raw capability never cross the channel. State is limited to 256 KiB, envelopes to 512 KiB, identifiers to 128 UTF-8 bytes, peers to 32, and message identities to 128 per peer. Invalid traffic receives at most five reports per ten seconds and can enter a temporary 30-second quarantine.

Authentication protects against accidental or unauthorized peers. It does not protect state from a compromised same-origin application, and it does not make BroadcastChannel suitable for cross-origin communication. Never synchronize credentials, tokens, secrets, or sensitive personal data. Keep synchronized values within the stated state and envelope budgets.

Lifecycle

Call await store.dispose() when the store lifetime ends. Disposal marks the store disposed synchronously, releases only that store's source subscription, and returns one shared, idempotent promise. Late hydration cannot mutate state; pending ready rejects with AbortError and reports cancelled hydration. Every cleanup and flush is attempted. Failures reject disposal once as an AggregateError and reach onError. Keep the source configured only while its stores are active, and treat ready as the store hydration lifecycle signal.

Hydration and convergence

Synchronization connects its sources before hydration starts. Each store first announces a signed hydrating presence, then announces ready only after its adapter has completed. The earliest live ready peer wins by signed creation time, with sender identity as the deterministic tie-breaker. A hydrating peer keeps its five-second lease alive until it becomes ready, so a newer tab cannot answer with a default snapshot while an older tab is still restoring data.

The default (strict) mode keeps store.isReady false and leaves store.ready pending until convergence. A requester sends a correlated snapshot request to the elected leader. The leader retries an immutable, targeted snapshot through the request retry schedule (50, 100, 200, and 400 ms). The requester applies a matching snapshot once, acknowledges it, and converges only after the leader confirms that acknowledgement. Lost requests, snapshots, or acknowledgements therefore recover without accepting unsolicited messages. An isolated store finishes after its bounded discovery cycle or when its source is unavailable.

Set sync.optimistic to resolve store.ready after local hydration while peer discovery continues. Local updates remain queued until convergence, then flush in order. Updates carry signed cohort epochs and bounded Lamport counters; the counter rolls to a new epoch before the signed 31-bit range is exhausted, and malformed, stale, replayed, skipped, or near-overflow revisions are ignored.

On this page