L’une des questions les plus fréquentes que nous recevons depuis l’annonce de Fatplant 2.0 est : comment l’édition collaborative fonctionne-t-elle vraiment ? Cet article est une plongée technique dans les coulisses de cette fonctionnalité.
Le problème de la concurrence
Imaginez deux journalistes qui modifient simultanément le même paragraphe. L’approche naïve — “le dernier qui sauvegarde gagne” — est inacceptable en contexte éditorial. Elle provoque des pertes de travail, des frustrations et des erreurs dans le contenu publié.
Les approches traditionnelles (verrouillage du document, système de checkout/checkin) évitent le problème mais nuisent à la collaboration : un seul utilisateur peut travailler à la fois.
La bonne solution est une structure de données qui peut être modifiée simultanément par plusieurs acteurs, dont les modifications peuvent toujours être fusionnées correctement. C’est exactement ce que les CRDT permettent.
Qu’est-ce qu’un CRDT ?
Un CRDT (Conflict-free Replicated Data Type) est une famille de structures de données dont la propriété fondamentale est la suivante : toute modification locale peut être appliquée immédiatement, et la fusion de deux états CRDT est toujours commutative, associative et idempotente.
En termes simples : peu importe l’ordre dans lequel les modifications arrivent sur un nœud, le résultat final sera toujours le même.
Yjs est une bibliothèque JavaScript qui implémente des CRDT pour les types de données courants : texte, tableaux, maps, arbres imbriqués (XML). C’est ce dernier type — le document XML CRDT — qui nous intéresse pour le contenu du page builder de Fatplant.
Architecture du système
Navigateur A ──WebSocket──┐
Navigateur B ──WebSocket──┤── Serveur Yjs ──HTTP──> Backend Symfony
Navigateur C ──WebSocket──┘ │
└── Persistance périodique Le serveur Yjs maintient en mémoire des rooms (salles). Chaque document (article, page) a sa propre room identifiée par une clé unique (article:42, page:7).
Quand un utilisateur ouvre un document dans l’administration :
- Son navigateur se connecte à la room correspondante via WebSocket.
- Le serveur lui envoie l’état courant du document Yjs (un vecteur d’états encodé en binaire).
- Toutes ses modifications locales sont encodées en update Yjs et envoyées au serveur.
- Le serveur relaie cet update à tous les autres clients de la room.
- Chaque client applique l’update à son propre document Yjs local — le merge est automatique et sans conflit.
La présence
En parallèle des données de document, le serveur Yjs gère la présence : qui est connecté, où se trouve son curseur.
Chaque client émet périodiquement un message de présence contenant :
{
"user": { "name": "Claire Maublanc", "color": "#e11d48" },
"cursor": { "sectionId": "sec-1", "moduleId": "mod-3" }
} Ces données sont éphémères — elles ne sont jamais persistées. Quand un utilisateur se déconnecte, sa présence disparaît automatiquement de l’interface des autres.
Autosave et persistance
Yjs gère la synchronisation entre navigateurs, mais le contenu doit aussi être persisté dans la base de données. Deux mécanismes complémentaires s’en chargent :
Autosave local : l’administration SvelteKit écoute les changements du document Yjs et déclenche un appel API vers le backend toutes les 3 secondes si des modifications ont eu lieu. C’est simple et robuste.
Flush Yjs : quand une room devient vide (tous les utilisateurs se sont déconnectés), le serveur Yjs persiste l’état complet du document dans le backend avant d’évacuer la room de la mémoire. Cela garantit qu’aucune modification récente n’est perdue même si l’autosave n’a pas encore eu le temps de s’exécuter.
Cas de déconnexion
Si un utilisateur perd sa connexion réseau, Yjs continue de fonctionner localement. Les modifications sont accumulées dans une file d’attente locale. Quand la connexion est rétablie, le client se reconnecte à la room et envoie tous les updates accumulés. Le serveur les applique dans l’ordre et les relaie aux autres clients.
Du point de vue de l’utilisateur : il peut continuer à travailler pendant une coupure brève et ses modifications seront synchronisées automatiquement à la reconnexion.
Ce que nous avons appris
Mettre en place ce système n’a pas été sans défis. Le principal écueil a été la gestion de la mémoire du serveur Yjs : sans éviction des rooms inactives, la mémoire du processus augmentait indéfiniment au fil des articles ouverts. Nous avons implémenté un mécanisme d’éviction avec hook onEmpty qui déclenche un flush avant de libérer la room.
L’autre apprentissage majeur : tester la robustesse aux déconnexions brutales. Les connexions WebSocket peuvent se fermer sans signal propre (coupure réseau, mise en veille de l’ordinateur). Le protocole de reconnexion doit être pensé pour ces cas.
Nous documenterons ces détails dans un futur article dédié à l’opération du serveur Yjs.