Un Excalidraw dont le serveur est la source de vérité : chaque canvas vit dans le cloud (ou sur votre machine), se partage par un lien, et s'ouvre aussi bien à des humains qu'à des agents.
Excalidraw est browser-first : la scène vit dans l'onglet (localStorage), la collaboration passe par un relais aveugle (excalidraw-room, 150 lignes, chiffré de bout en bout avec la clé dans l'URL), et la persistance Firebase ne stocke que du chiffré. Le serveur ne peut donc ni lire un canvas, ni le lister, ni l'ouvrir à un agent. Nous inversons ça : le serveur possède le canvas, le navigateur en est une vue.
@excalidraw/excalidraw tel quel (package npm, MIT), aucune modification du moteur de rendu.version, versionNonce, index fractionnaire pour le z-order, tombstones isDeleted..excalidraw : import/export 100 % compatibles.Un process Node/TypeScript sert tout : l'application web, l'API, les WebSockets temps réel et l'authentification. À côté, Postgres et un dossier de fichiers. Rien d'autre n'est obligatoire. C'est volontairement ennuyeux : c'est ce qui rend l'auto-hébergement trivial.
On ne forke pas le monorepo Excalidraw. On crée un nouveau dépôt qui consomme le package npm @excalidraw/excalidraw et réécrit seulement ce que l'app excalidraw.com fait autour : la couche collab (Collab.tsx, Portal.tsx), la persistance (firebase.ts), le partage. C'est environ 2 000 lignes à remplacer, et on suit les versions upstream par un simple bump de dépendance. L'éditeur expose tout ce qu'il faut : onChange, updateScene, addFiles, onPointerUpdate, collaborateurs, mode suivi.
Pas de CRDT. Le serveur garde chaque canvas ouvert en mémoire, applique la règle de réconciliation d'Excalidraw (l'élément de version la plus haute gagne, à égalité le nonce le plus bas), numérote chaque changement accepté, et le diffuse. Le client applique les patchs reçus sans les mettre dans son undo, exactement comme aujourd'hui. L'undo multijoueur d'Excalidraw continue de fonctionner tel quel.
StoreDelta, déjà dans le package, c'est la voie qu'Excalidraw+ a prise en privé) reste possible plus tard sans changer le protocole : c'est le contenu du push qui changerait.lastSeq. Le serveur répond avec les éléments manqués. Pas de mode offline long en v1.WebSocket brut (bibliothèque ws), messages JSON, taille max 10 Mo, heartbeat. On abandonne socket.io : il impose des sessions collantes et un adaptateur Redis dès qu'on dépasse un process, pour un protocole qu'on réécrit de toute façon. Les fichiers (images) ne passent jamais par le socket : seulement leurs identifiants, le binaire voyage en HTTP avec un cache immuable.
Trois principes. Un lien suffit pour éditer : celui qui l'ouvre devient un invité anonyme avec un prénom et une couleur, sans rien saisir. Le compte, c'est un email : lien magique + code à 6 chiffres, jamais de mot de passe. Le SSO se branche, il ne se construit pas : on est client OIDC, pas fournisseur.
| Qui | Comment il arrive | Ce qu'il peut faire |
|---|---|---|
| Propriétaire | A créé le canvas | Tout, y compris supprimer, gérer les liens et les membres |
| Membre (éditeur / lecteur) | Invité par email, ou invité anonyme promu après connexion | Selon son rôle ; survit à la révocation des liens |
| Porteur de lien | Ouvre /s/<jeton> ; le jeton est échangé une fois et l'URL redevient propre | Édition ou lecture selon le lien ; plusieurs liens par canvas, chacun révocable, avec expiration optionnelle |
| Public | Canvas marqué « lecture publique » | Lecture seule, sans présence |
| Agent | Clé API de son propriétaire, ou OAuth 2.1 via MCP | Les mêmes droits que la personne pour qui il agit |
Résolution : rôle de membre, sinon lien valide, sinon réglage par défaut du canvas, sinon refus. Le bouton « Partager » crée un lien d'édition sans expiration et le copie : c'est tout le parcours nominal. Les organisations (plugin Better Auth, rôles owner/admin/member) arrivent en phase 3, c'est aussi là que le SSO par domaine d'email prend son sens.
| Table | Rôle | Colonnes clés |
|---|---|---|
users, sessions, accounts… | Gérées par Better Auth | is_anonymous pour les invités, fusion à la connexion |
canvases | Un canvas | id, owner_id, title, link_access, public_read, seq, app_state (fond, grille) |
elements | État courant, un élément par ligne | (canvas_id, element_id), version, version_nonce, is_deleted, seq, data jsonb |
element_log | Historique (chaque version acceptée) | canvas_id, seq, element_id, actor_id, data ; purgeable |
canvas_members, share_links, link_grants | Droits | rôle, token_hash, expires_at, revoked_at |
files, canvas_files | Images, adressées par contenu | sha256, mime, size, chemin disque ou clé S3 ; GC des orphelins |
Chargement d'un canvas = select … where canvas_id = ? and seq > ?. Écriture = upsert par lot depuis la room, toutes les secondes au plus. Un crash du process perd au pire une seconde d'édition. L'export .excalidraw est une simple projection de elements.
Parce que le serveur lit la scène, un agent n'a besoin de rien de spécial : il passe par la même room qu'un humain. On l'expose de deux façons, calquées sur ce que font Miro et Figma en 2026.
@excalidraw/mermaid-to-excalidraw, exporter en PNG/SVG, prendre un snapshot et le restaurer.describe_scene, batch_create_elements, update_element, get_canvas_screenshot, create_from_mermaid.Point d'attention technique : le rendu PNG côté serveur demande un DOM (les utilitaires d'export d'Excalidraw s'appuient sur canvas/SVG du navigateur). Solution simple : un Chromium headless dans l'image Docker, ou demander la capture à un client connecté. À trancher en phase 2.
| Brique | Choix | Pourquoi celui-là |
|---|---|---|
| Front | Vite + React + @excalidraw/excalidraw | Même outillage qu'upstream, l'éditeur reste un package |
| Serveur | Node 22+, TypeScript, Hono, ws | Hono parle Request/Response standard, Better Auth s'y branche en une ligne ; un seul process |
| Auth | Better Auth | Seule option MIT, dans le process, avec magic link + OTP + invités + OIDC/SSO + clés API + MCP |
| Base | Postgres 16 via Drizzle | Comptes, droits, listing, recherche et historique au même endroit ; jsonb pour les éléments |
| Fichiers | Disque local par défaut, driver S3 optionnel | Zéro dépendance pour l'auto-hébergeur, MinIO/R2 pour les autres |
| SMTP (une URL), Mailpit en dev | Couvre Resend, Postmark, Gmail, relais d'entreprise | |
| Déploiement | Une image Docker, un docker-compose.yml, Caddy devant | Le même que celui qu'on utilise sur ce VPS |
services:
app: # front + API + WS + auth, port 3000
image: excalidraw-multi
environment: [BASE_URL, DATABASE_URL, AUTH_SECRET, SMTP_URL, FILES_DIR=/data/files]
volumes: [files:/data/files]
postgres:
image: postgres:16
volumes: [pg:/var/lib/postgresql/data]
La phase 1 est celle qui valide le pari technique : une room serveur qui tient la charge d'une session à dix personnes, avec reconnexion propre. Tout le reste est du produit.
Pour chaque point : ma recommandation, et l'alternative crédible. Le reste du document suppose la recommandation.
Le garder rend le serveur aveugle : pas d'agents, pas de recherche, pas d'historique lisible, pas de miniatures.
Reco : l'abandonner. Chiffrement au repos côté serveur, TLS en transit.
Alternative : un mode « canvas privé E2E » opt-in, sans agents ni historique. Coût : deux chemins de code à maintenir.
Élément entier (règle Excalidraw) ou delta par propriété (StoreDelta, style tldraw) ?
Reco : élément entier en v1, protocole conçu pour accueillir les deltas plus tard.
Alternative : deltas dès le départ. Meilleure fidélité en édition simultanée du même objet, mais c'est le chantier qu'Excalidraw a laissé inachevé en open source : risque réel.
Postgres ou SQLite en binaire unique ?
Reco : Postgres. Un conteneur de plus, mais multi-process possible, sauvegardes connues, jsonb.
Alternative : SQLite + Litestream pour un « un seul binaire ». Séduisant pour l'auto-hébergeur solo, mais on s'interdit tout second process pour toujours.
Forker le monorepo Excalidraw ou consommer le package npm ?
Reco : le package npm + réécriture de la coque applicative.
Alternative : fork du monorepo. Permet de patcher l'éditeur, mais chaque mise à jour upstream devient un rebase douloureux.
N'importe qui avec un email (instance ouverte) ou seulement les comptes invités par un admin ?
Reco : les deux, par variable d'env, ouvert par défaut. Les invités via lien éditent toujours sans compte.
Décision produit plus que technique, mais elle fixe la page d'accueil et le premier écran.
Un lien d'édition ouvert par un inconnu : il édite tout de suite, ou il doit donner un email ?
Reco : il édite tout de suite, avec un prénom et une couleur, comme Excalidraw et tldraw.com. Le propriétaire peut exiger un compte par canvas.
Alternative : email obligatoire. Plus traçable, mais casse le « j'envoie un lien, c'est fini ».
La licence est MIT et il n'y a pas de clause de marque, mais « Excalidraw » reste leur nom.
Reco : un nom propre dès le premier commit, « propulsé par Excalidraw » dans le pied de page.
Garder « Excalidraw Multi » comme nom de code interne seulement.
Excalidraw limite à 4 Mo parce que les images voyagent en base64 dans la scène. Chez nous elles voyagent en HTTP.
Reco : 10 Mo par défaut, configurable.
Garder 4 Mo pour rester identique à l'export upstream.
auth/ à nous, seul endroit qui le connaît.Notes de recherche détaillées (sources, numéros de ligne, URL) : ~/excalidraw-multi/research/. Références clonées : ~/excalidraw-multi/ref/.