Files
werkbaum/backend/docs/client-live-editing-instructions.md
T
mhoennigandClaude Opus 5 af8a7b74b3 docs: Feed nur im sichtbaren Tab (D76-Nachtrag 1)
Der Zielserver bietet kein HTTP/2, also gilt das Browser-Limit von sechs
Verbindungen je Herkunft - ein dauerhaft offener Long-Poll je Tab engt bei
mehreren Tabs alles andere ein. Der Feed schliesst deshalb bei
visibilitychange und holt beim Zurueckkommen mit dem eigenen since nach.
Ein Hintergrund-Tab braucht keinen Live-Feed; das spart nebenbei
Server-Worker und Akku. Ein SharedWorker waere sauberer, ist aber eine eigene
Baustelle fuer ein Problem, das die einfache Loesung praktisch beseitigt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:30:03 +02:00

17 KiB

Aufgabe: Live-Editing-Client für den Werkbaum-Editor implementieren

Du arbeitest im Werkbaum-Monorepo, Teilprojekt frontend/. Implementiere die Client-Seite des Live-Editing-Protokolls gegen das Editor-Backend (backend/). Das Protokoll ist HTTP-only (kein WebSocket): Änderungen werden als zeilenbasierte Diffs per PATCH eingereicht, andere Clients erhalten sie über einen Long-Polling-Feed.

Wichtiger Kontext: Die Live-Editing-Endpunkte sind spezifiziert (backend/docs/live-editing-proposal.md, Entscheidungen in docs/DECISIONS.md D76), aber noch nicht implementiert. Baue gegen den hier definierten Vertrag; die CRUD-Endpunkte existieren bereits.

0. Randbedingungen des Projekts — bitte zuerst lesen

