Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

WebSocket

Connect a document to the edytor room with createWebsocketSync, and follow connection, saved state and refusals.

createWebsocketSync connects a document to a sync server over a WebSocket, keeps a local IndexedDB copy, and syncs open tabs. It is built for the edytor Durable Object room; any server that speaks the edytor protocol works too.

Connect

<Edytor
  server="wss://rooms.example.com/rooms"
  room={documentId}
  params={{ token }}
  actor={{ id: userId }}
/>

The view dials <server>/<room>?replica=<clientID>&<params>, here wss://rooms.example.com/rooms/doc-42?replica=…&token=…. A room id is any string of 1 to 256 characters except ., .. and one with a lone surrogate (half of an emoji, as title.slice(0, 40) can leave): it is percent-encoded (encodeURIComponent) into one path segment, so /, %, # and ? in it reach their own room, and the server decodes it (see the quick start). A URL collapses a . or .. segment (escaped as %2E too), and encodeURIComponent cannot encode a lone surrogate, so the provider and createWebsocketSync throw a TypeError for those ids, and for an empty or longer one, instead of dialing a path no room answers. <Edytor server room> never throws for such an id, in the browser or in the server render: an editable view reports it through onSyncRefused as the 4400 refusal the server would send, and a readonly view, which never dials, renders its value. replica is the document’s client id, added at every dial so the room can bind it to the user at connection time; see Authorization. A replica in params overrides it. params is read at every dial, so a refreshed token reaches the next reconnect.

The props build a createWebsocketSync for you. Use the factory directly for the options below, or to share one document between several views: createDocument({ actor }), then document.attachSync(createWebsocketSync(…)), then <Edytor {document} /> in each view.

Options

PropType
serverstring

The server's base URL (ws:// or wss://), as the `server` prop. Trailing slashes are removed.

Typestring
roomstring

The document's room, appended to server as one percent-encoded path segment, as the `room` prop.

Typestring
params?Record<string, string>

Query parameters such as a token. Read at every dial; `replica` defaults to the document's client id.

TypeRecord<string, string>
maxBackoffTime?number

Upper bound, in ms, of the exponential reconnect delay. A room that stays unreachable grows it to 30 s.

Typenumber
Default2500
connectTimeout?number

How long, in ms, a dial may take to open before it is closed like a failed one, which starts the readiness bound. Raise it for rooms whose load takes longer; `Infinity` waits as long as the browser does.

Typenumber
Default10000
onExpired?({ reason, attempts, nextRetryMs }) => void

The room closed the socket with `4401` (expired credentials). Put a fresh token in `params` before the redial, due in `nextRetryMs`. `<Edytor>` takes it as `onSyncExpired`.

Type({ reason, attempts, nextRetryMs }) => void
disableBc?boolean

Turn off cross-tab sync over BroadcastChannel, for both the socket and the local copy.

Typeboolean
Defaultfalse
persist?boolean

Keep a local IndexedDB copy. Skipped where there is no indexedDB.

Typeboolean
Defaulttrue
persistName?string

Name of the local copy. Also returned on the sync as sync.persistName, for clearDocument.

Typestring
Default"edytor:<server>/<room>"
WebSocketPolyfill?typeof WebSocket

A WebSocket implementation for environments without a global one.

Typetypeof WebSocket

serverUrl and roomName, the names of earlier releases, still work in place of server and room.

params is kept by reference and read each time the provider dials, so you can refresh a short-lived token by updating the object you passed:

const params = { token: await getToken() };
const sync = createWebsocketSync({ server, room, params });
setInterval(async () => (params.token = await getToken()), 10 * 60_000);

With <Edytor>, refresh when the room reports the expiry: the params prop reaches the next dial.

<Edytor {server} {room} params={{ token }} onSyncExpired={async () => (token = await getToken())} />

Browsers cannot set headers on a WebSocket, so tokens travel in params (or in cookies your authorize reads).

