Add collaboration support

Real-time collaboration through consumer-provided transport and
presence interfaces. Also includes a sample backend app.
This commit is contained in:
gsb
2026-04-29 22:15:02 +00:00
parent 2e28598243
commit 1198791505
10 changed files with 1097 additions and 4 deletions
+46
View File
@@ -0,0 +1,46 @@
# Flask Collaboration Example
A minimal Flask server demonstrating ribbit's collaboration features:
real-time sync, presence, locking, and revisions.
## Setup
```sh
pip install flask flask-sock
```
Copy (or symlink) the ribbit dist into the static directory:
```sh
ln -s /path/to/ribbit/dist/ribbit static/ribbit
```
## Run
```sh
python server.py
```
Open http://localhost:5000 in multiple browser tabs. Edits in one tab
appear in the others in real time.
## What it demonstrates
- **Real-time sync**: WebSocket relays document updates between clients
- **Presence**: colored badges show connected users and their status
- **Revisions**: save button creates named revisions, click to restore
- **Locking**: (available via console: `editor.lockForEditing()`)
- **Source mode**: entering markdown mode pauses sync, shows remote change count
## Architecture
```
Browser A ──┐
├── WebSocket ──→ Flask server ──→ WebSocket ──→ Browser B
Browser C ──┘ │
├── /api/revisions (REST)
└── /api/lock (REST)
```
The server is ~160 lines. In production you'd replace the in-memory
stores with a database and add authentication.
+160
View File
@@ -0,0 +1,160 @@
"""
Flask collaboration server example for ribbit.
Demonstrates: WebSocket relay, presence, revisions, and locking.
Requires: flask, flask-sock
pip install flask flask-sock
python server.py
Then open http://localhost:5000 in multiple browser tabs.
"""
import json
import time
import uuid
from pathlib import Path
from threading import Lock
from flask import Flask, jsonify, render_template, request
from flask_sock import Sock
app = Flask(__name__)
sock = Sock(app)
# In-memory state (replace with a database in production)
document = {"content": "# Hello\n\nEdit this page collaboratively.\n\n- Try opening multiple tabs\n- Watch edits appear in real time\n"}
revisions = []
lock_holder = None
lock_mutex = Lock()
clients = {} # ws -> user info
# ── Pages ────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html", content=document["content"])
# ── Revisions API ────────────────────────────────────────
@app.route("/api/revisions", methods=["GET"])
def list_revisions():
return jsonify([{k: v for k, v in r.items() if k != "content"} for r in revisions])
@app.route("/api/revisions/<revision_id>", methods=["GET"])
def get_revision(revision_id):
for r in revisions:
if r["id"] == revision_id:
return jsonify(r)
return jsonify({"error": "not found"}), 404
@app.route("/api/revisions", methods=["POST"])
def create_revision():
data = request.json
rev = {
"id": str(uuid.uuid4())[:8],
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
"author": data.get("author", "anonymous"),
"summary": data.get("summary", ""),
"content": data.get("content", document["content"]),
}
revisions.append(rev)
broadcast_json({"type": "revision", "revision": {k: v for k, v in rev.items() if k != "content"}})
return jsonify(rev), 201
# ── Locking API ──────────────────────────────────────────
@app.route("/api/lock", methods=["POST"])
def acquire_lock():
global lock_holder
with lock_mutex:
if lock_holder is None:
lock_holder = request.json
broadcast_json({"type": "lock", "holder": lock_holder})
return jsonify({"ok": True})
return jsonify({"ok": False, "holder": lock_holder}), 409
@app.route("/api/lock", methods=["DELETE"])
def release_lock():
global lock_holder
with lock_mutex:
lock_holder = None
broadcast_json({"type": "lock", "holder": None})
return jsonify({"ok": True})
@app.route("/api/lock/force", methods=["POST"])
def force_lock():
global lock_holder
with lock_mutex:
lock_holder = request.json
broadcast_json({"type": "lock", "holder": lock_holder})
return jsonify({"ok": True})
# ── WebSocket relay ──────────────────────────────────────
@sock.route("/ws")
def websocket(ws):
client_id = str(uuid.uuid4())[:8]
clients[client_id] = {"ws": ws, "user": None}
try:
while True:
data = ws.receive()
if isinstance(data, bytes):
# Binary = document update, relay to all other clients
document["content"] = data.decode("utf-8")
for cid, client in clients.items():
if cid != client_id:
try:
client["ws"].send(data)
except Exception:
pass
elif isinstance(data, str):
msg = json.loads(data)
if msg.get("type") == "join":
clients[client_id]["user"] = msg.get("user")
# Send current document state
ws.send(document["content"].encode("utf-8"))
# Send current lock state
ws.send(json.dumps({"type": "lock", "holder": lock_holder}))
# Broadcast updated peer list
broadcast_peers()
elif msg.get("type") == "presence":
clients[client_id]["user"] = msg
broadcast_peers()
except Exception:
pass
finally:
del clients[client_id]
broadcast_peers()
def broadcast_json(msg):
data = json.dumps(msg)
for client in clients.values():
try:
client["ws"].send(data)
except Exception:
pass
def broadcast_peers():
peers = [c["user"] for c in clients.values() if c["user"]]
broadcast_json({"type": "peers", "peers": peers})
if __name__ == "__main__":
app.run(debug=True, port=5000)
+164
View File
@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Ribbit Collaboration Example</title>
<link rel="stylesheet" href="/static/ribbit/themes/ribbit-default/theme.css">
<style>
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; }
#peers { padding: 8px; background: #f0f0f0; border-radius: 4px; margin-bottom: 10px; font-size: 14px; }
#peers .peer { display: inline-block; padding: 2px 8px; border-radius: 3px; margin-right: 4px; color: white; }
#status { font-size: 12px; color: #666; margin-bottom: 10px; }
#revisions { margin-top: 20px; }
#revisions button { margin: 2px; }
</style>
</head>
<body>
<h1>Ribbit Collaboration Example</h1>
<div id="peers">No peers connected</div>
<div id="status"></div>
<article id="ribbit">{{ content }}</article>
<div id="revisions">
<h3>Revisions</h3>
<div id="revision-list">Loading...</div>
</div>
<script src="/static/ribbit/ribbit.js"></script>
<script>
const userId = 'user-' + Math.random().toString(36).slice(2, 6);
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#1abc9c'];
const color = colors[Math.floor(Math.random() * colors.length)];
const ws = new WebSocket(`ws://${location.host}/ws`);
const transport = {
connect() {
ws.send(JSON.stringify({
type: 'join',
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
}));
},
disconnect() {},
send(update) { if (ws.readyState === 1) ws.send(update); },
onReceive(callback) {
ws.addEventListener('message', (e) => {
if (e.data instanceof Blob) {
e.data.arrayBuffer().then(buf => callback(new Uint8Array(buf)));
}
});
},
async lock() {
const res = await fetch('/api/lock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, displayName: userId }),
});
return res.ok;
},
unlock() { fetch('/api/lock', { method: 'DELETE' }); },
async forceLock() {
const res = await fetch('/api/lock/force', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, displayName: userId }),
});
return res.ok;
},
onLockChange(callback) {
ws.addEventListener('message', (e) => {
if (typeof e.data === 'string') {
const msg = JSON.parse(e.data);
if (msg.type === 'lock') callback(msg.holder);
}
});
},
};
const presence = {
send(info) {
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'presence', ...info }));
},
onUpdate(callback) {
ws.addEventListener('message', (e) => {
if (typeof e.data === 'string') {
const msg = JSON.parse(e.data);
if (msg.type === 'peers') callback(msg.peers);
}
});
},
};
const revisions = {
async list() {
return (await fetch('/api/revisions')).json();
},
async get(id) {
return (await fetch(`/api/revisions/${id}`)).json();
},
async create(content, metadata) {
const res = await fetch('/api/revisions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, ...metadata }),
});
return res.json();
},
};
const editor = new ribbit.Editor({
collaboration: {
transport,
presence,
revisions,
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
},
on: {
peerChange({ peers }) {
const el = document.getElementById('peers');
if (peers.length === 0) {
el.innerHTML = 'No peers connected';
} else {
el.innerHTML = peers.map(p =>
`<span class="peer" style="background:${p.color || '#999'}">${p.displayName} (${p.status})</span>`
).join('');
}
},
lockChange({ holder }) {
const el = document.getElementById('status');
el.textContent = holder ? `🔒 Locked by ${holder.displayName}` : '';
},
remoteActivity({ count }) {
const el = document.getElementById('status');
el.textContent = `${count} remote change${count > 1 ? 's' : ''} while in source mode`;
},
save({ markdown }) {
revisions.create(markdown, { author: userId, summary: 'Manual save' }).then(refreshRevisions);
},
revisionCreated() {
refreshRevisions();
},
},
});
editor.run();
async function refreshRevisions() {
const list = await editor.listRevisions();
const el = document.getElementById('revision-list');
if (list.length === 0) {
el.innerHTML = '<em>No revisions yet. Click Save to create one.</em>';
} else {
el.innerHTML = list.map(r =>
`<button onclick="restore('${r.id}')">${r.timestamp} by ${r.author}${r.summary ? ': ' + r.summary : ''}</button>`
).join('<br>');
}
}
window.restore = async function(id) {
await editor.restoreRevision(id);
};
refreshRevisions();
</script>
</body>
</html>