collaborative-text-editor

Par rivet-dev · skills

Construisez un backend d'éditeur de texte collaboratif avec les CRDT Yjs et Rivet Actors : des actors par document relaient les mises à jour de synchronisation et de conscience (awareness) et persistent les snapshots.

npx skills add https://github.com/rivet-dev/skills --skill collaborative-text-editor

Éditeur de texte collaboratif

IMPORTANT : Avant de faire quoi que ce soit, vous DEVEZ lire BASE_SKILL.md dans le répertoire de cette skill. Il contient des conseils essentiels sur le débogage, la gestion des erreurs, la gestion d'état, le déploiement et la configuration du projet. Ces règles et modèles s'appliquent à tout le travail RivetKit. Tout ce qui suit suppose que vous l'ayez déjà lu et compris.

Exemples fonctionnels

Si vous avez besoin d'une implémentation de référence, lisez le code d'exemple de travail brut dans ces modèles :

Modèles de construction d'un serveur Yjs sur RivetKit : synchronisation de document CRDT, présence et curseurs, et persistance d'instantané, avec un Actor Rivet par document agissant comme relais.

Code de démarrage

Commencez par l'exemple de travail sur GitHub et adaptez-le à votre éditeur. Il intègre un frontend React avec une simple zone de texte, des superpositions de curseur distant et un index de document d'espace de travail.

Cas d'usage Code de démarrage Exemples courants
Édition de document partagé GitHub Documents de type Notion, notes partagées, outils d'écriture en pair, co-édition de formulaires

CRDT vs OT

Deux familles d'algorithmes résolvent l'édition de texte concurrente. Le choix détermine ce que votre serveur doit faire.

Dimension CRDT (Yjs) Transformation opérationnelle
Modèle de résolution de conflits Fusions commutatives. Les mises à jour s'appliquent dans n'importe quel ordre sur n'importe quel pair et convergent vers le même résultat. Le serveur transforme chaque opération par rapport à chaque opération concurrente. La correction dépend d'un séquenceur central.
Support hors ligne Fort. Les clients continuent à éditer localement et fusionnent les mises à jour mises en buffer à la reconnexion. Faible. La divergence de longue durée rend les chaînes de transformation complexes et fragiles.
Rôle du serveur Relais plus persistance. Le serveur applique les mises à jour opaques et les rediffuse. Il n'a jamais besoin de comprendre la sémantique du document. Transformateur faisant autorité. Le serveur doit implémenter la logique de transformation pour chaque type d'opération.
Maturité de la bibliothèque Yjs est mature et largement déployé, avec des liaisons pour ProseMirror, CodeMirror, Monaco et autres. Les implémentations de qualité production sont principalement propriétaires (Google Docs) ou vieillissantes (ShareDB).

L'exemple utilise Yjs car les CRDT permettent au serveur de rester un Actor Rivet de style relais. L'actor applique chaque mise à jour entrante à un Y.Doc côté serveur afin qu'il puisse persister l'état fusionné et servir les arrivants tardifs, mais il ne transforme jamais les opérations ni n'arbitre les conflits. L'ordre n'a pas d'importance car les fusions Yjs sont commutatives.

Modèle d'Actor de document

Sujet Résumé
Topologie Un actor document[workspaceId, documentId] par document plus un coordinateur documentList[workspaceId] par espace de travail.
Modèle de synchronisation Chaque client détient un Y.Doc local. L'actor de document relaye les mises à jour Yjs incrémentielles en tant que événements de diffusion et conserve une copie fusionnée côté serveur dans les vars.
Persistance Instantané Yjs complet réécrit dans une clé actor KV binaire (yjs:doc) à chaque mise à jour de synchronisation. Les métadonnées du document se trouvent dans l'état JSON.
Files d'attente Aucune. L'exemple est purement actions plus événements de diffusion.
Présence Awareness Yjs relayé par la même action applyUpdate. Le connState par connexion suit les clientIds d'awareness assertés pour le nettoyage à la déconnexion.

La division en deux actors suit le modèle coordinateur de Design Patterns : le coordinateur possède la découverte et la création, et chaque actor de document possède l'état temps réel d'un document. Les clés multi-parties limitent les deux actors à un espace de travail.

