@pyreon/sync
Local-first, collaborative sync built directly on Pyreon's reactivity. A synced value is a normal Signal — so when a remote peer changes it, the update becomes one signal.set, which drives one fine-grained DOM update. No virtual-DOM re-render, no diff. This is the architectural reason signals are the ideal substrate for sync: the surgical-update path you already get for local state is exactly the path a remote op rides.
import { syncedSignal } from '@pyreon/sync'
import { createYjsDoc, connectViaWebSocket } from '@pyreon/sync/yjs'
const doc = createYjsDoc()
const title = syncedSignal({ doc, key: 'title', initial: 'Untitled' })
connectViaWebSocket(doc, 'wss://sync.example.com/my-room?token=abc')
// <h1>{() => title()}</h1>
// A peer edits the title → this exact <h1> text node patches in place.
title.set('Roadmap') // local edit relays to peersWhat you get
syncedSignal/syncedStore— bind a signal (or a flat store of signals) to a CRDT map. Indistinguishable from a normal signal to the compiler and every effect.An engine-neutral seam (
CrdtAdapter) so the reactive bridge never imports a concrete CRDT engine — plus an in-memoryFakeCrdtAdapterfor dependency-free unit tests.A real Yjs engine behind the
@pyreon/sync/yjssubpath (soimport '@pyreon/sync'never pulls inyjs).Offline persistence via IndexedDB (
persistViaIndexedDB).Transports: same-origin cross-tab (
connectViaBroadcastChannel) and cross-device WebSocket (connectViaWebSocket, auto-reconnecting).Collaborative text + lists (
syncedText/syncedList) with true positional merge — concurrent edits keep both.A relay server (
createSyncServer) for Node/Bun with a per-room/per-doc authorization gate.
Installation
npm install @pyreon/syncbun add @pyreon/syncpnpm add @pyreon/syncyarn add @pyreon/syncPeer dependency: @pyreon/reactivity. The Yjs engine (yjs, y-indexeddb) and the relay (ws) ship as dependencies but are only pulled in when you import the /yjs or /server subpaths.
Three entry points
| Import | Runs where | Pulls in | Use for |
|---|---|---|---|
@pyreon/sync | anywhere (universal) | only @pyreon/reactivity | the reactive bridge — syncedSignal, syncedStore, the CrdtAdapter seam, the test adapter |
@pyreon/sync/yjs | browser / Node 22+ / Bun / Deno | yjs, y-indexeddb | the real engine, transports, persistence, collaborative text/lists |
@pyreon/sync/server | Node / Bun only | ws, node:http | the relay server — never import this into client code |
Quick start — no engine (tests / learning)
The fastest way to understand the model is the in-memory adapter. It needs no server, no yjs, and connects two "peers" in-process:
import {
syncedSignal,
FakeCrdtAdapter,
connectFakeDocs,
} from '@pyreon/sync'
const a = new FakeCrdtAdapter().createDoc()
const b = new FakeCrdtAdapter().createDoc()
connectFakeDocs(a, b) // simulate a transport between two peers
const titleA = syncedSignal({ doc: a, key: 'title', initial: 'Untitled' })
const titleB = syncedSignal({ doc: b, key: 'title', initial: 'Untitled' })
titleA.set('Roadmap')
titleB() // 'Roadmap' — propagated through the linkHow the loop works (and why it can't echo)
This is the load-bearing design. Get it wrong and you get echo storms or dropped updates. The rule:
The observer applies every change. The transport prevents the network loop.
A syncedSignal wraps a base signal (via wrapSignal) and runs a single update loop:
synced.set(v)writes ONLY the CRDT —doc.transact(() => map.set(key, v), LOCAL_ORIGIN). It does not write the base signal directly (doing both would double-apply).The map observer is the one writer of the base signal. It fires at every committed transaction — local and remote — and calls
base.set(map.get(key)).The local echo is harmless. When the observer re-reports the value the base already holds,
base.setis anObject.isno-op (true for scalar values).The network loop is prevented in the transport, never in the observer: a transport applies inbound updates tagged
REMOTE_ORIGIN, and it re-broadcasts onlyLOCAL_ORIGINupdates — so a received update is never echoed back to peers.
import { LOCAL_ORIGIN, REMOTE_ORIGIN } from '@pyreon/sync'
// LOCAL_ORIGIN — a write originating on this client
// REMOTE_ORIGIN — an update applied from a peer/relay (never re-broadcast)The engine seam
The bridge depends only on an engine-neutral interface, so syncedSignal / syncedStore never import a concrete CRDT:
import type { CrdtAdapter, CrdtDoc, CrdtMap } from '@pyreon/sync'The Yjs implementation lives behind @pyreon/sync/yjs. The seam buys you client-bridge portability — but note its boundary: persistence (y-indexeddb), transport, and the relay are coupled to the Yjs wire format. Swapping engines later re-platforms the infrastructure, not the bridge.
Real engine: Yjs
Everything beyond the in-memory adapter lives at @pyreon/sync/yjs:
import { createYjsDoc } from '@pyreon/sync/yjs'
const doc = createYjsDoc() // fresh Y.Doc
const title = syncedSignal({ doc, key: 'title', initial: 'Untitled' })
doc.yDoc // the underlying Y.Doc (for transports/persistence)syncedSignal — scalar fields
A scalar field (string / number / boolean) syncs with last-writer-wins semantics:
const count = syncedSignal({ doc, key: 'count', initial: 0 })
count() // reactive read
count.set(5) // one CRDT write → one DOM update
count.dispose() // detach the observer (auto via onCleanup inside a scope)The initial value is create-if-missing only: if the key already exists (hydrated from persistence or received from a peer), the existing value wins and initial is ignored. This is the local-first convention — a fresh peer's default never clobbers established state.
syncedStore — a flat store of fields
import { syncedStore } from '@pyreon/sync'
const store = syncedStore({ title: 'Untitled', done: false }, { doc })
store.title() // 'Untitled'
store.title.set('Ship') // one CRDT write → one DOM update
store.done.set(true)
store.dispose() // tear down all fieldsA single-key change still produces exactly one base-signal write: every field's observer runs, but only the field whose key changed actually calls base.set — the rest early-return on a cheap Set.has.
Collaborative text — syncedText
For a string that two people edit at once, a scalar syncedSignal is wrong: last-writer-wins drops one editor's work. syncedText binds a Signal<string> to a Yjs Y.Text — a character-level CRDT where concurrent edits in different regions are both kept:
import { syncedText } from '@pyreon/sync/yjs'
const body = syncedText(doc, 'body')
// Positional ops — Y.Text merges these faithfully across peers:
body.insert(0, 'Hello ')
body.delete(0, 6)
// Or bind a controlled textarea (uses a minimal prefix/suffix diff):
// <textarea
// value={() => body()}
// onInput={(e) => body.set(e.currentTarget.value)}
// />Collaborative lists — syncedList
syncedList binds a Signal<T[]> to a Yjs Y.Array — positional merge, so concurrent push/insert from two peers are both kept:
import { syncedList } from '@pyreon/sync/yjs'
const todos = syncedList<string>(doc, 'todos')
todos.push('buy milk', 'walk dog')
todos.insert(0, ['first'])
todos.delete(1, 1)Render it with a keyed <For> so a remote change reconciles O(changed), not a full re-render:
<For each={() => todos()} by={(t) => t}>
{(t) => <li>{t}</li>}
</For>Presence & live cursors — syncedAwareness
syncedAwareness gives you ephemeral presence — who's online and their live cursor — over the Yjs awareness protocol. This is a separate channel from the document: awareness is never merged into the doc and never persisted, and a peer's state is purged the moment it disconnects. It's the right tool for "3 people here" avatars and live collaborator cursors; it is the wrong tool for anything durable (use syncedSignal / syncedStore / syncedText for that).
import { createYjsDoc, syncedAwareness, connectViaWebSocket } from '@pyreon/sync/yjs'
const doc = createYjsDoc()
// Create presence BEFORE connecting — the transport wires the doc's awareness at connect time.
const presence = syncedAwareness<{ name: string; color: string; cursor?: { x: number; y: number } }>(
doc,
{ name: 'Vít', color: '#e8590c' },
)
connectViaWebSocket(doc, 'wss://sync.example.com/room?token=abc')
// Publish a live cursor (throttle the high-frequency write):
window.addEventListener('mousemove', (e) =>
presence.setLocalField('cursor', { x: e.clientX, y: e.clientY }),
)
// Render everyone ELSE's cursors + avatars (`others` excludes you):
<For each={() => presence.others()} by={(p) => p.clientId}>
{(p) => <Cursor color={p.state.color} name={p.state.name} at={p.state.cursor} />}
</For>presence.others()— every other peer (the avatars / cursors to render).presence.states()includes you;presence.local()is your own published state.setLocal(state)replaces your whole presence;setLocalField(key, value)patches one field (ideal for a throttled cursor).The relay is awareness-stateful: a client that joins sees existing peers instantly (the relay replays the room's presence on connect), and a client that crashes is purged on socket close — so no ghost cursor lingers.
Lifecycle: the awareness is owned by the doc, shared by every transport and every
syncedAwarenessview.presence.dispose()only detaches that view's observer (so you can safely dispose one view while another keeps tracking) — it does not tear down the shared awareness or announce your departure; the transport announces departure on disconnect, anddoc.destroy()performs the full teardown.
Offline persistence — IndexedDB
persistViaIndexedDB makes edits survive a reload and lets the app work offline (a thin wrapper over y-indexeddb). It is browser-only — it opens the IndexedDB connection eagerly.
import { createYjsDoc, persistViaIndexedDB } from '@pyreon/sync/yjs'
import { syncedSignal } from '@pyreon/sync'
const doc = createYjsDoc()
const persist = persistViaIndexedDB(doc, 'my-app-doc')
await persist.whenSynced // ← load persisted state FIRST
const title = syncedSignal({ doc, key: 'title', initial: 'Untitled' })Transports
Cross-tab — connectViaBroadcastChannel
Same-origin, same-browser sync between tabs, no server. A minimal state-vector handshake catches a late-opening tab up:
import { connectViaBroadcastChannel, createYjsDoc } from '@pyreon/sync/yjs'
const doc = createYjsDoc()
const link = connectViaBroadcastChannel(doc, 'my-doc-room')
// edit in tab A → the same <h1> patches in place in tab B
link.disconnect()Cross-device — connectViaWebSocket
Point a doc at a relay over WebSocket. The wire protocol is the same minimal handshake (state vector → diff, then live updates) as the other transports, and it follows the same echo rule (a REMOTE-origin update is never re-sent). It reconnects with exponential backoff by default.
import { connectViaWebSocket, createYjsDoc } from '@pyreon/sync/yjs'
const doc = createYjsDoc()
const transport = connectViaWebSocket(
doc,
'wss://sync.example.com/my-room?token=abc',
{
reconnect: true, // default
onConnect: () => console.log('synced'),
onDisconnect: () => console.log('offline'),
},
)
transport.connected // boolean
transport.disconnect() // close + stop reconnectingOn runtimes without a global WebSocket (older Node), pass an implementation:
import WebSocket from 'ws'
connectViaWebSocket(doc, url, { WebSocketImpl: WebSocket })Relay server
createSyncServer is a Node/Bun WebSocket relay (@pyreon/sync/server — server-only; it imports ws + node:http). It keeps one authoritative Y.Doc per room so a late-joiner catches up, applies each inbound update, and broadcasts to the room's other clients. Rooms are garbage-collected when their last client leaves.
import { createSyncServer } from '@pyreon/sync/server'
const relay = await createSyncServer({
port: 1234,
authorize: ({ room, token }) => token === secretFor(room),
})
relay.port // resolved port (even when you pass port: 0)
relay.rooms // number of active rooms
await relay.close()Authorization is not optional
The authorize(ctx) hook is the per-room/per-doc access gate. Return false (or throw) to reject the connection — the socket closes with code 4401 before any document data is sent or received.
authorize: ({ room, token, req }) => {
// room — parsed from the URL path (wss://host/<room>)
// token — the ?token= query param (or null)
// req — the raw HTTP upgrade request (read cookies/headers here if you prefer)
return verifyAccess(room, token)
}Sharing a port with an existing server
Pass an existing http.Server to add WebSocket upgrade handling without opening a new port (the caller owns server.listen()):
import { createServer } from 'node:http'
import { createSyncServer } from '@pyreon/sync/server'
const http = createServer(/* your HTTP app */)
await createSyncServer({ server: http })
http.listen(3000)End-to-end: a collaborative document
import { syncedSignal } from '@pyreon/sync'
import {
createYjsDoc,
syncedText,
persistViaIndexedDB,
connectViaWebSocket,
} from '@pyreon/sync/yjs'
async function CollabDoc() {
const doc = createYjsDoc()
// 1. Load persisted state first.
const persist = persistViaIndexedDB(doc, 'collab-doc')
await persist.whenSynced
// 2. Bind reactive fields.
const title = syncedSignal({ doc, key: 'title', initial: 'Untitled' })
const body = syncedText(doc, 'body')
// 3. Go live across devices.
connectViaWebSocket(doc, 'wss://sync.example.com/doc-42?token=abc')
return (
<article>
<h1>{() => title()}</h1>
<textarea
value={() => body()}
onInput={(e) => body.set(e.currentTarget.value)}
/>
</article>
)
}Testing synced code
Use the in-memory adapter — no engine, no server, fully synchronous:
import { syncedStore, FakeCrdtAdapter, connectFakeDocs } from '@pyreon/sync'
test('two peers converge', () => {
const a = new FakeCrdtAdapter().createDoc()
const b = new FakeCrdtAdapter().createDoc()
connectFakeDocs(a, b)
const sa = syncedStore({ title: 'x' }, { doc: a })
const sb = syncedStore({ title: 'x' }, { doc: b })
sa.title.set('y')
expect(sb.title()).toBe('y')
})For offline-reconnect convergence (which the fake adapter can't model), use createYjsDoc with a real transport.
Honest limits
Sync is a powerful capability, but be precise about what it does and doesn't guarantee:
CRDTs prevent lost updates, not semantic conflicts. Never market this as "never lose data." Scalar
syncedSignalis last-writer-wins — the loser's value is silently dropped.syncedText/syncedListkeep both peers' operations, but the merged result can be semantically nonsensical (two sentences interleaved). Real apps still need conflict UX: presence, change indicators, optional field locking.It is not free weight. A synced app ships
yjs(~40KB min+gz) +y-indexeddb+ the WebSocket client on top of the runtime — realistically ~60KB+ gzipped. It is off the core hot path (an opt-in/yjsimport) and justified by the capability, but a synced Pyreon app is not a "smaller than Solid" app.Authorization is table-stakes. See the relay section — the default allows everything; production must gate per room/doc.
Native (PMTC) sync is out of near-term scope. The
CrdtAdapterseam keeps a future Loro-via-FFI engine door open, but compiler WebSocket-emit + a native WS runtime + a CRDT-via-FFI engine are not in scope yet.
API reference
@pyreon/sync (core bridge)
| Export | Kind | Summary |
|---|---|---|
syncedSignal(options) | function | Bind a Signal<T> to a scalar CRDT map entry. |
syncedStore(initial, options) | function | A flat store of synced fields over one map. |
SyncedSignal<T> / SyncedStore<T> | type | A signal / store of signals with dispose(). |
CrdtAdapter / CrdtDoc / CrdtMap | type | The engine-neutral seam. |
LOCAL_ORIGIN / REMOTE_ORIGIN | constant | Transaction-origin tags (transport loop guard). |
FakeCrdtAdapter / connectFakeDocs | class / function | In-memory test adapter + peer link. |
@pyreon/sync/yjs (engine)
| Export | Kind | Summary |
|---|---|---|
createYjsDoc(yDoc?) | function | A CrdtDoc backed by a real Yjs Y.Doc. |
syncedText(doc, key) | function | Collaborative string (Y.Text, character merge). |
syncedList(doc, key) | function | Collaborative list (Y.Array, positional merge). |
syncedAwareness(doc, initial?) | function | Ephemeral presence + live cursors (never persisted). |
persistViaIndexedDB(doc, dbName) | function | Offline durability (browser-only). |
connectViaBroadcastChannel(doc, name) | function | Same-origin cross-tab transport. |
connectViaWebSocket(doc, url, options?) | function | Cross-device transport (auto-reconnect). |
@pyreon/sync/server (relay)
| Export | Kind | Summary |
|---|---|---|
createSyncServer(options) | function | Node/Bun WebSocket relay with authorize gate. |
AuthorizeContext / SyncServerOptions / SyncServer | type | Relay configuration + handle. |