Live-Kollaboratives Bearbeiten in Astro mit… | LaunchFast
LaunchFast Logo LaunchFast
Blog
4.916 Wörter 25 Min. Lesezeit

Live-Kollaboratives Bearbeiten in Astro mit Cloudflare Durable Objects

Erstellen Sie Google-Docs-ähnliche Echtzeit-Kollaboration in Astro mit Cloudflare Durable Objects. Implementieren Sie Operational Transforms, Konfliktauflösung und Cursor-Tracking mit persistenten WebSocket-Verbindungen am Edge.

Rishi Raj Jain
Rishi Raj Jain Autor
Live Collaborative Editing in Astro with Cloudflare Durable Objects

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.

Sponsored

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 ↗

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:

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 Dokumentinhalt

Erstellen einer neuen Astro-Anwendung

Beginnen wir mit der Erstellung eines neuen Astro-Projekts. Führen Sie den folgenden Befehl aus:

Terminal window
npm create astro@latest my-astro-collab-editor
cd my-astro-collab-editor
npm install wrangler

Wenn 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:

Terminal window
npm run dev

Die 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:

Terminal window
npx astro add cloudflare --yes

Aktualisieren Sie astro.config.mjs, um den Worker-Einstiegspunkt anzugeben:

astro.config.mjs
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:

wrangler.jsonc
{
// ...
"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:

src/env.d.ts
/// <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:

src/worker.ts
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 die Document-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:

src/pages/api/doc/[id].ts
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 idFromName im Durable-Object-Namespace mit der Dokument-id ab und erhält dann über .get einen 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">&times;</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 einen timestamp, den content des Dokuments in diesem Moment und die userId des 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 von blockConcurrencyWhile geladen. 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 dem versions-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.put gespeichert.
  • Broadcasting: Nach dem Aktualisieren von Inhalt und Versionen werden alle verbundenen Clients (außer dem, der die Änderung gesendet hat) über this.broadcast informiert.

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:

Terminal window
# Build the project
npm run build
# Deploy to Cloudflare Workers
npx wrangler deploy

Ihr 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.

Weiterlesen