Actors

  • Clé : document[workspaceId, documentId]

  • Responsabilité : Applique les mises à jour de synchronisation et d'awareness entrantes à un Y.Doc et une Awareness côté serveur, persiste l'instantané Yjs fusionné dans actor KV, et diffuse les mises à jour à tous les collaborateurs connectés.

  • Actions

    • getContent
    • applyUpdate
    • getAwareness
  • Files d'attente

    • Aucune
  • État

    • Métadonnées JSON uniquement : title, createdAt, updatedAt
    • Clé KV binaire yjs:doc contenant l'instantané Yjs complet fusionné
    • Vars éphémères : le Y.Doc et Awareness actifs, créés dans createVars et réhydratés à partir de KV au démarrage de l'actor
    • connState par connexion : clientIds des clients d'awareness assertés par cette connexion
  • Clé : documentList[workspaceId]

  • Responsabilité : Coordinateur pour un espace de travail. Crée des actors de document via le client actor-to-actor et maintient l'index des résumés de documents.

  • Actions

    • createDocument
    • listDocuments
    • deleteDocument
  • Files d'attente

    • Aucune
  • État

    • JSON
    • Tableau documents d'entrées DocumentSummary (id, title, createdAt, updatedAt)

La createDocument du coordinateur génère un UUID, puis crée explicitement l'actor de document avec c.client<typeof registry>() et transmet { title, createdAt } en tant qu'entrée de création, que le createState de l'actor de document consomme. Voir Communicating Between Actors pour le client actor-to-actor.

Relais de mise à jour

Une seule action applyUpdate(update, kind, clientId?) gère les deux types de mises à jour. Les mises à jour franchissent la limite d'action en tant que tableaux d'octets number[] et sont reconverties en Uint8Array de chaque côté.

Type Le serveur applique à Persiste Diffuse
"sync" c.vars.doc via Y.applyUpdate avec origine "client" Instantané fusionné complet dans la clé KV yjs:doc, puis augmente updatedAt Événement sync portant la mise à jour incrémentiell
"awareness" c.vars.awareness via applyAwarenessUpdate avec origine "client" Rien. La présence est éphémère. Événement awareness portant la mise à jour

Notez l'asymétrie sur la branche de synchronisation : la diffusion porte uniquement la petite mise à jour incrémentiielle, tandis que l'écriture KV stocke le document complet fusionné ré-encodé avec Y.encodeStateAsUpdate.

Les étiquettes d'origine Yjs sont les gardes d'écho qui maintiennent la boucle de relais libre :

Étiquette d'origine Défini où Effet
"local" Éditions client à l'intérieur de doc.transact(..., "local") L'écouteur de mise à jour du client s'exécute et envoie applyUpdate à l'actor.
"client" Serveur appliquant une mise à jour entrante à son Y.Doc ou Awareness Marque le changement comme originaire du client sur la copie du serveur.
"remote" Client appliquant des événements de diffusion ou des données de synchronisation initiale Les écouteurs de mise à jour reviennent tôt sur "remote", donc un client ne renvoie jamais son propre écho.

À la connexion ou reconnexion, le client appelle getContent et getAwareness, puis applique les deux résultats à son Y.Doc et Awareness locaux avec origine "remote". Après cela, chaque changement passe par applyUpdate et les événements de diffusion.

Awareness et présence

La présence (noms d'utilisateurs, couleurs, positions du curseur) s'appuie sur le protocole Yjs Awareness plutôt que sur l'état des actors :

  • Les clients définissent la présence avec awareness.setLocalStateField pour les champs user et cursor. L'écouteur de mise à jour d'awareness encode la modification et envoie applyUpdate(update, "awareness", awareness.clientID).
  • L'actor enregistre chaque clientId asserté dans le connState.clientIds de cette connexion, applique la mise à jour à l'Awareness côté serveur, et diffuse l'événement awareness à tous les pairs. Voir Connections pour l'état par connexion.
  • onDisconnect lit les clientIds de la connexion, appelle removeAwarenessStates sur l'Awareness côté serveur, et diffuse la suppression encodée afin que chaque client restant abandonne le curseur de l'utilisateur parti. Voir Lifecycle pour le crochet.

Parce que l'actor suit quels clientIds d'awareness appartiennent à quelle connexion, le nettoyage de la présence est automatique à la déconnexion sans coopération client requise.

Persistance et compaction

L'exemple persiste avec une réécriture d'instantané complet : à chaque mise à jour "sync", l'actor ré-encode le document complet fusionné avec Y.encodeStateAsUpdate et réécrit la clé KV binaire unique yjs:doc. Il n'y a pas de log d'mises à jour ajoutables et pas de tâche de compaction séparée. La compaction est implicite car Y.encodeStateAsUpdate émet une représentation fusionnée compacte du document, donc la sémantique de fusion Yjs maintient le blob stocké compact par elle-même.

Propriété Réécriture d'instantané complet (l'exemple)
Coût d'écriture Une écriture KV de document complet par mise à jour de synchronisation, donc chaque frappe réécrit le blob entier.
Coût de lecture Une lecture KV binaire dans createVars réhydrate le document au démarrage de l'actor.
Sécurité après crash Le dernier applyUpdate complété est durable. Aucune relecture de log requise.
Point sweet Petits à moyens documents où la simplicité l'emporte sur l'amplification d'écriture.