Reconnects

  • On a lost connection the provider reconnects with exponential backoff: 100 ms × 2ⁿ, capped at maxBackoffTime. n counts the dials in a row that never synced (refused upgrades, sockets that closed before the handshake finished); a dial that synced starts it over. Each dial that ends before it synced emits unreachable with { attempts, nextRetryMs }, for a “Reconnecting in 2 s” indicator; a 4401 close emits expired instead, with the same fields. A room fault (1011) after the sync counts too, until the room acknowledges every local update again, so a fault that persists is not redialed every 100 ms.
  • After 8 such dials the cap doubles with each further one, up to 30 s, so an unreachable server, or one that refuses the upgrade with an HTTP error, is not dialed forever at full rate.
  • A close with a refusal code (1008, or 4000–4999) is terminal: the provider stops dialing and emits refused (see refusals). 4401 is the exception: the credentials expired, so the provider emits expired and redials after the backoff, reading params again. Put a fresh token in params when expired fires (onExpired, or onSyncExpired on <Edytor>). Before the first sync, an empty document does not seed meanwhile: it waits for a dial that gets in.
  • A dial that neither opens nor fails within connectTimeout (10 seconds by default; a connection the network silently drops, or a room still loading) is closed and counted like a failed dial: it emits unreachable, starts the readiness bound, and redials after the backoff.
  • A connection that receives nothing for 30 seconds is treated as dead and reopened. After 15 seconds of silence the provider sends one text ping frame; the edytor room answers pong without waking from hibernation, and a text pong counts as heard. The room also echoes each presence renewal (every 15 seconds), so even a client alone in its room hears from it in time. A server of your own should answer ping with pong, or ignore text frames.
  • Each (re)connection starts with a handshake in which both sides send what the other lacks. Nothing is replayed by hand and no periodic resync is needed.
  • While disconnected, other people’s carets disappear from this tab; edits keep going to the local copy. Other tabs of the browser keep the carets they still receive. On reconnect the edytor room sends the peers present in the room, and their carets show at once.

Provider events and state

createWebsocketSync creates the provider internally. To observe it, write a sync factory around the exported WebsocketProvider; this one behaves like createWebsocketSync (socket, local copy, one channel) and reports its state:

import {
  WebsocketProvider,
  createIndexeddbSync,
  type EdytorSync,
  type EdytorSyncPayload
} from 'edytor';

export const observedSync = (
  server: string,
  room: string,
  params: Record<string, string>,
  onState: (state: { status: string; saved: boolean; unsaved: number }) => void
): EdytorSync =>
  Object.assign(
    ({ doc, awareness, synced, failed, attach }: EdytorSyncPayload) => {
      // The local copy carries the cross-tab channel, so the socket's is off.
      const local = attach?.(createIndexeddbSync(`edytor:${server.replace(/\/+$/, '')}/${room}`));
      const provider = new WebsocketProvider(server, room, doc, {
        awareness,
        params,
        disableBc: true
      });
      let status = 'connecting';
      const report = () => onState({ status, saved: provider.saved, unsaved: provider.unsaved });
      provider.on('status', (event) => {
        status = event.status;
        report();
      });
      provider.on('saved', report);
      provider.on('synced', (isSynced) => isSynced && synced(provider));
      provider.on('failed', (error) => failed?.(error, provider));
      provider.on('permission-denied', (reason) => console.warn('denied:', reason));
      provider.on('schema-mismatch', (detail) => console.warn('refused content', detail));
      return () => {
        provider.destroy();
        return local?.();
      };
    },
    { target: `websocket:${server}/${room}` }
  );

This factory keeps the default readiness bound, counted from the attach. createWebsocketSync instead sets bound: Infinity and calls the payload’s armBound() when the socket opens and on its first failed dial, so time spent dialing a slow room never counts, and holdBound() on a 4401 before the first sync (see readiness).

Event Payload When
status { status: 'connecting' | 'connected' | 'disconnected' } The socket’s state changed.
synced boolean This connection has (true) or has lost (false) the room’s state. Resets with every connection.
saved ({ saved, unsaved }, provider) The number of local updates the room has not stored yet changed.
permission-denied (reason, provider) The server refused a write this socket sent. The edytor room sends read-only for each edit of a read-only socket; that denial is not a failure (the socket syncs), and the provider stops counting unsaved updates. It never fires for merely joining: a read-only socket that sends nothing hears none (see readOnly), and once the room said so, the provider sends it only this document’s own edits, never what it heard from another tab or restored from the local copy. Any other reason is terminal, as failed.
refused (refusal, provider) The server closed the socket with a refusal code. refusal is a SyncRefusedError with code and reason. The provider no longer dials.
expired ({ reason, attempts, nextRetryMs }, provider) The server closed the socket with 4401. The provider redials in nextRetryMs with the current params; refresh the token before then.
unreachable ({ attempts, nextRetryMs }, provider) A dial ended before its connection synced, or did not open within connectTimeout: attempts in a row, the next one in nextRetryMs. Also when the room closes a synced socket with 1011 (it could not store an update): attempts counts those faults since the room last stored every local update.
schema-mismatch (detail, provider) An update carrying a foreign schema stamp was refused and not applied.
protocol-mismatch (mismatch, provider) A frame of another edytor generation arrived and was dropped.
failed (error, provider) Terminal: the provider never synced (destroyed first, refused, or denied). Emitted at most once.
connection-close, connection-error the socket event The socket closed or errored. The provider reconnects on its own.
message-error (error, provider) A frame could not be read or applied; it was dropped.

A listener that throws is logged with console.error, and the other listeners and the provider keep running: a failing onExpired still gets the redial, and the empty document still waits for it.

