Aller au contenu principal

Une administration en direct avec Gleam et Lustre

· 13 minutes de lecture
Dave Rapin
Dave Rapin
Fondateur chez Curling IO
Mise à jour : 1er octobre 2026

Cet article présente notre exploration initiale de Gleam pour Curling IO v3. Nous avons finalement choisi Rust et TypeScript. Pourquoi nous avons choisi Rust pour la version 3 explique cette décision et ce que nous avons conservé de cette exploration.

À propos de cet article

Cet article d’architecture technique vient du développement de Curling IO v3. Il s’adresse aux ingénieurs logiciels et aborde Gleam, Lustre, BEAM, les WebSockets et les interfaces rendues côté serveur plus en détail que nos articles habituels sur le produit.

Le panneau d’administration de Curling IO devrait réagir instantanément lorsqu’un gestionnaire prépare une saison. Activer un paramètre, enregistrer un rabais, passer d’une section de produit à l’autre : la page devrait répondre sans rechargement complet.

La version 2 fonctionne, mais chaque envoi de formulaire recharge la page. L’administration de la version 3 est un seul composant serveur Lustre sur BEAM : une connexion WebSocket et un processus Erlang de longue durée par session. Chaque interaction passe par ce WebSocket et revient sous forme de modification du DOM. La page ne se recharge jamais, sans cadriciel JavaScript côté client.

Le panneau d’administration Curling IO avec la page d’inscription d’un produit

La page d’inscription d’un produit : barre latérale, fil d’Ariane, interrupteurs et champs de formulaire, tous rendus côté serveur par une seule connexion WebSocket. Chaque interrupteur, saisie et enregistrement met l’état à jour en direct.

Qu’est-ce qu’un composant serveur?​

Phoenix LiveView a popularisé l’idée : produire le HTML sur le serveur, envoyer les modifications au client par WebSocket et traiter les événements de la même façon. Le navigateur devient une mince couche d’affichage. Lustre, le cadriciel d’interface de Gleam, intègre cette notion sous le nom de « composants serveur ».

Un composant serveur Lustre est une application suivant l’architecture Elm (modèle, mise à jour, vue) qui fonctionne comme processus BEAM. Lorsque le modèle change, Lustre compare l’ancien et le nouveau DOM virtuel, puis envoie une modification JSON par WebSocket. Le client l’applique. Les événements du navigateur, clics, envois de formulaire et changements de saisie, retournent en JSON. Toute la boucle prend moins de dix millisecondes sur un réseau local.

Côté client, un élément personnalisé nommé <lustre-server-component> se charge de tout. Vous lui indiquez une route WebSocket, et il crée le DOM fantôme (shadow DOM), adopte les feuilles de style, applique les modifications et transmet les événements. Lustre fournit l’environnement d’exécution client dans un seul fichier JavaScript à inclure dans la page.

L’architecture​

Raccordement du composant serveur

L’administration charge une structure HTML minimale :

<html>
<head>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<lustre-server-component route="/ws/admin?url=/en/admin/products">
</lustre-server-component>
<script src="/static/lustre-server-component.mjs"></script>
<script src="/static/js/admin-live.js" defer></script>
</body>
</html>

C’est tout le HTML envoyé par le serveur. Le reste est rendu par WebSocket.

Lorsque le navigateur ouvre la page, l’élément personnalisé Lustre se connecte à /ws/admin, puis la connexion passe au protocole WebSocket. Côté serveur, Mist, le serveur HTTP, transmet la connexion à notre gestionnaire WebSocket, qui démarre un environnement d’exécution Lustre :

fn(_connection) {
let assert Ok(runtime) =
lustre.start_server_component(admin.app(), flags)

let self = process.new_subject()
let selector =
process.new_selector()
|> process.select(for: self)

server_component.register_subject(self)
|> lustre.send(to: runtime)

#(SocketState(runtime:, self:), Some(selector))
}

lustre.start_server_component crée un acteur qui conserve l’état de l’application, exécute la fonction de mise à jour à chaque message, compare les vues et émet les modifications. L’appel register_subject indique à l’environnement d’exécution d’envoyer ses modifications à cette connexion WebSocket. Tout devient alors bidirectionnel :

  • Du navigateur au serveur : l’utilisateur clique sur un lien. Le client envoie un événement JSON. Mist le décode et le transmet à l’acteur Lustre.
  • Du serveur au navigateur : l’acteur met le modèle à jour, compare la vue et retourne une modification JSON. Le client l’applique au DOM fantôme.

Chaque session d’administration a son propre processus BEAM. Les sessions ne partagent rien : aucun stockage de session, aucun mécanisme de publication et d’abonnement, aucun état à coordonner. Si une session plante, les autres ne le remarquent pas. C’est l’isolation des processus BEAM en action.

Les pages rendues côté serveur se rechargent habituellement à chaque navigation. Cela nuit à un panneau d’administration où l’on passe constamment d’une section de produit à l’autre. Il nous fallait le comportement de navigation côté client, avec pushState et le bouton de retour, sans routeur client.

La solution tient en environ 90 lignes de JavaScript sans cadriciel dans admin-live.js. Elles relient l’API d’historique du navigateur au composant serveur et gèrent trois parcours :

