feat: collaboration temps reel - edition simultanee (#62)
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.
This commit is contained in:
2026-09-11 23:27:22 -04:00
parent 3f7b7847a5
commit 063b02e996
19 changed files with 1741 additions and 33 deletions
+142
View File
@@ -0,0 +1,142 @@
# #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](../ROADMAP.md) · [Changelog](../../CHANGELOG.md)
- **Fichiers clés :**
- `backend/collab.py` — `CollabManager`, `CollabRoom`, `CollabClient`, auth WS, persistance
- `backend/main.py` — endpoint `@app.websocket("/ws/collab/{vault_name}/{path:path}")`, hook `lifespan`
- `frontend/js/collab.js` — provider WebSocket, liaison `Y.Text` ↔ CodeMirror, curseurs distants, présence
- `frontend/js/utils.js` — démarrage/arrêt de la session à l'ouverture/fermeture de l'éditeur
- `frontend/index.html` — import map `yjs`, `window.CodeMirror` (Decoration/ViewPlugin/WidgetType/StateEffect), conteneur `#collab-presence`
- `frontend/style.css` — styles présence + curseurs distants
- `tests/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`** (singleton `collab_manager`) :
- `connect()` enregistre la connexion dans la room `vault::path`, envoie le message `init`
(journal des updates, `seed` pour le premier client, pairs, awareness) et boucle sur
`receive_text()`.
- `disconnect()` retire le client, diffuse `peer_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()` traite `sync`/`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_thread` pour ne pas bloquer l'event loop).
- **`authenticate_websocket()`** : les dépendances FastAPI `Depends` ne s'exécutent pas sur les
routes WebSocket. Le JWT est donc lu manuellement depuis le cookie `access_token` (envoyé
automatiquement par le navigateur same-origin) ou, en repli, depuis `?token=`. Si
`OBSIGATE_AUTH_ENABLED=false`, un utilisateur anonyme admin est retourné (comme les routes REST).
- **Sécurité par connexion** : `check_vault_access()` puis `resolve_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 de `index.html`) : aucune
dépendance statique externe, ce qui rend les fonctions pures testables en Node/JSDOM.
- **Liaison `Y.Text` ↔ CodeMirror** : un `EditorView.updateListener` applique les changements locaux
au `Y.Text` (`ydoc.transact`, origin `local`) ; un observateur `Y.Text` applique les changements
distants à l'éditeur via un diff minimal à remplacement unique (`computeTextDiff`). Un flag
`applying` évite les boucles.
- **Curseurs distants** : extension CodeMirror (`ViewPlugin` + `Decoration.widget`) qui place un
caret coloré + étiquette de nom à la position `head` de chaque pair. Un `StateEffect` force le
rafraîchissement quand l'awareness change.
- **Présence** : `#collab-presence` affiche 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)
- [x] Serveur WebSocket : endpoint `/ws/collab/{vault}/{path}` avec gestion des rooms
- [x] Intégration Yjs : `Y.Doc` partagé, `Y.Text` pour le contenu markdown
- [x] Awareness : curseurs colorés par utilisateur, sélections visibles
- [x] Synchro backend : persistance périodique du document (debounce 2 s)
- [x] Gestion des droits : vérification `check_vault_access` par connexion WS
- [x] UI : indicateur de présence (avatars dans la barre d'outils éditeur)
- [x] UI : curseurs distants dans CodeMirror (extension collaborative)
- [x] Gestion des déconnexions : reconnexion automatique, merge state au retour
- [x] 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.editorView` existe.
- 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
```powershell
.\.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
```