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=…. 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 a 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. 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 or denying server is not dialed forever at full rate. Each of these dials emits unreachable with { attempts, nextRetryMs }, for a “Reconnecting in 20 s” indicator.
  • 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}/${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; the edytor room sends read-only to read-only sockets.
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.
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.
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, so a read-only socket’s provider, which the room never asks for its state, reports saved once it caught up. 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. 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). 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.
permission-denied with read-only A read-only socket sent an edit. The socket stays open; the edit is not stored.
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.

<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?