Kollaboratives Bearbeiten im Stil von Google Docs bedeutet, verteilte Zustandssynchronisation zu lösen, gleichzeitige Bearbeitungen zu handhaben und Konflikte aufzulösen. Traditionelle Ansätze erfordern Operational-Transform-Server, CRDT-Bibliotheken oder Managed Services wie Yjs mit Y-WebSocket-Servern — alles komplex einzurichten und teuer zu skalieren.
Hochwertige Starter-Kits mit integriertem Authentifizierungsfluss (Auth.js), Objekt-Uploads (AWS, Clouflare R2, Firebase Storage, Supabase Storage), integrierten Zahlungen (Stripe, LemonSqueezy), E-Mail-Verifizierungsablauf (Resend, Postmark, Sendgrid) und viel mehr . Kompatibel mit jeder Datenbank (Redis, Postgres, MongoDB, SQLite, Firestore).
Get all 3 kits Bundle ↗- Next.js Starter Kit
- SvelteKit Starter Kit
- Astro Starter Kit
One-time license · Lifetime updates
Cloudflare Durable Objects vereinfachen dies, indem sie stark konsistente, zustandsbehaftete Objekte am Edge bereitstellen, die WebSocket-Verbindungen nativ unterstützen. Jedes Dokument erhält seine eigene Durable-Object-Instanz mit integriertem Storage und Single-Thread-Ausführung — Race Conditions entfallen und die Konfliktauflösung wird unkompliziert.
In diesem Leitfaden bauen Sie einen kollaborativen Texteditor in Astro, der Änderungen in Echtzeit über mehrere Benutzer synchronisiert. Sie implementieren Change-Broadcasting, Cursor-Tracking und Dokumentpersistenz mit Durable Objects und WebSocket Hibernation für Kosteneffizienz.
Demo
Voraussetzungen
Sie benötigen:
- Node.js 20 oder höher
- Ein Cloudflare-Konto
Architekturüberblick
1. Editor-Seite (src/pages/doc/[id].astro) → Rendert die kollaborative Editor-Oberfläche → Stellt WebSocket-Verbindung her
2. WebSocket-API (src/pages/api/doc/[id].ts) → Verarbeitet WebSocket-Upgrade-Anfragen → Verbindet mit Document Durable Object
3. Document Durable Object (src/worker.ts) → Verwaltet Dokumentzustand und Verlauf → Sendet Änderungen an alle verbundenen Clients → Verfolgt Benutzer-Cursor und Auswahlen → Persistiert DokumentinhaltErstellen einer neuen Astro-Anwendung
Beginnen wir mit der Erstellung eines neuen Astro-Projekts. Führen Sie den folgenden Befehl aus:
npm create astro@latest my-astro-collab-editorcd my-astro-collab-editornpm install wranglerWenn Sie dazu aufgefordert werden, wählen Sie:
Use minimal (empty) template, wenn Sie gefragt werden, wie Sie das neue Projekt starten möchten.Yes, wenn Sie gefragt werden, ob Abhängigkeiten installiert werden sollen.Yes, wenn Sie gefragt werden, ob ein Git-Repository initialisiert werden soll.
Sobald das erledigt ist, können Sie in das Projektverzeichnis wechseln und die App starten:
npm run devDie App sollte auf localhost:4321 laufen.
Cloudflare-Adapter in Ihr Astro-Projekt integrieren
Um Ihr Astro-Projekt auf Cloudflare Workers bereitzustellen und Durable Objects zu nutzen, müssen Sie den Cloudflare-Adapter installieren. Führen Sie den folgenden Befehl aus:
npx astro add cloudflare --yesAktualisieren Sie astro.config.mjs, um den Worker-Einstiegspunkt anzugeben:
import { defineConfig } from 'astro/config';import cloudflare from '@astrojs/cloudflare';
export default defineConfig({ output: 'server', adapter: cloudflare({ workerEntryPoint: { path: './src/worker.ts', namedExports: ['Document'] } })});Durable-Objects-Binding konfigurieren
Erstellen Sie wrangler.jsonc im Stammverzeichnis Ihres Projekts:
{ // ... "durable_objects": { "bindings": [ { "name": "Document", "class_name": "Document" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["Document"] } ]}TypeScript-Typen hinzufügen
Erstellen Sie src/env.d.ts, um passende Typen für das Durable-Objects-Binding hinzuzufügen:
/// <reference types="astro/client" />/// <reference types="@cloudflare/workers-types" />
type ENV = { Document: DurableObjectNamespace}
type DurableObjectNamespace = import('@cloudflare/workers-types').DurableObjectNamespace
type Runtime = import('@astrojs/cloudflare').Runtime<ENV>
declare namespace App { interface Locals extends Runtime { }}Document Durable Object erstellen
Erstellen Sie das Durable Object, das Dokumentzustand und WebSocket-Verbindungen verwaltet:
import { DurableObject } from 'cloudflare:workers';import { handle } from '@astrojs/cloudflare/handler';import type { SSRManifest } from 'astro';import { App } from 'astro/app';
interface SessionData { userId: string userName: string color: string}
interface Version { content: string timestamp: number userId?: string userName?: string}
interface Message { type: 'init' | 'change' | 'cursor' | 'user-join' | 'user-leave' | 'get-versions' | 'versions' | 'restore-version' content?: string userId?: string userName?: string color?: string position?: number changes?: { from: number; to: number; insert: string } users?: SessionData[] versions?: Version[] versionIndex?: number}
export class Document extends DurableObject<ENV> { private state: DurableObjectState private sessions: Map<WebSocket, SessionData> private content: string private versions: Version[]
constructor(ctx: DurableObjectState, env: ENV) { super(ctx, env) this.state = ctx this.sessions = new Map() this.content = '' this.versions = []
// Load document content and versions from storage this.state.blockConcurrencyWhile(async () => { const stored = await this.state.storage.get<string>('content') this.content = stored || ''
const storedVersions = await this.state.storage.get<Version[]>('versions') this.versions = storedVersions || []
// If there's content but no versions, create initial version if (this.content && this.versions.length === 0) { this.versions.push({ content: this.content, timestamp: Date.now() }) await this.state.storage.put('versions', this.versions) } }) }
189 collapsed lines
async fetch(request: Request): Promise<Response> { const upgradeHeader = request.headers.get('Upgrade') if (upgradeHeader !== 'websocket') { return new Response('Expected WebSocket', { status: 426 }) }
const pair = new WebSocketPair() const [client, server] = Object.values(pair)
this.state.acceptWebSocket(server)
return new Response(null, { status: 101, webSocket: client, }) }
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { try { const data = JSON.parse(message.toString()) as Message
switch (data.type) { case 'init': { // New user connecting const sessionData: SessionData = { userId: data.userId!, userName: data.userName!, color: data.color!, }
this.sessions.set(ws, sessionData)
// Send current document state to new user ws.send( JSON.stringify({ type: 'init', content: this.content, users: Array.from(this.sessions.values()), }) )
// Notify others about new user this.broadcast( { type: 'user-join', userId: sessionData.userId, userName: sessionData.userName, color: sessionData.color, }, ws ) break }
case 'change': { // Apply and broadcast document changes const { changes } = data if (changes) { // Simple operational transform: apply change to content const before = this.content.slice(0, changes.from) const after = this.content.slice(changes.to) this.content = before + changes.insert + after
// Persist to storage await this.state.storage.put('content', this.content)
// Save version (limit to 50 versions) const session = this.sessions.get(ws) this.versions.push({ content: this.content, timestamp: Date.now(), userId: session?.userId, userName: session?.userName })
// Keep only last 50 versions if (this.versions.length > 50) { this.versions = this.versions.slice(-50) }
await this.state.storage.put('versions', this.versions)
// Broadcast to all clients this.broadcast( { type: 'change', changes, userId: this.sessions.get(ws)?.userId, }, ws ) } break }
case 'get-versions': { // Send version history to requesting client ws.send( JSON.stringify({ type: 'versions', versions: this.versions }) ) break }
case 'restore-version': { // Restore to a specific version if (data.versionIndex !== undefined && data.versionIndex >= 0 && data.versionIndex < this.versions.length) { const version = this.versions[data.versionIndex] this.content = version.content
await this.state.storage.put('content', this.content)
// Broadcast full content update to all clients this.broadcast({ type: 'init', content: this.content, users: Array.from(this.sessions.values()) }) } break }
case 'cursor': { // Broadcast cursor position to others const session = this.sessions.get(ws) if (session) { this.broadcast( { type: 'cursor', userId: session.userId, position: data.position, }, ws ) } break } } } catch (error) { console.error('WebSocket message error:', error) } }
async webSocketClose(ws: WebSocket, code: number, reason: string) { const session = this.sessions.get(ws) if (session) { // Notify others about user leaving this.broadcast({ type: 'user-leave', userId: session.userId, }) this.sessions.delete(ws) } ws.close(code, reason) }
async webSocketError(ws: WebSocket, error: unknown) { console.error('WebSocket error:', error) ws.close(1011, 'WebSocket error') }
private broadcast(message: Message, exclude?: WebSocket) { const payload = JSON.stringify(message) for (const [ws, _] of this.sessions) { if (ws !== exclude) { try { ws.send(payload) } catch (error) { console.error('Broadcast error:', error) } } } }}
export function createExports(manifest: SSRManifest) { const app = new App(manifest); return { default: { async fetch(request, env, ctx) { // @ts-expect-error - request is not typed correctly return handle(manifest, app, request, env, ctx); } } satisfies ExportedHandler<ENV>, Document, }}Der obige Code zeigt die Hauptimplementierung einer Cloudflare-Durable-Object-Klasse Document für einen kollaborativen Echtzeit-Texteditor. Hier eine Aufschlüsselung dessen, was passiert:
-
Session- und Benutzerverwaltung: Das System verfolgt verbundene Benutzer (Sessions) über WebSocket-Verbindungen. Wenn eine WebSocket-Verbindung geschlossen wird oder einen Fehler meldet, wird die Session bereinigt, verbleibende Clients werden über das Verlassen des Benutzers informiert und die Verbindung wird ordnungsgemäß geschlossen.
-
Broadcasting: Die
broadcast-Methode ist dafür verantwortlich, Updates (wie Dokumentänderungen, Benutzerbeitritte/-austritte, Antworten mit Versionsverlauf usw.) an alle verbundenen WebSocket-Clients zu senden — optional mit Ausnahme des Clients, der das Ereignis ausgelöst hat. So bleiben Änderungen für alle Benutzer in Echtzeit synchron. -
Export des Handlers und Document Durable Object: Die
createExports-Funktion integriert die Astro-SSR-App in die Cloudflare-Worker-Umgebung. Sie gibt ein Objekt zurück, das den SSR-Handler (default.fetch) für HTTP-Anfragen enthält und dieDocument-Klasse als Durable Object bereitstellt. So kann die Plattform WebSocket-Upgrade-Anfragen routen und persistenten Zustand für jedes Dokument verwalten.
Insgesamt verdrahtet dieser Code die gesamte serverseitige Funktionalität für kollaboratives Dokumentenbearbeiten: Echtzeit-Messaging, persistenten Dokumentzustand, Versionsverlauf und die Nutzung von Astros SSR-Routen neben Live-Kollaborationsfunktionen.
WebSocket-API-Endpunkt erstellen
Erstellen Sie die API-Route, die Clients mit dem Document Durable Object verbindet:
import type { APIRoute } from 'astro'
export const GET: APIRoute = async ({ params, locals, request }) => { const { id } = params const upgradeHeader = request.headers.get('Upgrade') if (upgradeHeader !== 'websocket') return new Response('Expected WebSocket', { status: 426 }) // Get the Durable Object for this document const documentId = locals.runtime.env.Document.idFromName(id!) const documentStub = locals.runtime.env.Document.get(documentId) // Forward the WebSocket upgrade to the Durable Object return documentStub.fetch(request)}Der obige Code definiert eine API-Route in Astro unter src/pages/api/doc/[id].ts, die als Einstiegspunkt für WebSocket-Clients dient, die einer kollaborativen Dokumentsession beitreten möchten. So funktioniert sie Schritt für Schritt:
-
Wenn eine GET-Anfrage an diesen Endpunkt gesendet wird (in der Regel von einem Client, der einer kollaborativen Bearbeitungssession beitreten oder eine erstellen möchte), prüft der Code zuerst, ob es sich um ein WebSocket-Upgrade handelt. Ist das nicht der Fall, antwortet er mit Status 426, um anzuzeigen, dass eine WebSocket-Verbindung erforderlich ist.
-
Handelt es sich um ein gültiges WebSocket-Upgrade, nutzt der Endpunkt den
id-Parameter aus der URL, um das kollaborative Dokument (und damit die zugehörige Durable-Object-Instanz) zu identifizieren. -
Er ruft die Durable-Object-ID über
idFromNameim Durable-Object-Namespace mit der Dokument-idab und erhält dann über.geteinen Stub (Remote-Handle) zur korrekten Durable-Object-Instanz. -
Schließlich leitet der Code die gesamte eingehende Anfrage (einschließlich des WebSocket-Upgrades) direkt an die
fetch-Methode des Durable Objects weiter. Damit wird die Verbindung (und ihr Upgrade) an das Durable Object übergeben, das den gemeinsamen Dokumentzustand verwaltet und Bearbeitungen in Echtzeit zwischen allen verbundenen Clients synchronisiert.
Kollaborative Editor-Oberfläche erstellen
Erstellen Sie die Editor-Seite mit Echtzeit-Synchronisation:
---const { id } = Astro.params;---
<html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Collaborative Editor - {id}</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Inter', system-ui, sans-serif; background: #f8fafc; color: #0f172a;452 collapsed lines
} .container { max-width: 900px; margin: 0 auto; padding: 24px; } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; padding: 16px; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .users { display: flex; gap: 8px; align-items: center; } .user-badge { padding: 6px 12px; border-radius: 16px; font-size: 14px; font-weight: 500; color: white; } .status { font-size: 14px; color: #64748b; } .status.connected { color: #10b981; } .editor-wrapper { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); padding: 24px; } #editor { width: 100%; min-height: 500px; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; font-family: 'Monaco', 'Courier New', monospace; font-size: 16px; line-height: 1.6; resize: vertical; } #editor:focus { outline: none; border-color: #3b82f6; } .cursors { position: relative; } .cursor { position: absolute; width: 2px; height: 20px; pointer-events: none; transition: all 0.1s ease; } .cursor-label { position: absolute; top: -24px; left: -4px; padding: 2px 8px; border-radius: 4px; font-size: 12px; color: white; white-space: nowrap; } .version-btn { padding: 10px 20px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; } .version-btn:hover { background: #2563eb; } .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center; } .modal.open { display: flex; } .modal-content { background: white; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .modal-header h2 { font-size: 20px; font-weight: 600; } .close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: #64748b; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; } .close-btn:hover { background: #f1f5f9; color: #0f172a; } .version-list { display: flex; flex-direction: column; gap: 12px; } .version-item { padding: 16px; border: 1px solid #e2e8f0; border-radius: 8px; cursor: pointer; transition: all 0.2s; } .version-item:hover { border-color: #3b82f6; background: #f8fafc; } .version-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .version-time { font-size: 14px; color: #64748b; } .version-user { font-size: 12px; padding: 4px 8px; background: #f1f5f9; border-radius: 4px; color: #475569; } .version-preview { font-size: 13px; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'Monaco', 'Courier New', monospace; } .empty-state { text-align: center; padding: 40px 20px; color: #64748b; } </style> </head> <body> <div class="container"> <div class="header"> <div> <h1>Document: {id}</h1> <div id="status" class="status">Connecting...</div> </div> <div style="display: flex; gap: 16px; align-items: center;"> <button id="versionBtn" class="version-btn">View Versions</button> <div class="users" id="users"></div> </div> </div>
<div class="editor-wrapper"> <textarea id="editor" placeholder="Start typing..."></textarea> </div> </div>
<div id="versionModal" class="modal"> <div class="modal-content"> <div class="modal-header"> <h2>Version History</h2> <button id="closeModal" class="close-btn">×</button> </div> <div id="versionList" class="version-list"></div> </div> </div>
<script define:vars={{ documentId: id }}> const editor = document.getElementById('editor') const status = document.getElementById('status') const usersContainer = document.getElementById('users')
// Generate random user info const userId = Math.random().toString(36).substring(7) const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'] const userColor = colors[Math.floor(Math.random() * colors.length)] const userName = `User ${userId.substring(0, 4)}`
let ws = null let users = new Map() let isRemoteUpdate = false let versions = []
function connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' ws = new WebSocket(`${protocol}//${window.location.host}/api/doc/${documentId}`)
ws.onopen = () => { status.textContent = 'Connected' status.classList.add('connected')
// Send init message ws.send(JSON.stringify({ type: 'init', userId, userName, color: userColor, })) }
ws.onmessage = (event) => { const data = JSON.parse(event.data)
switch (data.type) { case 'init': { // Set initial content isRemoteUpdate = true editor.value = data.content lastValue = data.content isRemoteUpdate = false
// Update users list data.users.forEach((user) => { if (user.userId !== userId) { users.set(user.userId, user) } }) updateUsersList() break }
case 'change': { // Apply remote changes if (data.userId !== userId) { isRemoteUpdate = true const { from, to, insert } = data.changes const before = editor.value.slice(0, from) const after = editor.value.slice(to) editor.value = before + insert + after lastValue = editor.value isRemoteUpdate = false } break }
case 'user-join': { users.set(data.userId, { userId: data.userId, userName: data.userName, color: data.color, }) updateUsersList() break }
case 'user-leave': { users.delete(data.userId) updateUsersList() break }
case 'cursor': { // Handle cursor updates from other users console.log('Cursor update:', data) break }
case 'versions': { // Received version history versions = data.versions || [] displayVersions() break } } }
ws.onclose = () => { status.textContent = 'Disconnected' status.classList.remove('connected') setTimeout(connect, 2000) }
ws.onerror = (error) => { console.error('WebSocket error:', error) } }
function updateUsersList() { usersContainer.innerHTML = '' users.forEach((user) => { const badge = document.createElement('div') badge.className = 'user-badge' badge.style.backgroundColor = user.color badge.textContent = user.userName usersContainer.appendChild(badge) }) }
// Track changes let lastValue = '' editor.addEventListener('input', () => { if (isRemoteUpdate || !ws || ws.readyState !== WebSocket.OPEN) { return }
const newValue = editor.value const cursorPos = editor.selectionStart
// Simple diff: find where the change occurred let from = 0 while (from < lastValue.length && from < newValue.length && lastValue[from] === newValue[from]) { from++ }
let lastEnd = lastValue.length let newEnd = newValue.length while (lastEnd > from && newEnd > from && lastValue[lastEnd - 1] === newValue[newEnd - 1]) { lastEnd-- newEnd-- }
const insert = newValue.slice(from, newEnd)
// Send change ws.send(JSON.stringify({ type: 'change', changes: { from, to: lastEnd, insert }, }))
lastValue = newValue })
// Track cursor position editor.addEventListener('selectionchange', () => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'cursor', position: editor.selectionStart, })) } })
// Version modal handling const versionBtn = document.getElementById('versionBtn') const versionModal = document.getElementById('versionModal') const closeModal = document.getElementById('closeModal') const versionList = document.getElementById('versionList')
versionBtn.addEventListener('click', () => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'get-versions' })) versionModal.classList.add('open') } })
closeModal.addEventListener('click', () => { versionModal.classList.remove('open') })
versionModal.addEventListener('click', (e) => { if (e.target === versionModal) { versionModal.classList.remove('open') } })
function displayVersions() { if (versions.length === 0) { versionList.innerHTML = '<div class="empty-state">No versions yet</div>' return }
versionList.innerHTML = versions .map((version, index) => { const date = new Date(version.timestamp) const timeStr = date.toLocaleString() const preview = version.content.substring(0, 100).replace(/\n/g, ' ') const userInfo = version.userName ? `by ${version.userName}` : ''
return ` <div class="version-item" data-index="${index}"> <div class="version-meta"> <div class="version-time">${timeStr}</div> ${userInfo ? `<div class="version-user">${userInfo}</div>` : ''} </div> <div class="version-preview">${preview || '(empty)'}</div> </div> ` }) .reverse() .join('')
// Add click handlers to version items document.querySelectorAll('.version-item').forEach((item) => { item.addEventListener('click', () => { const index = parseInt(item.dataset.index) if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'restore-version', versionIndex: index })) versionModal.classList.remove('open') } }) }) }
connect() </script> </body></html>Der obige Code implementiert Versionsverlauf-Tracking für den kollaborativen Texteditor mit einem Cloudflare Durable Object (Document). Das leistet er:
-
Version-Interface: Es definiert ein
Version-Objekt als Snapshot des Dokuments. Jede Version speichert einentimestamp, dencontentdes Dokuments in diesem Moment und dieuserIddes Benutzers, der die Änderung vorgenommen hat. -
Initialisierung: Im
Document-Konstruktor werden sowohl der aktuelle Dokumentinhalt als auch alle früheren Versionen aus persistentem Storage innerhalb vonblockConcurrencyWhilegeladen. So stehen die neuesten Daten bereit, bevor Anfragen verarbeitet werden. -
Bei jeder Änderung: Im
'change'-Handler (der Bearbeitungen von Benutzern verarbeitet):- Die Änderungsoperation wird auf den Dokumentinhalt angewendet.
- Ein neues
Version-Objekt erfasst den neuen Dokumentzustand, den aktuellen Zeitstempel und die Benutzer-ID des Editors und wird demversions-Array hinzugefügt. - Um unbegrenztes Storage-Wachstum zu verhindern, werden nur die letzten 100 Versionen behalten.
- Sowohl der neue Inhalt als auch der aktualisierte Versionsverlauf werden persistent mit
this.state.storage.putgespeichert.
-
Broadcasting: Nach dem Aktualisieren von Inhalt und Versionen werden alle verbundenen Clients (außer dem, der die Änderung gesendet hat) über
this.broadcastinformiert.
Dieser Mechanismus ermöglicht Funktionen wie Undo/Verlauf, erlaubt Benutzern das Anzeigen und Wiederherstellen früherer Versionen in der Editor-Oberfläche und macht kollaboratives Bearbeiten nachvollziehbar und widerstandsfähig gegen versehentlichen Datenverlust.
Versionsverlauf handhaben
Fügen Sie Versionsverlauf-Tracking zum Durable Object hinzu:
// Add to Document class in src/worker.ts
interface Version { timestamp: number content: string userId: string}
// In constructor:private versions: Version[] = []
this.state.blockConcurrencyWhile(async () => { const stored = await this.state.storage.get<string>('content') this.content = stored || ''
const storedVersions = await this.state.storage.get<Version[]>('versions') this.versions = storedVersions || []})
// In 'change' handler:case 'change': { const { changes } = data if (changes) { const before = this.content.slice(0, changes.from) const after = this.content.slice(changes.to) this.content = before + changes.insert + after
// Save version this.versions.push({ timestamp: Date.now(), content: this.content, userId: this.sessions.get(ws)?.userId || 'unknown', })
// Keep last 100 versions if (this.versions.length > 100) { this.versions = this.versions.slice(-100) }
await this.state.storage.put('content', this.content) await this.state.storage.put('versions', this.versions)
this.broadcast({ type: 'change', changes, userId: this.sessions.get(ws)?.userId }, ws) } break}Bereitstellung auf Cloudflare Workers
Stellen Sie Ihren kollaborativen Editor in Produktion bereit:
# Build the projectnpm run build
# Deploy to Cloudflare Workersnpx wrangler deployIhr kollaborativer Editor ist jetzt live und läuft auf Cloudflares globalem Netzwerk.
Fazit
Cloudflare Durable Objects machen kollaboratives Bearbeiten zugänglich — ohne komplexe Operational-Transform-Server oder CRDT-Bibliotheken. Durch starke Konsistenz und integrierte WebSocket-Unterstützung können Sie Echtzeit-Kollaborationsfunktionen bauen, die global skalieren, mit minimaler Infrastruktur.
Das Muster, das Sie gebaut haben — Dokumentzustand + Change-Broadcasting + Cursor-Tracking — skaliert auf jeden kollaborativen Anwendungsfall. Fügen Sie es Notiz-Apps, Code-Editoren, Design-Tools oder jeder Anwendung hinzu, die Echtzeit-Bearbeitung durch mehrere Benutzer benötigt.
Bei Fragen oder Anmerkungen können Sie mich gerne auf Twitter kontaktieren.