Enderbot est un jeu qui se joue dans Discord : un monorepo TypeScript où un cœur applicatif, un site SvelteKit et un client passerelle se parlent en protobuf par dessus RabbitMQ, avec Postgres, Redis et Prometheus derrière. Le client passerelle, c'est la partie qui tient la connexion à Discord, encaisse les évènements et pousse les messages. C'était historiquement du discord.js, un process Node par shard. Ça ne l'est plus.
Le client Discord passe de discord.js, un process Node par shard, à un binaire Go qui tient les seize shards à lui seul.
Mêmes messages protobuf, mêmes clés Redis, mêmes métriques : le cœur applicatif tourne sans une ligne modifiée.
Des caches sans plafond retenaient environ 2,7 Go par jour, et une métrique à 40 295 s ne mesurait qu'un sleep attendu par erreur.
La bascule du 9 août a été annulée le jour même. Sept jours de correctifs plus tard, le client JS a pu être supprimé.
Le cœur n'est plus empaqueté : Bun exécute les sources, et le bundle de 2962 modules disparaît avec ses quatre bundlers.
Le monorepo n'avait aucun job de test. Il en a deux, et 4196 tests passent à l'identique sous le nouveau runtime.
Qui a fait quoi
Le travail décrit ici s'est joué à deux, sur deux terrains nettement séparés. La passerelle est entièrement l'œuvre d'EnderSpirit : le constat de départ, la proposition de repartir sur du Go, le choix de la bibliothèque, l'écriture du module, la bascule en production et jusqu'à la suppression du client JS. Je n'ai pas écrit une ligne du Go dont parle cet article, je le raconte depuis l'autre bout du monorepo. De mon côté j'ai pris le cœur : son runtime, sa mesure en production et les correctifs qui en sortent.
Le constat, la proposition de réécrire en Go, puis la réécriture entière du client Discord, du premier paquet à la suppression du client JS. Aussi la cure de dépendances du monorepo, le passage à TypeScript 7 et Vite 8, et le dégraissage des images Docker.
Migration du monorepo vers Bun, profil de performance en production et les correctifs qui en sortent : N+1, caches bornés, encodage d'images, crons, index Postgres, observabilité Redis.
Pourquoi sortir discord.js
Le raisonnement est celui d'EnderSpirit, qui tenait la passerelle et voyait la facture de près. Un process par shard, c'est un cache mémoire par shard, un heap par shard et un watchdog maison pour relancer ceux qui décrochent. À seize shards, la facture mémoire du client rivalisait avec celle du cœur alors qu'il ne fait que du relais. Le calcul métier vit ailleurs, donc la passerelle n'avait aucune raison de rester dans le même langage que lui.
Le remplaçant, écrit par EnderSpirit, s'appelle discord-go : un module Go bâti sur disgo, un seul process qui tient tous les shards, le nombre de shards résolu tout seul depuis /gateway/bot. Cent vingt six fichiers Go, une trentaine de milliers de lignes, et un démarrage à froid d'environ quatre vingt dix secondes à seize shards, imposé par la limite d'identify de Discord.
Le contrat de fil comme point fixe
La règle qu'il s'est fixée, et qui a rendu la réécriture tenable : ne rien changer de ce qui traverse le réseau. Les mêmes messages protobuf sur les files client-to-core et core-to-client-{shardId}, les mêmes clés Redis status-discord-*, les mêmes métriques enderbot_discord_*. Le cœur tourne sans une ligne modifiée, et les tableaux de bord Grafana continuent d'afficher les mêmes séries.
Concrètement, les types Go sont générés depuis les mêmes .proto que le TypeScript. Les numéros de champ sont le contrat, jamais édités à la main.
Faites glisser le schéma pour le parcourir
cd discord-go
./gen-proto.sh # shared/proto/**/*.proto -> internal/wire/
go build ./... && go test ./...La parité se joue sur le comportement, pas sur l'API
La partie longue n'a pas été de reproduire les appels, mais les habitudes de discord.js. Elles ont occupé une journée entière de correctifs, le 11 août, tous signés EnderSpirit. Une bibliothèque de ce calibre transporte des décisions non écrites, et chacune se paie en bug de production si on ne la rejoue pas :
- les permissions doivent ignorer un timeout, exactement comme discord.js le fait, sinon un membre exclu temporairement perd des droits qu'il devrait garder
- le rôle @everyone voyage avec les rôles du membre, et compte tous les membres du cache
- les positions des salons et des rôles sont calculées, pas lues telles quelles
- un avatar animé se sert en .gif, un attachement qui arrive sans nom doit quand même en recevoir un
- les messages privés se répondent, les nameLocalizations des choix d'autocomplétion se transmettent
- le repli sur une édition simple ne vaut que pour un jeton périmé, pas pour toute erreur
Les paquets faits main sont montés à 94 % de couverture, et une revue adversariale dédiée a servi à chercher les écarts de parité restants plutôt qu'à relire du style.
Bascule, retour arrière, bascule
Le passage en production n'a pas été un interrupteur, et il a été piloté du même côté que le reste de la passerelle. Les deux clients ont tourné en double, pilotés par un drapeau Redis discord-client-active, avec un mode veille froide des deux côtés : le process boote passif, sonde le drapeau toutes les deux secondes, et l'activation recrée entièrement le client disgo. Fermer un client disgo bloque ses seaux REST et la réouverture devient impossible, donc on le reconstruit.
- 9 aoûtLe client Go passe par défaut
discord-go entre dans le compose de base, le client JS recule derrière un profil Docker prévu pour le retour arrière.
- 9 aoûtRetour arrière le jour même
Le client JS reprend la main. Le chemin de repli n'était pas décoratif, il a servi en quelques heures.
- 10 au 13 aoûtVeille froide et parité
Drapeau Redis discord-client-active des deux côtés, sonde toutes les deux secondes, et la longue série de correctifs de comportement.
- 16 aoûtLe client JS est supprimé
Avec lui partent le système de bascule, le service de compose, l'onglet d'administration et ses cinq locales.
Faites glisser le schéma pour le parcourir
Depuis, un échec d'activation fait sortir le process, et Docker le relance.
Pourquoi Bun, et ce que le passage a cassé
Pendant qu'EnderSpirit refaisait la passerelle, j'ai attaqué le runtime du cœur. La raison n'est pas la mode, c'est une couche entière devenue inutile : le cœur était livré en bundle esbuild, un dist/out.js de 2962 modules reconstruit à chaque changement, et trois autres bundlers vivaient à côté pour la CLI de scrap, l'export du wiki et fakecord. Bun lit le TypeScript directement, donc tout cet étage disparaît. Une trace d'erreur pointe de nouveau un fichier source plutôt qu'une ligne dans un bundle.
Ce qui a rendu la bascule possible, c'est la migration canvas faite plus tôt : toutes les dépendances natives qui restaient sont du N-API avec binaires préconstruits, que Bun charge sans y toucher, et le seul appel Node délicat du code, AsyncLocalStorage dans le logger, fonctionne. bun install remplace pnpm, et le blocage d'approvisionnement est reporté tel quel dans bunfig.toml : sept jours de quarantaine avant qu'une version publiée soit installable. Le régime des scripts d'installation se resserre au passage, puisque pnpm les autorisait tous en bloc dans les images alors que Bun n'exécute que ceux des quatre paquets nommés explicitement.
Le prix se paie sur les différences de sémantique, pas sur les performances. Cinq pièges, tous trouvés en une journée :
- useDefineForClassFields : tsc le déduit à false, Bun le suppose à true. Un champ initialisé depuis une propriété de constructeur voyait donc undefined, et toutes les commandes liées à une guilde levaient. L'option est maintenant écrite noir sur blanc.
- un npx prettier au démarrage du générateur de clés de langue, sur un fichier que l'API prettier venait de formater deux lignes plus haut. Redondant, et introuvable dans une image Bun.
- bun --watch redémarre sur toute écriture dans l'arbre surveillé : les générateurs qui réécrivaient des octets identiques provoquaient soixante démarrages en trois minutes. Ils comparent avant d'écrire, et le fichier de log est sorti de l'arbre.
- zod sous vitest : le runner de modules renvoyait undefined pour le binding nommé, ce qui cassait les 95 fichiers qui l'importent ainsi. Le paquet est inliné dans la config vitest.
- better-sqlite3 n'est pas seulement à recompiler, Bun l'intercepte et lève. fakecord passe à bun:sqlite, dont les génériques, l'absence de pragma et le null au lieu de undefined se rattrapent aux trois getters concernés.
Ce que ça donne une fois posé : plus aucune image ne contient Node, l'étage de production du cœur se réduit à une installation et une copie des sources, et le réglage de taille de heap disparaît puisque JSC se dimensionne sur la RAM disponible. La CI tourne sous Bun et a gagné deux jobs de test qu'elle n'avait jamais eus, ce qui explique qu'une assertion sensible à l'ordre ait pu pourrir sans que personne le voie. Un script de garde refuse désormais tout retour de Node : shebang, appel à un gestionnaire de paquets, image de base ou action de CI.
Ce que disaient réellement les métriques
Une fois la passerelle stabilisée, c'est mon terrain qui passe à la question. J'ai profilé le cœur en production plutôt que de deviner. Relevé sur une fenêtre de 4993 secondes d'uptime :
L'hypothèse de départ était mauvaise. Je cherchais des lectures disque synchrones et du crypto bloquant ; il n'y en avait quasiment pas, et passer à Bun.file ou Bun.hash n'aurait rien gagné de mesurable. Le temps était ailleurs.
Temps cumulé par point chaud, sur la même fenêtre de 4993 s.
Ce qui a été corrigé
Les 40 295 secondes de guildMemberInformationUpdate n'étaient pas du travail : le handler RabbitMQ attendait un debounce de trente secondes qui existait « au cas où d'autres informations arrivent ». Le rafraîchissement part désormais sans être attendu, ses erreurs journalisées, et la métrique se referme tout de suite.
Le RSS venait de caches sans plafond. Les entités guilde traînent leurs salons, leurs rôles et leurs emojis dans des Map qui ne rendaient jamais la mémoire ; les avatars Discord jamais rendus retenaient à eux seuls environ 2,7 Go de RSS par jour. Deux caches bornés en LRU, l'un pour les entités guilde, l'autre pour les buffers et images du chargeur d'assets, avec des plafonds très au dessus du working set actif pour que le taux de hit ne bouge pas.
summon, la commande la plus lente du jeu, composait jusqu'à vingt quatre frames dans une boucle await séquentielle. sharp encode sur le pool libuv, dimensionné à douze threads : la boucle n'en utilisait qu'un. Les trois boucles passent par une map concurrente bornée à six, l'ordre des frames restant garanti par construction puisque c'est lui qui définit le GIF.
- une quinzaine de N+1 supprimés d'un coup : crons de ferme, classements, snapshots quotidiens, autocomplétion de boutique, collections de familiers
- les statistiques de guilde agrégées en SQL au lieu d'être chargées entièrement en mémoire
- deux index manquants sur command_logs et players.last_time_activity, où sept count() partaient en full scan avec un p95 à 4,7 s
- les encodages canvas sortis du thread principal, et le verrou salon plus auteur ramené du message à la commande pour qu'une chaîne de commandes ne gèle plus le salon
- le trafic Redis applicatif basculé sur le client natif de Bun, qui parle RESP sans traverser la couche de compatibilité node:net
- les quantiles de latence Redis relabellisés par opération, avec des buckets qui commencent sous la milliseconde au lieu de cinq
Vingt crons pour une seule seconde
Sur quarante quatre tâches planifiées, vingt partageaient la seconde HH:00:00 et se disputaient le pool Prisma une fois par heure. Un décalage stable est maintenant dérivé du hash du couple module:job, borné sous la minute pour qu'aucune tâche ne sorte de la minute pour laquelle elle était planifiée. Au passage, la garde de ré-entrance couvrait trois jobs sur quarante quatre via des drapeaux écrits à la main : elle vaut désormais pour tous, et le fuseau UTC que plusieurs schedules documentaient en commentaire est enfin garanti par le code.
Faites glisser le schéma pour le parcourir
Autre boucle qui n'additionnait que des allers-retours réseau : le balayage de démarrage parcourait les 141 structures une par une, chacune avec son verrou Redis et sa transaction. Il pesait 3,5 s sur les 5,5 s qui séparent le lancement du process de la mise en service du serveur web.
Le poids des images
Pendant que je faisais passer le monorepo sous Bun, EnderSpirit s'attaquait aux images Docker : base slim, installations filtrées par service, séparation build et runtime, polices Noto dégraissées. Puis les deux couches qui coûtaient le plus cher. Un chown -R des node_modules vivait dans son propre RUN, ce qui fait réécrire chaque fichier dans une nouvelle couche : le node_modules existait deux fois dans l'image. Bun installe par ailleurs les deux variantes libc des binaires natifs alors que l'image est glibc.
Le mois a aussi été celui d'une remise à plat de l'outillage, côté EnderSpirit : montée sur le TypeScript 7 natif et sur Vite 8, dépendances mortes évacuées, et un plugin de lint maison branché pour traquer le code sans intention.
Ce que la production a mesuré
Les chiffres précédents viennent d'un profil ponctuel. Le Prometheus de production garde quinze jours, ce qui couvre exactement les trois bascules : la passerelle Go qui prend les quinze shards le 13 août, la suppression du client JS le 16, et le passage à Bun dans la nuit du 22. La charge n'a pas bougé sur la fenêtre, environ 15 490 serveurs Discord, donc l'avant et l'après se comparent honnêtement.
Le passage à Bun se lit tout aussi nettement sur le cœur, redémarré en Bun 1.4.0 le 22 août au matin :
Le p95 des commandes reste haut, et c'est attendu : il inclut les allers et retours vers Discord ainsi que les attentes volontaires de certains handlers. Ce qui compte, c'est que la même charge passe maintenant avec deux gigaoctets de moins et un event loop deux fois plus calme.
Ce qui reste
read guild à 762 secondes cumulées est le prochain gros morceau : la lecture charge systématiquement salons, rôles et emojis, même quand l'appelant ne veut que le préfixe ou la langue. Un chemin « guilde nue » demande de trier les appelants un par un. buttonClick à 1587 ms est une métrique agrégée sur tous les boutons : il faut d'abord la labelliser par customId pour savoir lesquels sont lents.
La leçon qui vaut au delà d'Enderbot tient en deux lignes. Une réécriture ne devient raisonnable que si un contrat sérialisé stable permet de faire cohabiter l'ancien et le nouveau, avec un chemin de retour arrière qu'on emprunte vraiment. Et une passe de performance guidée par les métriques de production trouve autre chose que ce qu'on croyait chercher : ici, pas de disque bloqué, mais des caches sans plafond et un sleep attendu par erreur.