Extension recommandée (pas dans l'exemple) : pour les gros documents ou les taux d'édition très élevés, basculez vers l'ajout de mises à jour incrémentielles à un log d'mises à jour KV et la rédaction d'un instantané fusionné uniquement périodiquement (par exemple tous les N mises à jour). Le démarrage devient instantané plus relecture de log, et l'écriture d'instantané devient l'étape de compaction explicite qui tronque le log. Adoptez ceci seulement quand les écritures d'instantané complet deviennent le goulot d'étranglement mesuré ou que le blob s'approche des limites de taille de valeur KV.

Cycle de vie

sequenceDiagram
    participant A as Client A
    participant B as Client B
    participant DL as documentList
    participant D as document

    A->>DL: listDocuments()
    A->>DL: createDocument(title)
    DL->>D: create([workspaceId, documentId], input)
    DL-->>A: DocumentSummary
    A->>D: connect
    B->>D: connect
    Note over D: createVars réhydrate Y.Doc à partir de KV "yjs:doc"
    A->>D: getContent() + getAwareness()
    D-->>A: doc encodé + état awareness
    Note over A: édition locale avec origine "local"
    A->>D: applyUpdate(update, "sync")
    Note over D: appliquer avec origine "client", réecrire instantané KV
    D-->>B: événement sync (mise à jour incrémentiielle)
    Note over B: appliquer avec origine "remote", pas d'écho
    A->>D: applyUpdate(update, "awareness", clientId)
    D-->>B: événement awareness
    B-->>D: disconnect
    Note over D: onDisconnect supprime les clientIds d'awareness de B
    D-->>A: événement awareness (suppression)

Liste de contrôle de sécurité

L'exemple est expédié sans authentification ni autorisation. Renforcez-le avec cette base avant la production. Aucun de ces éléments n'est implémenté dans l'exemple.

  • Authentifier avant la connexion : Quiconque connaît ou devinez un ID d'espace de travail peut se connecter, et parce que useActor crée implicitement getOrCreate, la connexion avec un ID d'espace de travail inexistant crée silencieusement un coordinateur documentList vierge. Ajoutez une authentification de connexion afin que les clients non authentifiés ne rejoignent jamais un actor. Voir Authentication.
  • Contrôle d'accès par document : Validez que l'utilisateur authentifié est autorisé à accéder à la clé [workspaceId, documentId] spécifique, et pas seulement à n'importe quel document.
  • Cap et limite de débit applyUpdate : Les charges utiles de mise à jour sont des tableaux number[] non validés sans limite de taille, et le client exemple envoie une action par frappe et par mouvement de curseur sans limitation. Appliquez des limites de taille de charge utile et des limites de débit par connexion côté serveur, et déthrottlez côté client.
  • Ne faites pas confiance aux clientIds d'awareness assertés par le client : L'argument clientId de applyUpdate est fourni par le client et accepté tel quel. Dérivez ou vérifiez l'identité de présence à partir de l'état serveur limité à la connexion.
  • Détruisez les actors et KV à la suppression : deleteDocument filtre uniquement l'entrée de l'index du coordinateur. L'actor de document et son instantané KV sont orphelins. À la suppression, détruisez aussi l'actor de document et son stockage, avec une vérification d'autorisation sur qui peut supprimer.

Carte de référence

Actors

Agent Os

Clients

Connexion

Cookbook

Général

Auto-hébergement

Skills similaires