pyreon

@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 peers

What 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-memory FakeCrdtAdapter for dependency-free unit tests.

  • A real Yjs engine behind the @pyreon/sync/yjs subpath (so import '@pyreon/sync' never pulls in yjs).

  • 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/sync
bun add @pyreon/sync
pnpm add @pyreon/sync
yarn add @pyreon/sync

Peer 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

ImportRuns wherePulls inUse for
@pyreon/syncanywhere (universal)only @pyreon/reactivitythe reactive bridge — syncedSignal, syncedStore, the CrdtAdapter seam, the test adapter
@pyreon/sync/yjsbrowser / Node 22+ / Bun / Denoyjs, y-indexeddbthe real engine, transports, persistence, collaborative text/lists
@pyreon/sync/serverNode / Bun onlyws, node:httpthe 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 link

How 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:

  1. synced.set(v) writes ONLY the CRDTdoc.transact(() => map.set(key, v), LOCAL_ORIGIN). It does not write the base signal directly (doing both would double-apply).

  2. 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)).

  3. The local echo is harmless. When the observer re-reports the value the base already holds, base.set is an Object.is no-op (true for scalar values).

  4. The network loop is prevented in the transport, never in the observer: a transport applies inbound updates tagged REMOTE_ORIGIN, and it re-broadcasts only LOCAL_ORIGIN updates — 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 fields

A 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 syncedAwareness view. 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, and doc.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 reconnecting

On 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 syncedSignal is last-writer-wins — the loser's value is silently dropped. syncedText / syncedList keep 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 /yjs import) 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 CrdtAdapter seam 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)

ExportKindSummary
syncedSignal(options)functionBind a Signal<T> to a scalar CRDT map entry.
syncedStore(initial, options)functionA flat store of synced fields over one map.
SyncedSignal<T> / SyncedStore<T>typeA signal / store of signals with dispose().
CrdtAdapter / CrdtDoc / CrdtMaptypeThe engine-neutral seam.
LOCAL_ORIGIN / REMOTE_ORIGINconstantTransaction-origin tags (transport loop guard).
FakeCrdtAdapter / connectFakeDocsclass / functionIn-memory test adapter + peer link.

@pyreon/sync/yjs (engine)

ExportKindSummary
createYjsDoc(yDoc?)functionA CrdtDoc backed by a real Yjs Y.Doc.
syncedText(doc, key)functionCollaborative string (Y.Text, character merge).
syncedList(doc, key)functionCollaborative list (Y.Array, positional merge).
syncedAwareness(doc, initial?)functionEphemeral presence + live cursors (never persisted).
persistViaIndexedDB(doc, dbName)functionOffline durability (browser-only).
connectViaBroadcastChannel(doc, name)functionSame-origin cross-tab transport.
connectViaWebSocket(doc, url, options?)functionCross-device transport (auto-reconnect).

@pyreon/sync/server (relay)

ExportKindSummary
createSyncServer(options)functionNode/Bun WebSocket relay with authorize gate.
AuthorizeContext / SyncServerOptions / SyncServertypeRelay configuration + handle.
Sync