Clics sur les liens. Un écouteur de clics sur le composant serveur intercepte les balises <a>, empêche le chargement, appelle history.pushState et informe le serveur de la nouvelle URL. Les touches de modification, comme cmd+clic, les liens externes et target="_blank" sont laissés au navigateur. L’écouteur utilise composedPath() pour franchir la frontière du DOM fantôme et trouver le véritable élément <a>.

Boutons précédent et suivant. Un écouteur popstate détecte la navigation par les boutons du navigateur et envoie la nouvelle URL au serveur sans l’ajouter à l’historique, déjà mis à jour par le navigateur.

Navigation déclenchée par le serveur. Lorsqu’un enregistrement redirige vers une autre page, par exemple du formulaire de modification à l’aperçu, le serveur émet un événement personnalisé avec la nouvelle URL. Le client l’intercepte et appelle pushState.

Les trois parcours communiquent avec le composant serveur par la même astuce : des éléments <input> cachés dans le DOM fantôme. JavaScript définit la valeur du champ à la nouvelle URL et déclenche un événement change. Le gestionnaire d’événements Lustre côté serveur le reçoit. Les événements DOM ordinaires ne franchissent pas cette frontière de façon fiable, mais ceux des éléments de formulaire remontent dans les mécanismes internes du composant. Nous avions d’abord essayé des événements personnalisés et passé du temps à déboguer avant d’arriver à cette solution.

DOM fantôme et feuilles de style​

Lustre produit le rendu dans un DOM fantôme, ce qui offre une véritable encapsulation. Les styles ne traversent pas la frontière dans un sens ou l’autre. Le CSS de l’administration n’affecte pas le reste de la page, et vice versa. Mais il faut faire entrer les feuilles de style dans la racine du DOM fantôme.

Lustre s’en charge automatiquement avec adoptedStyleSheets, une API du navigateur qui permet aux racines de DOM fantôme de partager les feuilles de style du document parent. Au montage, le composant parcourt toutes les feuilles du document et les ajoute au tableau adoptedStyleSheets de sa racine. Si une feuille ne peut pas être adoptée directement à cause des restrictions d’origine, il copie les règles CSS dans une nouvelle feuille. Si cela échoue aussi, il clone l’élément <link> ou <style> dans la racine. Trois stratégies, par ordre de préférence.

Cela a fonctionné directement avec Tailwind et notre bibliothèque de composants Basecoat. Un point nous a fait trébucher : les propriétés CSS personnalisées. Nos variantes de couleur d’alerte utilisaient --success, --warning et --info, mais ces variables n’étaient jamais définies. Les styles étaient bien adoptés, mais color: var(--success) ne donnait rien, car la variable n’existait pas. Il a suffi d’ajouter les définitions à :root. Les propriétés CSS personnalisées sont héritées à travers les frontières du DOM fantôme, contrairement aux règles CSS ordinaires. Une fois définies dans :root, elles sont donc disponibles partout.

Ce qui fonctionne sur le serveur et sur le client​

La séparation est simple. Le serveur gère tout ce qui ne demande pas de rétroaction visuelle en moins d’une image d’affichage :

  • Navigation et routage
  • Saisie et validation des formulaires
  • Interrupteurs de fonctions
  • Chargement des données, synchrone depuis SQLite
  • Alertes temporaires
  • Ouverture et fermeture de la barre latérale

Le client gère le raccordement de la navigation, soit 90 lignes de JavaScript, et l’adoption des feuilles de style intégrée à Lustre. C’est tout. Toute l’interface d’administration est écrite en Gleam, y compris la barre latérale, le fil d’Ariane, les champs, les tableaux et les alertes.

Le créateur de tableau de Curling IO

Le Créateur de tableau de Curling IO. Ce type d’interaction par glisser-déposer exige un rendu côté client.

Nous excluons explicitement du composant serveur les minuteries, le glisser-déposer, les animations et les observateurs de redimensionnement. Leurs boucles de rétroaction côté client surchargeraient le WebSocket. Le créateur de tableaux et le glisser-déposer d’équipes seront des « îlots » Lustre : de petites applications Gleam côté client, compilées en JavaScript et intégrées à certaines pages. Le composant serveur rend la page; l’îlot gère le widget interactif.

Nos erreurs en cours de route​

Boucle infinie du bouton de retour. Notre première navigation utilisait un seul message NavigateTo pour tout. Le serveur demandait au client d’ajouter une URL à l’historique, et le client le faisait. L’utilisateur revenait en arrière, le navigateur déclenchait popstate, le client envoyait l’ancienne URL au serveur, qui émettait un autre pushState. Une boucle. La solution : séparer NavigateTo, qui ajoute à l’historique, d’UrlChanged, qui ne le fait pas.

Clignotement de la barre latérale. Pendant le chargement entre deux pages de produit, nous effacions le contexte du produit. La barre se repliait à son état par défaut, puis se dépliait au chargement. Nous avons conservé le contexte du produit dans le modèle d’administration pendant les navigations au sein du même produit.

