Add real-time auto-save and multi-user collaboration to RTE with a single script tag.
rte-ws.js is a standalone companion file for RTE that connects to any WebSocket backend. It provides debounced auto-save, real-time content syncing between users, automatic reconnection with exponential backoff, and heartbeat keep-alive.
Debounced content sync to your backend on every change.
Broadcast and receive changes between multiple users in real time.
Exponential backoff reconnection with heartbeat keep-alive.
<script src="https://rte.whitneys.co/rte.js"></script>
<script src="https://rte.whitneys.co/rte-ws.js"></script>
<script src="rte.js"></script>
<script src="rte-ws.js"></script>
const RTE = require('rte-rich-text-editor');
const RTEWS = require('./rte-ws.js');
<div id="editor"></div>
<script src="https://rte.whitneys.co/rte.js"></script>
<script src="https://rte.whitneys.co/rte-ws.js"></script>
<script>
const editor = RTE.init('#editor');
const ws = RTEWS.connect(editor, 'wss://yourserver.com/ws', {
docId: 'doc-123',
userId: 'user-abc',
onOpen: () => console.log('Connected'),
onSaved: (msg) => console.log('Saved, version:', msg.version),
onRemoteUpdate: (msg) => console.log('Update from:', msg.userId),
});
</script>
That's it. The editor will auto-save content to your backend and receive real-time updates from other users.
Pass an options object as the third argument to RTEWS.connect():
| Option | Type | Default | Description |
|---|---|---|---|
docId | String | null | Document identifier sent with all messages |
userId | String | null | User identifier for collaboration |
debounceMs | Number | 1000 | Milliseconds to debounce before sending changes |
autoSave | Boolean | true | Automatically send changes on editor input |
reconnect | Boolean | true | Auto-reconnect on disconnect |
reconnectBaseMs | Number | 1000 | Initial reconnect delay (doubles each attempt) |
reconnectMaxMs | Number | 30000 | Maximum reconnect delay |
heartbeatMs | Number | 30000 | Ping interval in ms (0 to disable) |
onOpen | Function | null | Called when WebSocket connects. Receives ws instance. |
onClose | Function | null | Called when WebSocket closes. Receives close event. |
onError | Function | null | Called on WebSocket error or server error message. |
onSaved | Function | null | Called when server confirms a save. Receives message. |
onRemoteUpdate | Function | null | Called when a remote user's change is applied. |
onMessage | Function | null | Called for every incoming message (raw handler). |
All messages are JSON. Your backend should handle these message types:
| Type | Fields | Description |
|---|---|---|
"join" |
docId, userId |
Sent on connect. Server should respond with a "load" message. |
"change" |
docId, userId, html, text, words, chars |
Sent on editor change (debounced). Server should broadcast to other users and optionally persist. |
"save" |
docId, userId, html, text, words, chars |
Explicit save request via ws.save(). Server should persist and respond with "saved". |
"ping" |
(none) | Heartbeat keep-alive. Server can respond with "pong" or ignore. |
| Type | Fields | Description |
|---|---|---|
"load" |
html |
Load initial document content into the editor. |
"update" |
html, userId |
Remote user change. Applied only if userId differs from local user. |
"saved" |
version (optional) |
Server confirms save. Triggers onSaved callback. |
"error" |
message |
Server error. Triggers onError callback. |
RTEWS.connect() returns a connection object with the following methods and properties:
| Method / Property | Description |
|---|---|
ws.save() | Send an explicit save request to the server. Returns true if sent. |
ws.send(data) | Send a custom JSON message. Returns true if sent. |
ws.disconnect() | Close the connection and stop auto-reconnecting. |
ws.reconnect() | Manually reconnect (re-enables auto-reconnect). |
ws.state | Connection state: "connecting", "open", "closing", or "closed". |
ws.socket | The raw WebSocket instance. |
const ws = RTEWS.connect(editor, 'wss://api.example.com/ws', {
docId: 'doc-123',
userId: 'user-abc',
debounceMs: 500,
onSaved: (msg) => console.log('Version:', msg.version),
});
// Explicit save
document.getElementById('save-btn').addEventListener('click', () => {
ws.save();
});
// Check state
console.log(ws.state); // "open"
// Disconnect when done
ws.disconnect();
With autoSave: true (the default), every editor change is debounced and sent to the server automatically:
const ws = RTEWS.connect(editor, 'wss://api.example.com/ws', {
docId: 'my-doc',
userId: 'user-1',
debounceMs: 2000, // wait 2 seconds of inactivity before sending
onSaved: () => showStatus('Saved'),
});
The server receives a "change" message with the full HTML, plain text, word count, and character count. To disable auto-save and only send on explicit ws.save() calls:
const ws = RTEWS.connect(editor, url, {
autoSave: false, // only send when ws.save() is called
});
When your server broadcasts "update" messages from one user to others, rte-ws automatically applies the changes while preserving the local user's cursor position.
// User A and User B both connect to the same document
const ws = RTEWS.connect(editor, 'wss://api.example.com/ws', {
docId: 'shared-doc',
userId: currentUser.id,
onRemoteUpdate: (msg) => {
showNotification(msg.userId + ' made a change');
},
});
"change" is sent to the server."update" with userId: "A" to all other clients.msg.userId !== cfg.userId, the HTML is applied.rte-ws uses exponential backoff for reconnection:
reconnectMaxMs (default 30s)On successful reconnect, a "join" message is sent again so the server can re-associate the client with the document.
const ws = RTEWS.connect(editor, url, {
reconnect: true,
reconnectBaseMs: 500, // start at 500ms
reconnectMaxMs: 15000, // cap at 15 seconds
onOpen: () => console.log('Connected / reconnected'),
onClose: () => console.log('Disconnected, retrying...'),
});
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const docs = new Map(); // docId -> { html, clients: Set }
wss.on('connection', (socket) => {
let clientDoc = null;
let clientUser = null;
socket.on('message', (raw) => {
const msg = JSON.parse(raw);
switch (msg.type) {
case 'join':
clientDoc = msg.docId;
clientUser = msg.userId;
if (!docs.has(clientDoc)) {
docs.set(clientDoc, { html: '', clients: new Set() });
}
const doc = docs.get(clientDoc);
doc.clients.add(socket);
// Send current content
socket.send(JSON.stringify({ type: 'load', html: doc.html }));
break;
case 'change':
if (clientDoc && docs.has(clientDoc)) {
const doc = docs.get(clientDoc);
doc.html = msg.html;
// Broadcast to other clients
doc.clients.forEach((client) => {
if (client !== socket && client.readyState === 1) {
client.send(JSON.stringify({
type: 'update',
html: msg.html,
userId: msg.userId,
}));
}
});
}
break;
case 'save':
// Persist to database here...
socket.send(JSON.stringify({ type: 'saved', version: Date.now() }));
break;
case 'ping':
socket.send(JSON.stringify({ type: 'pong' }));
break;
}
});
socket.on('close', () => {
if (clientDoc && docs.has(clientDoc)) {
docs.get(clientDoc).clients.delete(socket);
}
});
});
import asyncio, json, websockets
docs = {} # docId -> { "html": str, "clients": set }
async def handler(ws):
doc_id = user_id = None
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "join":
doc_id = msg["docId"]
user_id = msg["userId"]
if doc_id not in docs:
docs[doc_id] = {"html": "", "clients": set()}
docs[doc_id]["clients"].add(ws)
await ws.send(json.dumps({"type": "load", "html": docs[doc_id]["html"]}))
elif msg["type"] == "change" and doc_id:
docs[doc_id]["html"] = msg["html"]
for client in docs[doc_id]["clients"]:
if client != ws:
await client.send(json.dumps({
"type": "update", "html": msg["html"], "userId": user_id
}))
elif msg["type"] == "save":
# Persist to database here...
await ws.send(json.dumps({"type": "saved"}))
if doc_id and doc_id in docs:
docs[doc_id]["clients"].discard(ws)
asyncio.run(websockets.serve(handler, "0.0.0.0", 8080))