---
title: "Live-Kollaboratives Bearbeiten in Astro mit Cloudflare Durable Objects"
description: "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."
source: "https://www.launchfa.st/de/blog/astro-collaborative-editing-durable-objects"
author: "Rishi Raj Jain"
created_at: 2025-12-09T14:00:00.000Z
keywords: "astro, cloudflare, durable objects, collaborative editing, operational transforms, real-time, websockets, crdt"
---

> **Site index for agents**
> Fetch https://www.launchfa.st/llms.txt to discover every page on this site.
> Every page is also available as Markdown by appending `.md` to its URL.

<img width="1600" height="900" alt="Live Collaborative Editing in Astro with Cloudflare Durable Objects" decoding="async" loading="eager" class="mt-4 border rounded-sm bg-cover bg-center bg-no-repeat transform will-change-auto" src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/astro-collaborative-editing-durable-objects.png" />

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.

[Cloudflare Durable Objects](https://developers.cloudflare.com/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.

- [Beispielprojekt auf GitHub ansehen](https://github.com/rishi-raj-jain/my-astro-collab-editor)

## Demo

<video controls class="rounded aspect-video" autoplay="false">
    <source src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/Screen%20Recording%202025-12-10%20at%201.24.46%E2%80%AFAM.mp4" type="video/mp4">
</video>

## Voraussetzungen

Sie benötigen:

- [Node.js 20](https://nodejs.org/en/blog/announcements/v20-release-announce) oder höher
- Ein [Cloudflare](https://dash.cloudflare.com/sign-up)-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 Dokumentinhalt
```

## Erstellen einer neuen Astro-Anwendung

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

```bash
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:

```bash
npm run dev
```

Die App sollte auf [localhost:4321](http://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:

```bash
npx astro add cloudflare --yes
```

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

```javascript {6,8-11}
// File: 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:

```jsonc {4-17}
// File: 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:

```typescript
// File: 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:

```typescript collapse={0,66-255}
// File: 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)
      }
    })
  }

  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:

```typescript
// File: 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:

```astro collapse={0,20-472}
---
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;
      }
      .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:

```typescript
// 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:

```bash
# 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](https://twitter.com/direct_messages/create/rishi_raj_jain_) kontaktieren.