Property Description
synced The current connection holds the room’s state.
hasSynced, whenSynced The provider has synced at least once, and a promise for it.
saved true when the room has stored every update this document’s actor wrote.
unsaved How many of those updates the room has not stored yet, offline edits included.
readOnly true once the room said this connection may read but not write (a notice when it joins, no event). false again when a later dial may write.
wsconnected The socket is open.
connect(), disconnect(), destroy() Control the connection. destroy() also announces your departure to peers.

Saved state

The edytor room stores every update in SQLite before it acknowledges it. The provider compares those acknowledgements with what this replica wrote:

  • unsaved counts updates the room has not confirmed that this document’s actor wrote: in this session, in this user’s other tabs, and before a reload (edits restored from the local copy). The document binds each client id to its actor, so the provider knows which ids are the user’s own. Give the document an actor: an anonymous one gets a new id per session, so its edits from before a reload no longer count. A seed this replica wrote counts too. The binding records themselves are bookkeeping and never count. A read-only socket counts nothing: the room sends it a read-only notice when it joins (no permission-denied), and its provider sets readOnly, drops what it counted and reports saved, whatever the local copy, the other tabs or a seed hold. Edits the room never stored (made offline before the access changed, say) stay unstored and are not sent: to warn the user, check readOnly in the 'saved' listener, and remember unsaved from the event before it (an unsaved above zero dropped to 0 with readOnly set means those edits were not stored). An edit made while read-only is refused, one permission-denied per frame. If a later dial of the same provider may write (a refreshed token), what the actor wrote counts again until the room stores it. A provider on a bare engine doc, with no actor binding, counts everything the doc holds.
  • saved is true once all of those are confirmed. Content written by other actors never counts, even when it only reached the room through this client: after a restore from an older snapshot, the room strips another user’s edits it no longer holds, and they stay stripped until their author returns. Deleting such content is stored at once: the delete waits in the room and applies when the author’s edit returns, so the text does not come back. A delete counts as saved only once the room names it in an acknowledgement; if the room already holds its limit of waiting deletes (MAX_WAITING_DELETES, 1,024 ranges), it drops the delete, which stays unsaved (and the text comes back when its author returns) until a later connection resends it and the room stores it. An edit of a block whose last change the restore lost stays unsaved until that change’s author returns: every edit rewrites the block’s last-changed-by attribute, and that rewrite builds on the lost change (see restoring from an older snapshot). A delete that adds no new data (undoing a block deletion, for example) stays unsaved until the room confirms that exact delete. A delete restored from the local copy counts only if its update wrote no other actor’s content, since a delete does not record who made it.
  • The 'saved' event fires with { saved, unsaved } whenever either changes.

Use it for a “Saving… / Saved” indicator or to warn before closing a tab (a beforeunload prompt: when the user stays, the providers keep running). A server that sends no acknowledgements (another relay, for example) leaves unsaved counting forever.

Refusals

What happens Cause What the client sees
Close 4401 The credentials expired. Not terminal: expired fires and the provider redials after the backoff, reading params again. No refused, no failed. A document still empty is not seeded meanwhile: it waits for a dial that gets in, and stays pending while the token is not refreshed.
Close 4403 document access denied authorize returned null, or an invalid identity: a userId that is empty or not a string, over 256 characters or with a lone surrogate, or a replica that is not a client id. Terminal: refused fires once and the provider stops dialing. Refresh the token, then reload (or call connect()) to dial again. An empty document waits for that dial: it seeds only once the provider syncs, or is released while another attached provider has settled. A document that already holds content (a local copy, bytes it was attached over) is decided at the refusal, and a view shows it.
Close 4409 replica bound to another user The replica belongs to another user. Terminal: refused fires and the provider stops dialing.
Read-only notice (auth subtype 1) The socket is read-only; sent when it joins. No event: readOnly turns true and saved reports true. The socket keeps syncing.
permission-denied with read-only The read-only socket sent an edit. The socket stays open and keeps syncing; the edit is not stored, and saved stays true.
Close 1008 refused: <reason>, or another 4xxx code A frame of another generation, a presence entry for a client id another user owns, a foreign schema stamp, or a malformed frame. Content under another user’s client id is not a refusal: the room strips it and the socket stays open. Terminal: refused fires and the provider stops dialing. A document still empty is not seeded; it stays pending and reports the refusal as document.syncRefusal / onSyncRefused. Reload with a fixed client (a new deploy, a new token) to try again.
Close 1011 The room could not store an update. The provider reconnects and the handshake resends the edit, backing off while the fault repeats; each such close emits unreachable.

<Edytor> reports a refusal of its document through onSyncRefused(refusal): one already standing when the view mounts, then each new one.

See Authorization and the room for the server side.

Was this page helpful?