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.