Éditer à plusieurs
Le montage complet, dans l'ordre où on l'écrit : un document CRDT, un transport, les curseurs des autres, les commentaires, l'historique — et les deux pièges qui font perdre une soirée.
La démo qui tourne dans le navigateur estÀ plusieurs ; le code source complet des deux modes (deux volets sans serveur, et un vrai salon partagé) est dans examples/collab.
Le modèle, en trois phrases
Le document fusionne tout seul. C'est un CRDT (Loro) : les modifications commutent, donc l'ordre d'arrivée n'a pas d'importance et deux personnes peuvent écrire dans la même phrase. Hors ligne n'est pas un cas particulier — c'est le cas général avec une latence plus longue.
Le transport est une interface à deux méthodes,send et onMessage. Il ne reconnecte pas, ne ré-essaie pas, n'authentifie pas : ces trois choses diffèrent par transport et appartiennent à celui qu'on écrit. Un WebSocket, une boucle locale, un canal WebRTC et un test se ressemblent donc du point de vue du document.
Le contrôle d'accès reste dehors. Un CRDT fusionne ce qu'on lui donne : un pair qui n'aurait pas dû écrire doit être arrêtéavant que ses octets arrivent, pas après.
Le montage complet
Rien n'est omis ici — c'est le fichier qui marche, pas un extrait.
import { Editor, uuidv7 } from '@nbe/core';
import { EditorView } from '@nbe/dom';
import { LoroBlockStore, connect, connectToRelay, redrawOnRemote } from '@nbe/collab';
const room = 'reunion-lancement';
// 1. Le document devient un CRDT. `LoroBlockStore` implémente `BlockStore`,
// l'interface que l'éditeur utilise déjà : rien d'autre ne change.
const store = new LoroBlockStore();
// 2. Le transport. Deux méthodes, `send` et `onMessage` — ici un WebSocket
// vers un relais, mais l'éditeur ne sait pas lequel.
const transport = connectToRelay('ws://localhost:8787', room);
const stopSync = connect(store, transport);
// 3. La racine doit être le MÊME bloc chez tout le monde : on la dérive du
// salon au lieu de la générer (voir « les deux pièges », plus bas).
const rootId = `${room}-root`;
// 4. Laisser arriver le document existant avant de décider que le salon est
// vide, puis créer la page si personne ne l'a fait.
setTimeout(() => {
if (!store.get(rootId)) {
store.set(rootId, {
id: rootId, type: 'page', version: 1,
props: { title: room }, children: [], parentId: null,
});
const first = {
id: uuidv7(), type: 'paragraph', version: 1,
props: {}, text: [], children: [], parentId: rootId,
};
store.set(first.id, first);
store.set(rootId, { ...store.get(rootId)!, children: [first.id] });
}
// 5. À partir d'ici, c'est l'éditeur habituel.
const editor = new Editor({ doc: { blocks: store, rootId } });
const view = new EditorView(document.querySelector('#editeur')!, editor);
// 6. Une édition distante n'entre PAS par cet éditeur : sans ceci, la fusion
// serait correcte et l'écran mentirait.
const stopRedraw = redrawOnRemote(store.doc, () => view.renderAll());
}, 400);Les deux pièges du démarrage
L'identifiant de la racine. Si chaque pair génère le sien, tout se synchronise correctement et chacun regarde une page différente — la forme la plus déroutante de « cassé », parce que rien n'échoue. On le dérive donc du nom du salon, ou on le reçoit de l'hôte ; ce qui compte est qu'il ne soit pas tiré au hasard localement.
Le salon vide. Un relais sans persistance n'a rien à envoyer, donc « le document n'est pas encore arrivé » et « il n'existe pas » se ressemblent pendant un instant. D'où le délai avant de créer la page : le premier arrivé la crée, les suivants la reçoivent. C'est un délai fixe et non une poignée de main — assumé, avec son plafond écrit dans le code : si un lien lent finit par faire clignoter une page vide, il faudra un événement « synchronisé » émis par connect.
Voir les autres
La présence voyage sur le même socket que le document, et n'entrejamais dedans : rien de ce qui suit ne peut finir dans le fichier ni dans l'historique. Elle expire toute seule au bout de trente secondes, donc un onglet fermé brutalement disparaît de lui-même.
import { attachRemoteCarets, peerSelection, type RemoteSelection } from '@nbe/dom';
import { createPresence } from '@nbe/collab';
const me = { id: uuidv7(), name: 'Alice', color: 'rgb(41, 78, 199)' };
const carets = attachRemoteCarets(view); // peint les curseurs et les sélections
const presence = createPresence(transport, { id: me.id }); // même socket, jamais le document
// Ce que les autres reçoivent de nous
const announce = () =>
presence.set({ name: me.name, color: me.color, selection: peerSelection(editor) });
// Ce qu'on fait de ce qu'ils envoient
presence.onChange((peers) =>
carets.update(
Object.entries(peers).map(([id, state]) => ({
id,
name: String(state.name ?? ''),
color: String(state.color ?? ''),
selection: (state.selection ?? null) as RemoteSelection | null,
})),
),
);
editor.on(announce); // le texte a bougé sous le caret
editor.onSelection(() => announce()); // la sélection a bougé
announce(); // et dire bonjour tout de suiteLe piège : écouter selectionchange du DOM. Une sélection qui traverse plusieurs blocs, le navigateur refuse de la tenir — c'est le modèle qui la porte et la CSS Custom Highlight API qui la peint. L'événement du DOM ne part donc jamais pour ce cas, et la sélection du pair semble s'arrêter à la frontière du bloc sur tous les autres écrans. editor.onSelection est celui qui sait.
peerSelection(editor) fait la mise à plat une fois pour toutes et porte les deux formes : une plage de texte avec son bloc de tête, et l'ensemble des blocs quand la personne tient des blocs entiers. Les sélections sont peintes par pair, dans sa couleur, jusqu'à huit simultanées ; au-delà il reste les curseurs, qui sont la partie qui dit où sont les gens.
Repeindre sur une édition distante
Une édition locale passe par l'éditeur, qui prévient sa vue. Une éditiondistante arrive en important des octets directement dans le store : l'éditeur n'en entend jamais parler et la vue continue de peindre le document tel qu'il était. Tout converge correctement et l'écran ment en silence — la pire des deux pannes, parce qu'elle a l'air de marcher. C'est tout le travail de redrawOnRemote, et c'est pour ça qu'il ignore local (déjà à l'écran) et checkout(l'historique qui lit le passé, et qui ne doit pas repeindre le présent).
Le protocole, et pourquoi le relais ne comprend rien
Quatre types de messages, un octet en tête : Have (voici ma version), Update (voici des changements),Presence, Signal (négociation WebRTC). Un pair ouvre en annonçant ce qu'il a, l'autre répond exactement ce qui manque — envoyer un instantané entier serait plus simple et ferait payer chaque reconnexion au prix du document : sur l'échantillon mesuré, 104 octets contre 372, et l'écart grandit avec l'historique.
Le relais, lui, recopie des octets d'un pair à l'autre. Il ne les décode pas, donc il ne peut pas les corrompre, et un type de message qu'il ne connaît pas ne le dérange pas.
nbe relay --port 8787 # un port, aucune base de données
nbe serve --port 8787 # le même, plus un pair permanent qui garde les documents
nbe peer <salon> # un pair WebRTC sans écran, qui écrit sur le disquePair-à-pair, sans TURN
p2pTransport enveloppe un transport et en rend un :connect n'apprend jamais qu'il est passé en direct. Le relais négocie la connexion, puis sort du chemin, et sert de repli — il n'y a donc pas de serveur TURN à héberger.
import { p2pTransport, connectToRelay, connect } from '@nbe/collab';
// Le relais négocie, puis sort du chemin — et reste le repli.
const transport = p2pTransport(connectToRelay(url, room), {
onState: ({ peers, direct, relayed }) =>
status.textContent = !peers
? 'Seul dans le salon.'
: relayed
? `${peers} pair(s), via le relais.`
: `${direct} pair(s) en direct — le relais ne voit plus rien.`,
});
connect(store, transport); // `connect` n'apprendra jamais qu'il est passé en directLe piège, écrit parce qu'il est silencieux : un pair qui ne sait pas parler WebRTC ne dit jamais bonjour. Des pairs qui compteraient les bonjours se croiraient donc au complet, cesseraient d'utiliser le relais, et laisseraient nbe serve ne plus rien recevoir pendant que chaque écran a l'air en bonne santé. Le compte vient du relais, qui est le seul à le connaître.
Commentaires
Ils vivent dans le même document que le texte, donc ils arriventavec lui plutôt que par un second canal qui pourrait prendre du retard. Un commentaire porte sur un bloc, et son ancre est une marque posée sur tout le texte de ce bloc : elle survit aux modifications et aux fusions parce qu'elle suit le texte, et non un décalage numérique.
import { LoroComments } from '@nbe/collab';
import { newThread, newMessage, plainText, threadsInDocumentOrder, documentOrder } from '@nbe/core';
const comments = new LoroComments(store.doc); // dans le document, pas à côté
// L'hôte fournit le geste ; le bouton n'apparaît que s'il le fournit.
const view = new EditorView(el, editor, {
commentAuthor: { id: me.id, name: me.name }, // optionnel : l'anonyme est un vrai mode
onComment(blockId, author) {
const body = window.prompt('Votre commentaire');
if (!body) return;
const message = author ? newMessage(author.id, body, author.name) : newMessage('anon', body);
const thread = newThread(message, blockId);
comments.create(thread);
// L'ancre est une marque posée sur tout le texte du bloc : elle suit la
// phrase quand elle bouge, et se retrouve orpheline si le texte disparaît.
const length = plainText(editor.doc.blocks.get(blockId)?.text).length;
editor.dispatch((tx) =>
tx.op({ type: 'format_text', id: blockId, from: 0, to: length,
mark: { type: 'comment', attrs: { threadId: thread.id } }, add: true }),
);
},
});
comments.onChange(() => render(threadsInDocumentOrder(editor.doc, comments, documentOrder(editor.doc))));Quand le texte commenté disparaît, le fil devient orphelin —orphanThreads() les liste — plutôt que de pointer vers une position qui ne veut plus rien dire.
Historique
Le CRDT garde déjà tout ce qu'il faut ; l'historique n'est qu'une lecture. readAt() lit un état passé sans y aller,restore() ramène cet état en avant comme une nouvelle modification — ce qui reste fusionnable, contrairement à un retour en arrière destructif.
import { LoroHistory } from '@nbe/collab';
const history = new LoroHistory(store);
history.checkpoint('Avant la relecture'); // nommer l'état courant
const revisions = history.list(); // les versions, la plus récente d'abord
const before = history.readAt(revisions[3].frontiers); // lire le passé sans y aller
history.restore(revisions[3].frontiers, 'Retour avant relecture');Démonter proprement
Dans cet ordre : on cesse de peindre avant de couper le lien, et on dit au revoir plutôt que d'attendre l'expiration.
stopRedraw();
carets.destroy();
presence.leave(); // dire au revoir plutôt que d'expirer au bout de 30 s
stopSync(); // ferme aussi le transport
view.destroy();Ce qui n'est pas fourni
- Ni authentification ni permissions. Le transport porte des octets ; qui a le droit d'écrire se décide avant lui.
- Pas de reconnexion automatique dans
websocketTransport— un WebSocket fermé se remplace, et la reprise ne coûte qu'unHave. - Pas de sélection distante annoncée aux lecteurs d'écran.Un highlight n'est pas une sélection : c'est un manque d'accessibilité réel, écrit dans
docs/TESTING.md. - Un réseau où le chemin direct échoue vraiment n'est pas prouvé. Le repli est testé, la NAT qui l'imposerait ne l'est pas.