CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
WebSocket /ws/collab/{vault}/{path} (rooms par fichier), relais Yjs/CRDT, awareness (curseurs colores + presence), persistance serveur debounce 2s, auth WS + check_vault_access, reconnexion automatique. Frontend frontend/js/collab.js, backend backend/collab.py. Tests: 17 backend (5 clients simultanes) + 10 frontend. Docs: CHANGELOG, ROADMAP, fiche features/collaboration.md, README FR/EN.
7.4 KiB
7.4 KiB
#62 - Collaboration temps réel — Édition simultanée
Statut : ✅ Terminé — 100 % implémenté + 17 tests backend + 10 tests frontend (2026-09-11) Effort : 5-7 jours (réalisé) | Impact : 🟢 Références : Roadmap · Changelog
-
Fichiers clés :
backend/collab.py—CollabManager,CollabRoom,CollabClient, auth WS, persistancebackend/main.py— endpoint@app.websocket("/ws/collab/{vault_name}/{path:path}"), hooklifespanfrontend/js/collab.js— provider WebSocket, liaisonY.Text↔ CodeMirror, curseurs distants, présencefrontend/js/utils.js— démarrage/arrêt de la session à l'ouverture/fermeture de l'éditeurfrontend/index.html— import mapyjs,window.CodeMirror(Decoration/ViewPlugin/WidgetType/StateEffect), conteneur#collab-presencefrontend/style.css— styles présence + curseurs distantstests/test_collab.py— 17 tests (manager, auth, ACL, persistance, 5 clients simultanés)tests/frontend/collab.test.mjs— 10 tests (diff de texte, URL, avatars)
-
Description : plusieurs utilisateurs peuvent éditer le même document markdown en même temps, comme Google Docs. Chaque personne voit en temps réel les modifications des autres, avec leur curseur affiché en couleur et un indicateur de présence dans la barre d'outils de l'éditeur.
Architecture
Navigateur A ──┐
Navigateur B ──┼── WebSocket /ws/collab/{vault}/{path} ──► CollabManager (room par fichier)
Navigateur C ──┘ │
├── relais des updates Yjs (opaque)
├── relais de l'awareness (curseurs)
└── persistance différée (debounce 2 s)
Backend — backend/collab.py
CollabManager(singletoncollab_manager) :connect()enregistre la connexion dans la roomvault::path, envoie le messageinit(journal des updates,seedpour le premier client, pairs, awareness) et boucle surreceive_text().disconnect()retire le client, diffusepeer_left, et flush le fichier si la room devient vide (puis supprime la room).stop()annule les tâches et flush toutes les rooms (arrêt de l'application)._on_message()traitesync/update(Yjs),awareness,text(persistance),ping._broadcast()diffuse aux autres clients et purge les connexions mortes._schedule_save()/_debounced_save()/_flush(): écriture disque après 2 s d'inactivité (asyncio.to_threadpour ne pas bloquer l'event loop).
authenticate_websocket(): les dépendances FastAPIDependsne s'exécutent pas sur les routes WebSocket. Le JWT est donc lu manuellement depuis le cookieaccess_token(envoyé automatiquement par le navigateur same-origin) ou, en repli, depuis?token=. SiOBSIGATE_AUTH_ENABLED=false, un utilisateur anonyme admin est retourné (comme les routes REST).- Sécurité par connexion :
check_vault_access()puisresolve_safe_path()(anti path traversal) avant tout accès disque.
Protocole WebSocket
Client → serveur :
| Message | Contenu | Rôle |
|---|---|---|
update |
{update: <base64 Yjs>} |
mise à jour incrémentale |
sync |
{update: <base64 Yjs>} |
état complet (à la connexion / reconnexion) |
awareness |
{clientId, state} |
curseur + nom + couleur |
text |
{text} |
snapshot markdown pour la persistance |
ping |
{} |
keepalive |
Serveur → client :
| Message | Contenu | Rôle |
|---|---|---|
init |
{connId, color, seed, updates[], peers[], awareness[]} |
état initial de la room |
update |
{update, from} |
mise à jour d'un autre client |
awareness |
{clientId, state, from} |
curseur d'un autre client |
peer_joined / peer_left |
{peer, clientId} |
présence |
pong |
{t} |
réponse keepalive |
Frontend — frontend/js/collab.js
- Yjs chargé dynamiquement (
import('yjs'), résolu par l'import map deindex.html) : aucune dépendance statique externe, ce qui rend les fonctions pures testables en Node/JSDOM. - Liaison
Y.Text↔ CodeMirror : unEditorView.updateListenerapplique les changements locaux auY.Text(ydoc.transact, originlocal) ; un observateurY.Textapplique les changements distants à l'éditeur via un diff minimal à remplacement unique (computeTextDiff). Un flagapplyingévite les boucles. - Curseurs distants : extension CodeMirror (
ViewPlugin+Decoration.widget) qui place un caret coloré + étiquette de nom à la positionheadde chaque pair. UnStateEffectforce le rafraîchissement quand l'awareness change. - Présence :
#collab-presenceaffiche un point de statut (connecté / connexion / interrompu), les avatars colorés et le nombre de personnes. - Reconnexion : backoff exponentiel 1 s → 30 s, puis
init+ renvoi de l'état complet (sync) et de l'awareness → merge CRDT au retour. - Persistance : le texte est envoyé au serveur 300 ms après la dernière modification ; le serveur écrit le fichier après 2 s d'inactivité.
Sous-tâches (ROADMAP #62)
- Serveur WebSocket : endpoint
/ws/collab/{vault}/{path}avec gestion des rooms - Intégration Yjs :
Y.Docpartagé,Y.Textpour le contenu markdown - Awareness : curseurs colorés par utilisateur, sélections visibles
- Synchro backend : persistance périodique du document (debounce 2 s)
- Gestion des droits : vérification
check_vault_accesspar connexion WS - UI : indicateur de présence (avatars dans la barre d'outils éditeur)
- UI : curseurs distants dans CodeMirror (extension collaborative)
- Gestion des déconnexions : reconnexion automatique, merge state au retour
- Tests de charge : 5+ utilisateurs simultanés sur le même fichier
Sécurité
- Authentification obligatoire si
OBSIGATE_AUTH_ENABLED=true(cookie ou?token=). - Vérification
check_vault_access()par connexion (un utilisateur ne peut pas rejoindre une room d'une vault non autorisée). resolve_safe_path()empêche toute traversée de chemin (../../).- Bornes anti-abus :
MAX_UPDATE_BYTES(8 Mo) par mise à jour,MAX_TEXT_CHARS(8 Mio) par snapshot. - Le serveur ne décode pas le binaire Yjs : il le stocke et le relaie tel quel (pas de surface d'attaque supplémentaire côté parsing).
Limites connues
- Le journal des mises à jour Yjs est conservé en mémoire tant qu'au moins un client est connecté. Quand la room devient vide, le document est persisté sur disque et la room est supprimée : au redémarrage du serveur, l'état CRDT est réinitialisé à partir du contenu du fichier.
- L'éditeur « fallback » (textarea, quand CodeMirror ne charge pas) n'est pas collaboratif : la
session n'est démarrée que si
state.editorViewexiste. - Yjs est chargé depuis
esm.sh(comme CodeMirror) : une connexion Internet est requise au premier chargement, sauf mise en cache par le service worker.
Vérifications
.\.venv\Scripts\python.exe -m pytest tests/test_collab.py -q
.\.venv\Scripts\python.exe -m ruff check backend/
.\.venv\Scripts\python.exe -m mypy backend/ --ignore-missing-imports
node tests/frontend/collab.test.mjs
node tests/frontend/validate-imports.mjs