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.
143 lines
7.4 KiB
Markdown
143 lines
7.4 KiB
Markdown
# #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
|
|
```
|