Disparition des messages temporaires. Enregistrer un rabais ou des frais d’association mène à la liste avec un message temporaire. Mais nous avions aussi ajouté l’effacement de ce message à la navigation. L’enregistrement définissait le message, puis appelait NavigateTo, qui l’effaçait. Il fallait définir le message après la navigation.

Ce que nous avons obtenu sans effort​

Le chargement de code à chaud de BEAM s’applique aux composants serveur. Lorsque l’outil de surveillance du développement recompile un module, le processus en cours utilise le nouveau code au message suivant. Le WebSocket reste connecté, l’état de la session est conservé et le prochain clic ou changement de page affiche la nouvelle vue. Nous n’avons rien créé ou configuré pour cela. Un jour, nous l’avons remarqué pendant le développement et compris que BEAM le faisait depuis le début.

Pourquoi pas du SSR ou une SPA?​

Par rapport au SSR​

L’administration de la version 2 utilise le rendu serveur avec des rechargements complets. Cela fonctionne, mais chaque interaction a une latence visible. On active un interrupteur et attend le rechargement pour voir le résultat. On change de section de produit et perd sa position de défilement.

Avec le composant serveur, activer un interrupteur met immédiatement l’interface à jour. Le processus BEAM est là, sans aller-retour réseau vers une base de données. L’enregistrement d’un formulaire peut mener instantanément à une autre page avec un message temporaire. La barre latérale reste en place, car elle appartient à la même application en cours d’exécution.

Par rapport à une SPA​

Pour l’utilisateur, le composant serveur ressemble exactement à une application monopage (SPA). La navigation ne recharge pas la page, l’état persiste et les mises à jour sont instantanées. La différence est l’endroit où le travail se fait.

Une SPA télécharge un paquet JavaScript avant que l’utilisateur voie quoi que ce soit. Une administration React modeste avec bibliothèque de composants, routeur et gestion d’état atteint facilement 200 à 500 Ko de JavaScript. La nôtre envoie environ 15 Ko de HTML initial par WebSocket et aucun JavaScript applicatif. L’environnement client Lustre est un script générique de 10 Ko, pas du code d’application.

Une SPA exige aussi une API REST ou GraphQL pour parler au serveur : points d’accès à concevoir, données à sérialiser en JSON puis à désérialiser côté client, états de chargement, cache et synchronisation des types client et serveur. Avec le composant serveur, la fonction de mise à jour a directement accès au domaine et à la logique métier. Aucune mécanique d’API ne s’interpose. Lorsqu’un formulaire est enregistré, elle écrit dans la base et retourne le nouveau modèle. La vue est comparée automatiquement. Toute l’administration est un seul ensemble de code Gleam avec l’état au même endroit.

Expérience de développement​

Chaque page d’administration est un module Gleam avec Model, Msg, init, update et view. Les types imposent le traitement de tous les messages. Ajouter une page revient à ajouter une variante de route, dont le compilateur indique tous les endroits à traiter, un module de page et son raccordement à la fonction de mise à jour de l’administration. Aucun langage de modèles ni objets de contexte transmis par intergiciel. Des fonctions à tous les niveaux.

Les chiffres​

Pour situer ce que nous utilisons :

  • La structure HTML d’administration a 6 lignes de contenu : métadonnées, une feuille de style, l’élément personnalisé et deux scripts.
  • admin-live.js contient 89 lignes de JavaScript sans cadriciel.
  • Le montage initial envoie tout le DOM virtuel, soit environ 15 à 30 Ko selon la page.
  • Les modifications suivantes font généralement moins de 1 Ko, seulement les différences.
  • Chaque session est un seul processus BEAM qui consomme quelques centaines de Ko de mémoire.
  • Les requêtes SQLite fonctionnent dans le processus. Le chargement des données lors des changements de page est donc synchrone et mesuré en microsecondes.

Il y a aussi un avantage de volume transmis par rapport au SSR traditionnel. Chaque requête HTTP transporte des en-têtes : témoins, type de contenu, jetons CSRF, en-têtes d’acceptation et directives de cache. Cela représente généralement 1 à 2 Ko supplémentaires par aller-retour, dans les deux sens. Une trame WebSocket ne contient que la charge utile avec quelques octets d’encadrement. Une session d’administration peut compter des centaines d’interactions : activer des interrupteurs, changer de section, enregistrer des formulaires. En HTTP, chacune paie le coût des en-têtes. En WebSocket, la connexion est déjà établie et authentifiée. Un interrupteur qui change un booléen envoie peut-être 80 octets de JSON et reçoit une modification de 200 octets. Le POST HTTP équivalent ferait 2 à 3 Ko avec les en-têtes, la redirection et le nouveau rendu complet.

La suite​

Le composant serveur couvre les 95 % des pages d’administration faites de formulaires, tableaux et paramètres. Les 5 % restants, le créateur de tableaux, la gestion des équipes par glisser-déposer et les tableaux de pointage en direct, seront des îlots Lustre côté client. Ils communiqueront avec le serveur par le même WebSocket ou par des appels d’API distincts.


Cet article est le huitième de la série sur les fondations de Curling IO. À suivre : améliorer la récupération avec NixOS.