Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

y-multiplex

Multiplex many Yjs collaborative rooms over a single WebSocket connection, instead of opening one socket per room.

The problem

The reference y-websocket implementation — and most Yjs tutorials — open one WebSocket connection per Y.Doc room. That's fine for an app with a single collaborative surface. It falls apart the moment your page has several: a kanban board, a text editor, and a live cursor layer all backed by different Yjs docs means 3+ sockets to the same server, 3x the connection overhead, and 3x the reconnect/backoff logic to get right.

y-multiplex multiplexes any number of Yjs rooms over one connection, using a small binary framing layer on top of the standard y-protocols wire format (the same format y-websocket itself uses for each message body — this package does not reinvent Yjs sync, it just adds a cheap room-id header in front of it).

Wire protocol

Every frame:

varString(roomId) . varUint(msgType) . <type-specific payload>

Encoded with lib0/encoding / lib0/decoding — the same low-level varint codec Yjs itself uses internally.

msgType Name Payload
0 sync a standard y-protocols/sync message (step1/step2/update)
1 awareness varUint8Array — a raw y-protocols/awareness update
2 control varString containing JSON: {t:'join'} | {t:'leave'} (client→server), {t:'joined',writable} | {t:'denied',reason} | {t:'error',reason} (server→client)

Why not just use y-websocket?

y-websocket is a great, minimal reference implementation for one room. y-multiplex is for the case where a single page/session needs several rooms live at once:

y-websocket (per room) y-multiplex
Connections for N rooms N 1
Reconnect/backoff logic duplicated per room shared, once
Server-side auth re-check on write not built in built in, per message
Room lifecycle / eviction races left to you handled (see below)
Presence persistence left to you explicitly never persisted

If your app only ever has one live room per page, y-websocket (or y-sweet, Hocuspocus, etc.) is simpler and you don't need this. If you're opening multiple sockets to the same origin for multiple simultaneous rooms, this package is a drop-in way to collapse them into one.

Install

npm install y-multiplex yjs ws
# optional, for offline-first client persistence:
npm install y-indexeddb

yjs and ws are peer dependencies — bring your own versions. y-indexeddb is an optional peer used only by the client, only if installed.

Server quickstart

import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import { YMultiplexHub } from 'y-multiplex/server'

const hub = new YMultiplexHub({
  // Called on every write, not just at connect time.
  authorize: (identity, roomId, action) => {
    if (action === 'write') return identity.role !== 'viewer'
    return true
  },
  resolveIdentity: async (req) => {
    // Look up a session, decode a JWT, whatever your app does.
    return { userId: req.headers['x-user-id'] ?? 'anonymous' }
  },
  store: {
    async load(roomId) {
      /* return Uint8Array | null from your database */
      return null
    },
    async save(roomId, update) {
      /* persist the full state snapshot */
    },
  },
})

const httpServer = createServer()
const wss = new WebSocketServer({ server: httpServer })

wss.on('connection', (socket, req) => {
  hub.handleConnection(socket, req)
})

httpServer.listen(3001)

process.on('SIGTERM', async () => {
  await hub.destroy()
  process.exit(0)
})

Client quickstart

import { YMultiplexClient } from 'y-multiplex'

const client = new YMultiplexClient({ url: 'ws://localhost:3001' })

const board = client.getRoom('board:123')
const editor = client.getRoom('doc:readme') // same underlying socket, no extra connection

board.onStatus(() => {
  console.log('writable:', board.writable, 'synced:', board.synced)
})

board.setPresence({ name: 'Ada', color: '#ff6600', cursor: { x: 10, y: 20 } })

board.onPresence(() => {
  for (const [clientId, state] of board.presenceStates()) {
    console.log(clientId, state.presence)
  }
})

await board.whenSynced
// board.doc is now a regular Y.Doc, ready to use with any Yjs-aware editor binding

board.destroy() // leaves the room; closes the shared socket once no rooms remain

Good fit for

Any app with multiple collaborative widgets live on one page at once — a document editor plus a presence/cursor layer, a kanban board plus a chat panel, a multi-pane workspace where each pane is its own Yjs doc. If that's your situation, one socket instead of N is a meaningful reduction in connection overhead and client-side reconnect complexity.

API reference

YMultiplexHub (y-multiplex/server)