Diese Punkte sind nicht verhandelbar und bestimmen jede Implementierungsentscheidung unten:

  • Keine Laufzeit-Abhängigkeiten. frontend/package.json hat ein leeres dependencies; alles unter devDependencies (Vite, Vitest, jsdom) verschwindet beim Bauen. D11/D19/D20 und CLAUDE.md: neue Laufzeit-Abhängigkeiten nur nach Rückfrage. Also kein jsdiff, kein CodeMirror, kein MSW — Diff und Test-Doubles werden selbst gebaut.
  • Vanilla JavaScript, keine TypeScript-Dateien. Module liegen als frontend/src/*.js (siehe parser.js, model.js, render.js, snapshots.js, autocomplete.js).
  • Der Editor ist eine rohe <textarea wrap="off"> (index.html, D49). Ein Editor-Framework ist bewusst nicht im Spiel (D76: CodeMirror wäre eine eigene Entscheidung mit eigenem Nutzen, nicht die Nebenwirkung dieses Features). Cursor-Erhalt beim Einspielen fremder Änderungen ist deshalb deine Aufgabe, nicht die eines Frameworks.
  • Testbare Logik gehört in ein eigenes Modul. Die Hausregel aus D54-Nachtrag 3: Was entscheidbar ist, wandert headless nach src/*.js und bekommt Vitest-Tests; in app.js bleibt nur die Verdrahtung mit DOM und Netz.
  • Deutsch ist die Quellsprache für neue UI-Texte (I18N in frontend/index.html), danach in alle neun Sprachen übersetzen.

1. Backend-Vertrag

Basis-URL: konfigurierbar, Pfad-Präfix /api/v1. Alle Bodies sind JSON. Fehler kommen als RFC-9457 application/problem+json.

1.1 Bestehende Endpunkte

  • GET /documents/{uuid}{ id, title, content, version, createdAt, updatedAt }
    • content: das komplette Werkbaum-Dokument als ein String, LF-getrennt.
    • version: Long, wird serverseitig bei jeder Änderung inkrementiert.
  • GET /documents/{uuid}/history → Meilensteine (siehe §7).
  • POST /documents/{uuid}/restore → gelöschtes Dokument wiederherstellen.

GET /documents (Liste) verlangt ein Master-Passwort und ist für den Editor nicht vorgesehen — er kennt seine Dokumente über ihre URL (§6).

1.2 PATCH /documents/{uuid}/content — Änderung einreichen

Request:

{
  "baseVersion": 41,
  "checksum": "sha256:9f2b…",
  "clientId": "c-8a41…",
  "seq": 17,
  "ops": [
    { "op": "replace", "index": 12, "count": 1, "lines": ["  - [~] Backend (L) @ben"] },
    { "op": "insert",  "index": 20, "lines": ["  + [?] Dark mode (S)"] },
    { "op": "delete",  "index": 25, "count": 2 }
  ]
}
  • checksum ist Pflicht: Hash des vollständigen Basistexts. Er fängt Client-Bugs und abweichende Zeilenenden, bevor Ops an falschen Indizes landen.
  • clientId ist eine zufällige, im localStorage gehaltene Kennung; seq eine je Client aufsteigende Nummer. Beides zusammen macht den Request wiederholbar (§3.5).

Antworten:

Status Bedeutung Body
200 akzeptiert { "version": 42, "opsSinceBase": [ … ] }
409 echte Überlappung { "currentVersion": 43, "opsSinceBase": [ … ] }
404 Dokument gelöscht problem+json
422 Diff nicht anwendbar oder Prüfsumme falsch problem+json

Der Server rebased selbst. War baseVersion veraltet, überschneiden sich die Ops aber nicht mit den zwischenzeitlichen, verschiebt der Server sie und antwortet mit 200; opsSinceBase enthält dann die fremden Ops, damit du deine Schattenkopie nachziehst. Ein 409 kommt nur bei echter Überlappung derselben Zeilen — nur dafür brauchst du den Konfliktdialog (§4).

Nach 200: serverVersion = version, dann erst opsSinceBase, dann die eigenen Ops auf die Schattenkopie anwenden. Nach 422: Dokument einmalig komplett neu laden (GET) und Zustand ersetzen — das ist neben dem Volltext-Feed (§1.3) der einzige Vollreload-Pfad.

Titel: PATCH /documents/{uuid}/title mit { title, expectedVersion }. Der Titel ist ein Metadatum und läuft nicht durch das Zeilen-Diff.

1.3 GET /documents/{uuid}/changes?since={version}&wait=25 — Feed

  • 200 mit ops: { "fromVersion": 41, "currentVersion": 43, "ops": [ … ], "events": [ … ] }
    • ops ist das kumulierte Diff fromVersion → currentVersion.
  • 200 mit content: { "fromVersion": null, "currentVersion": 87, "content": "…", "events": [ … ] }
    • Kommt, wenn since bereits verdichtet ist (langes Offline, alter Tab). Ersetze den Stand vollständig. Kein Fehler, kein Sonderzustand.
  • 204: Timeout ohne Änderungen → sofort erneut pollen.
  • Netzwerkfehler/Timeout des Browsers: mit Exponential Backoff (1 s, 2 s, 4 s … max 30 s) erneut versuchen; bei Erfolg Backoff zurücksetzen.

events trägt je Eintrag { version, changeType, clientId, displayName }, changeTypeUPDATED | DELETED | RESTORED | ROLLED_BACK | RENAMED:

  • DELETED: Editieren sperren, Banner „Dokument wurde gelöscht" mit Restore-Knopf (POST /restore).
  • RESTORED: Ein gelöschtes Dokument ist wieder da — Sperre aufheben.
  • ROLLED_BACK: Ein lebendes Dokument wurde auf eine alte Version zurückgesetzt. Es gab keine Sperre; behandle es wie ein gewöhnliches Inhaltsupdate. (Die beiden Fälle trugen früher denselben Typ — deshalb die Trennung.)
  • RENAMED: Das Ereignis führt den neuen Titel mit; übernimm ihn in den Dokumentnamen.

2. Diff-Format: exakte Semantik

Das Dokument ist eine Liste von Zeilen: content.split("\n"). Alle Indizes sind 0-basiert und beziehen sich auf die Basisversion (nicht auf Zwischenstände!). Ops sind nach index aufsteigend sortiert und überlappen nicht.

  • replace: ersetzt count Zeilen ab index durch lines (lines.length darf von count abweichen).
  • insert: fügt lines vor index ein; index == zeilen.length bedeutet anhängen.
  • delete: entfernt count Zeilen ab index.

Anwenden: applyOps(lines, ops) als pure Funktion, rückwärts iterierend (höchster Index zuerst) — dann bleiben die Basis-Indizes gültig und die Korrektheit ist leicht zu sehen.

Erzeugen: computeOps(before, after) selbst implementieren — ein zeilenbasiertes Diff über die längste gemeinsame Teilfolge, rund hundert Zeilen (dieselbe Größenordnung wie der Zeilenumbruch aus D64). Zeilen für den Vergleich als Ganzes behandeln; aufeinanderfolgende delete+insert am selben Index zu replace zusammenfassen. Keine Bibliothek (§0).

Überlappung (für den Konfliktfall, §4): Der betroffene Bereich ist [index, index+count) für replace und delete, für insert ein Punkt bei index. Daraus folgt — und das ist Absicht:

  • Zwei Einfügungen an derselben Stelle sind kein Konflikt; beide Zeilen bleiben, die bereits bestätigte fremde steht oben.
  • Eine Einfügung in einen Bereich, den ein anderer löscht, ist einer.

Invarianten (als Tests absichern):

  • applyOps(before, computeOps(before, after)) ≡ after — Property-Test mit zufälligen Zeilen-Arrays, unbedingt mit Duplikaten und Leerzeilen.
  • Leeres Diff (ops: []) wird gar nicht erst gesendet.

Zeilenenden: beim Laden und vor jedem computeOps normalisieren: content.replace(/\r\n?/g, "\n"). Der Server normalisiert beim Speichern ebenfalls auf LF — nur so hashen beide Seiten denselben Text (Prüfsumme!). Kein trailing-newline-Sonderfall: split("\n") auf beiden Seiten konsistent.


3. Sync-Engine (Kernmodul)

Lege ein UI-unabhängiges Modul frontend/src/sync.js an mit diesem Zustand:

// {
//   documentId,          // UUID
//   serverVersion,       // letzte bestätigte Server-Version
//   serverLines,         // Stand der Server-Version (Schattenkopie)
//   localLines,          // aktueller Editor-Inhalt
//   pending,             // gerade unterwegs befindlicher Patch (oder null)
//   seq,                 // laufende Nummer des nächsten Patches
//   status,              // 'idle' | 'sending' | 'conflict' | 'deleted' | 'offline'
// }

Die reinen Funktionen (Diff, Überlappung, Verschiebung, Cursor-Korrektur) gehören nach frontend/src/diff.js und bekommen Vitest-Tests; sync.js hält den Ablauf, app.js nur die Verdrahtung.

  1. Init: GET /documents/{id}serverVersion, serverLines, localLines initialisieren; Feed-Schleife starten.
  2. Lokale Eingabe: Der Editor schreibt nur localLines. Ein Debounce von 1500 ms nach dem letzten Tastendruck, zusätzlich sofort bei Blur und visibilitychange, triggert flush(). Der Wert bleibt bewusst kurz: Die Rate-Limit-Disziplin galt einem fremden Etherpad-Server, am eigenen Backend gelten eigene Grenzen (D76).
  3. flush(): wenn kein pending und localLines ≠ serverLines: ops = computeOps(serverLines, localLines), Prüfsumme über serverLines.join("\n") bilden, PATCH senden, pending = ops, status = 'sending'.
    • 200serverVersion = antwort.version; erst serverLines = applyOps(serverLines, antwort.opsSinceBase) (falls vorhanden), dann applyOps(…, pending); pending = null, seq++. Hat sich localLines inzwischen weiter geändert: erneut flushen.
    • 409 → Konflikt (§4).
    • 422 → einmalig neu laden.
  4. Feed-Ereignis: Remote-Ops einarbeiten (§5). Während status === 'sending' nicht anwenden, sondern puffern — sonst entsteht ein Race zwischen eigener und fremder Änderung.
  5. Die gepufferte Antwort nur anwenden, wenn ihr fromVersion zur aktuellen serverVersion passt, sonst verwerfen. Ohne diese Prüfung wendest du dieselben Ops doppelt an: Feed und die Antwort auf deinen PATCH können dasselbe fremde Diff enthalten.
  6. Wiederholung nach Netzwerkfehler: Denselben Patch mit derselben seq erneut senden. Der Server erkennt ihn und wendet ihn nicht zweimal an. seq erst nach einer bestätigten Antwort erhöhen — genau darin liegt der Schutz.

Nur eine Feed-Anfrage gleichzeitig; AbortController benutzen und beim Dokumentwechsel/Unmount abbrechen.

Der Feed läuft nur im sichtbaren Tab. Bei visibilitychange auf verborgen die Verbindung schließen, beim Zurückkommen einmal mit dem eigenen since nachholen und weiterpollen. Grund: Der Zielserver bietet kein HTTP/2 (gemessen), also gilt das Browser-Limit von sechs Verbindungen je Herkunft — ein dauerhaft offener Long-Poll je Tab engt bei mehreren Tabs alles andere ein. Ein Tab im Hintergrund braucht ohnehin keinen Live-Feed: Niemand schaut hin, und beim Sichtbarwerden holt ein einziger Request den ganzen Rückstand. Spart nebenbei Server-Worker und Akku. Der Editor hört für die Höhenmessung schon auf visibilitychange (D17-Nachtrag 4) — die Stelle gibt es also. status = 'offline' gilt, sobald ein Request am Netz scheitert, und endet mit der nächsten erfolgreichen Antwort; lokale Änderungen bleiben dabei erhalten und werden danach normal geflusht.

4. Konflikt nach 409

Ein 409 heißt: Eure Änderungen betreffen dieselben Zeilen. Alles andere hat der Server bereits rebased.

  1. serverLines = applyOps(serverLines, opsSinceBase), serverVersion = currentVersion.
  2. status = 'conflict', Editieren weiter erlauben, aber nicht senden.
  3. Dialog mit genau zwei Möglichkeiten, jeweils nur für die überlappenden Zeilen — alles Übrige ist bereits sauber übernommen:
    • Fremde Fassung übernehmen: die eigenen überlappenden Ops verwerfen.
    • Eigene Fassung durchsetzen: die überlappenden Zeilen des neuen Serverstands durch die eigenen ersetzen und als neuen Patch senden. Dort gewinnt einer vollständig — das ist bewusst so, und nichts geht endgültig verloren: Jede Version steht in der Server-Historie.
  4. Keine automatische Zusammenführung bei Überlappung, keine Konfliktmarker im Text.

5. Remote-Ops anwenden ohne Cursor-Verlust

Der Editor ist eine <textarea> — die Umrechnung ist deine Aufgabe, und sie gehört als pure Funktion nach diff.js:

  • Cursor (selectionStart/selectionEnd) in (zeile, spalte) umrechnen.
  • Pro Op: Bereich vollständig unterhalb der Cursor-Zeile → nichts; vollständig oberhalb → Cursor-Zeile um das Zeilendelta der Op verschieben; Op trifft die Cursor-Zeile → Zeile beibehalten (auf die neue Zeilenanzahl klemmen), Spalte auf die neue Zeilenlänge klemmen.
  • Zurückrechnen, setSelectionRange setzen; Scrollposition vorher sichern und wiederherstellen (auch scrollLeft — das Textfeld bricht nicht um, D49).
  • Der Zeilennummern-Streifen und der Spiegel (D33) müssen danach neu gemessen werden, ebenso das Diagramm.
  • IME: Während einer aktiven Composition (compositionstart bis compositionend) keine Remote-Ops schreiben — puffern und danach anwenden.
  • Niemals den Gesamttext neu setzen. Das ist genau der verbotene „Cursor springt an den Anfang"-Fall.

Faltmarken sind gewöhnlicher Text. Klappt jemand im Diagramm um, schreibt Werkbaum >/< in den Text zurück (D38-Nachtrag 2) — das ist eine ganz normale lokale Änderung und geht als PATCH hinaus wie jede andere. Das ist entschieden (D76) und braucht keine Sonderbehandlung, auch nicht für den Falt-Durchschalter (D75).

6. Einbettung in den Editor

  • Adressierung über einen URL-Parameter, im Muster von ?sourceUrl= (D23) und ?etherpad= (D31): Die Dokument-Identität leitet sich aus der URL ab, derselbe Link führt immer in dasselbe Dokument, der Name ist die URL (vollständig im Tooltip). Ein Menüeintrag „Auf den Server legen" legt ein lokales Dokument neu an — analog zu „Als Datei speichern" (D72).
  • Der Verlaufs-Knopf zeigt bei Server-Dokumenten die Server-Meilensteine statt der lokalen Stände (D54): gleiche Bedienung, bessere Quelle. Lokale Stände wären hier irreführend, weil „mein Stand von vorhin" fremde Änderungen enthält, die man nie gesehen hat.
  • Textfeld nicht schreibgeschützt (anders als bei Pad-Dokumenten, D31) — hier ist unser Textfeld die Schreibfläche.

7. Was NICHT tun

  • Kein WebSocket, kein SSE, kein setInterval-Kurztakt-Polling — nur die Long-Poll-Schleife.
  • Keine neue Laufzeit-Abhängigkeit (§0) — insbesondere kein jsdiff und kein Editor-Framework.
  • Kein Vollreload außer bei 422 und beim Volltext-Feed.
  • Keine Ops auf Zwischenständen: computeOps immer gegen serverLines der bestätigten serverVersion.
  • Keine Auto-Merges bei überlappenden Änderungen — der Nutzer entscheidet.
  • seq nicht erhöhen, bevor eine Antwort da ist.

8. Tests (mindestens)

Unit (pure Funktionen, kein DOM, Vitest wie die übrigen Module):

  • applyOps: jede Op-Art; Anhängen; letzte Zeile löschen; leeres Dokument; mehrere Ops in einem Diff; Property-Test Roundtrip mit computeOps.
  • Überlappungsprüfung: insert gegen insert am selben Index (kein Konflikt), insert in einen delete-Bereich (Konflikt), Ränder.
  • Index-Verschiebung mit Tabellenfällen.
  • Cursor-Korrektur: Änderung oberhalb / unterhalb / auf der Cursor-Zeile; „300-Zeilen-Dokument, Edit in Zeile 5, Cursor in Zeile 200 bleibt inhaltlich an derselben Stelle".
  • Prüfsummenbildung inklusive Zeilenenden-Normalisierung.

Integration (mit selbstgebauten fetch-Doubles, kein MSW):

  • Feed liefert Ops → Editorinhalt aktualisiert, Cursor stabil.
  • PATCH mit veralteter Basis ohne Überlappung → 200 mit opsSinceBase → Schattenkopie stimmt danach mit dem Server überein.
  • PATCH → 409 mit überlappenden Ops → Konfliktdialog erscheint, beide Wege führen zu einem konsistenten Zustand.
  • Derselbe Patch zweimal gesendet (simulierter Antwortverlust) → Dokument ändert sich nur einmal.
  • Feed liefert content statt ops → Stand wird ersetzt.
  • Feed meldet DELETED → gesperrt; RESTORED → entsperrt; ROLLED_BACK → Inhalt gewechselt, keine Sperre; RENAMED → Name übernommen.
  • Gepufferte Feed-Antwort mit veraltetem fromVersion → verworfen, keine doppelte Anwendung.
  • Long-Poll 204 → nahtloses Re-Polling; Netzwerkfehler → Backoff.

9. Reihenfolge

  1. frontend/src/diff.js: applyOps, computeOps, Überlappungs- und Verschiebungs-Helfer, Cursor-Korrektur + Unit-Tests
  2. frontend/src/sync.js: Zustand, flush, Feed-Schleife, Idempotenz + Tests gegen fetch-Doubles
  3. Editor-Anbindung (Anwenden ohne Cursor-Verlust, Zeilennummern und Diagramm nachziehen, IME)
  4. Einbettung: URL-Parameter, „Auf den Server legen", Verlauf vom Server
  5. UI: Konfliktdialog, Deleted-Banner, Offline-Anzeige — Texte zuerst auf Deutsch, dann in alle neun Sprachen