new YMultiplexHub(options?: YMultiplexHubOptions)
Option Default Description
authorize(identity, roomId, action) always true Re-checked on every write (action === 'write'), not just at connect. Reads are always allowed once joined — gate read access yourself before the WebSocket upgrade reaches handleConnection if you need it.
resolveIdentity(req) {} Derive an opaque Identity from the upgrade IncomingMessage.
store in-memory no-op { load(roomId), save(roomId, update) }. Nothing persists across restarts by default.
persistDebounceMs 2500 Trailing debounce window after a room's last update before it's flushed to store.
maxRoomsPerConnection 32 Cap on distinct rooms one connection may join.
maxMessageBytes 4_000_000 Oversized incoming messages are dropped silently.
heartbeatIntervalMs 30000 Ping interval; a connection that misses one full cycle without a pong is terminated.
Method Description
handleConnection(socket, req) Wire to your WebSocket server's 'connection' event.
applyTextPatch(roomId, textKey, content) Server-initiated edit to a Y.Text, computed as a minimal common-prefix/common-suffix diff — never a full replace — so it merges cleanly with concurrent client edits instead of resetting everyone's cursor position.
getTextContent(roomId, textKey) Read current text content, loading transiently from store if the room isn't already in memory.
getViewerCount(roomId) Number of sockets currently joined to a room.
broadcastControl(roomId, data) Out-of-band JSON push to current viewers. Returns false if the room has no viewers.
destroy() Flushes all dirty rooms and stops the heartbeat. Call on shutdown.

Room lifecycle correctness (the actual value of this package, not boilerplate):

  • Per-room state is a Y.Doc({gc: true}) + Awareness + the set of connected sockets, kept alive only while at least one connection has joined (or a join is mid-flight, or applyTextPatch is mid-flight).
  • TOCTOU-safe eviction: a room is destroyed once its viewer count hits zero, closing two race windows — (a) an in-flight join counts as "in use" even before it's registered in the connection set, and (b) if the room is dirty, eviction awaits a real persist and then re-checks viewer count afterward, because a join can complete entirely during that await.
  • Debounced persistence: writes mark a room dirty and (re)start a trailing debounce timer; flushed on eviction and on destroy().
  • Origin-tagged echo suppression: a client's own update is never sent back to it.
  • Presence is never persisted — see below.
  • Awareness cleanup is immediate on disconnect, not after the awareness protocol's ~30s outdated-timeout, because each socket's contributed awareness client IDs are tracked and explicitly removed on close/leave.
  • Message ordering per connection is preserved even though authorization checks are async, by chaining each connection's message handling through a serial promise queue.

YMultiplexClient (y-multiplex)

new YMultiplexClient(options: YMultiplexClientOptions)
Option Default Description
url — ws:///wss:// URL, or a function returning one (useful for token-refreshed URLs).
offlinePersistence true Back each room with y-indexeddb if installed; skipped silently if unavailable (e.g. private browsing).
client.getRoom(roomId: string): CollabRoom

Idempotent per roomId until destroy()ed. One shared WebSocket per YMultiplexClient, opened lazily on the first getRoom() call and closed automatically once the last room is destroyed. Reconnects with exponential backoff (1s → 10s cap, reset on successful open), rejoining every open room and resending its current awareness state.

CollabRoom:

Member Description
doc The Y.Doc — bind any Yjs-aware editor/UI to it directly.
awareness The Awareness instance.
writable Server-confirmed write permission; false until joined and confirmed.
synced true once the initial sync handshake has completed.
whenSynced Promise resolving the first time synced becomes true.
setPresence(state) Merges state under a presence field in the local awareness state. setPresence(null) removes the field entirely, so peers see you as gone rather than "present with null."
presenceStates() Map<clientId, { presence?: unknown }> of all known awareness states.
onPresence(cb) Subscribe to any peer presence change. Returns an unsubscribe function.
onStatus(cb) Subscribe to writable/synced changes. Returns an unsubscribe function.
destroy() Leaves the room and tears down its local state.

Local edits made while offline apply immediately to the local Y.Doc and sync automatically once the connection returns — the standard Yjs step1/step2 handshake reconciles both directions with no special-case offline code required. On page unload, local awareness states are proactively removed so peers don't see a ghost cursor sitting until the awareness timeout.

Why presence is never persisted

Awareness (cursors, "who's online," selection ranges, etc.) is held only in the in-memory Awareness instance for a room's lifetime and rebuilt from scratch on every process restart or room recreation. It is architecturally separate from the Y.Doc CRDT state that gets persisted — Y.encodeStateAsUpdate never includes awareness data, and y-multiplex never routes awareness updates through store.save. Persisting presence would be actively wrong: it should only ever reflect who is connected right now, never a stale snapshot from a previous session.

Example

See examples/basic for a minimal two-tab live-sync demo (shared textarea + "who's online" list) running on plain http + ws.

License

MIT

About

One WebSocket connection, many Yjs rooms — a multiplexed CRDT sync hub for apps with multiple live collaborative widgets on one page.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages