Registres et singletons
Pourquoi les registres vivent sur globalThis via Symbol.for, ce que ça garantit et ce que ça n'empêche pas.
L'extensibilité de LRVO repose sur des registres runtime : le core expose des fonctions d'enregistrement, les modules et le projet hôte les appellent au boot, et le core lit le résultat au moment du rendu.
Tous suivent le même patron. Le comprendre une fois suffit pour les dix.
Les registres du core
| Registre | Clé de slot | Contenu |
|---|---|---|
| Admin | __LRVO_ADMIN_REGISTRY__ | Pages, widgets, entrées de navigation, emplacements de la barre supérieure, métadonnées de page. |
| Modules | __LRVO_MODULES_REGISTRY__ | Modules découverts et activés. |
| API | __LRVO_SESSION_RESOLVER__, __LRVO_FEATURE_ROUTES__ | Résolveur de session et routes enregistrées. |
| Permissions | __LRVO_PERMISSIONS_REGISTRY__ | Permissions contribuées par les modules. |
| Stockage | __LRVO_STORAGE_POLICIES__, __LRVO_STORAGE_PUBLIC_PREFIXES__ | Policies d'accès et préfixes publics. |
| Cron | __LRVO_CRON_JOBS__ | Jobs planifiés. |
| Templates | __LRVO_TEMPLATES__ | Templates de rendu public. |
| Contenu public | __LRVO_PUBLIC_CONTENT_RESOLVERS__ | Résolveurs d'URL racine. |
| Pages publiques | __LRVO_PUBLIC_MODULE_PAGES__ | Pages sous /lrvo/<module>/. |
| Blocs | __LRVO_CUSTOM_BLOCKS__ | Blocs personnalisés, côté serveur. |
| BlockEditor | __LRVO_BLOCK_REGISTRY__ | Définitions de blocs, côté éditeur. |
| Média | __LRVO_MEDIA_PROVIDER__, __LRVO_MEDIA_CLIENT_SLOT__ | Provider fourni par @lrvo/module-medias. |
| Rate limit | __LRVO_RATE_LIMIT_STORE__ | Compteurs par identifiant. |
| Thème | __LRVO_THEME__ | Thème résolu du projet, enregistré par initializeLrvo. |
Deux derniers slots ne sont pas des registres. __LRVO_INITIALIZED__ porte le garde d'idempotence d'initializeLrvo(), pour qu'un second appel dans le même processus ne réenregistre pas tout. __LRVO_MODULES_READY__ porte la file d'attente d'onModulesReady(), vidée par initializeLrvo() une fois tous les modules enregistrés.
Le patron
const SLOT_KEY = Symbol.for('__LRVO_TEMPLATES__');
if (!globalThis[SLOT_KEY]) {
globalThis[SLOT_KEY] = { map: new Map() };
}
const slot = globalThis[SLOT_KEY];Trois propriétés découlent de cette forme.
Symbol.for produit un symbole partagé. Contrairement à Symbol('x'), Symbol.for('x') retourne toujours le même symbole pour la même chaîne, dans tout le processus. Deux copies du fichier de registre, chargées par deux chemins différents, retrouvent donc le même slot.
globalThis survit à l'invalidation du cache de modules. En développement, Next.js recharge les modules à chaque modification. Un état stocké dans une variable de module serait remis à zéro à chaque sauvegarde : les widgets disparaîtraient, les compteurs de rate limit repartiraient de zéro, les jobs cron seraient réenregistrés en double.
Le garde if (!globalThis[SLOT_KEY]) rend l'initialisation idempotente. Recharger le fichier ne réinitialise pas le contenu.
Pourquoi le build ne bundle rien
C'est la contrepartie côté build. Si un barrel index.js bundlait registry.js, il en ferait une copie inline distincte de la version importée en relatif par les pages et les widgets.
Le Symbol.for sauverait encore la mise dans ce cas précis, puisque les deux copies pointeraient vers le même slot. Mais toute autre forme d'état de module (un compteur, un cache, une closure) serait dupliquée. bundle: false supprime la classe de problème plutôt que ses instances. Voir Build et exports.
Double instanciation Node natif et bundle
Un module externe est chargé par Node en ESM natif, hors du graphe Turbopack. Le code du core qui le consomme, lui, est bundlé par Turbopack. Les deux voient donc potentiellement deux instances du même fichier de registre.
C'est exactement le cas que Symbol.for couvre : le client.js d'un module et le code core qui consomme le registre partagent le même slot malgré le double bundle.
Ordre d'enregistrement
Un registre est une Map : le dernier enregistrement pour une clé donnée écrase le précédent. Cela vaut pour une page publique par moduleName, pour un template par couple (kind, id), pour un job cron par nom.
Deux conventions rendent l'ordre non significatif là où c'est possible :
- Les entrées de navigation et les widgets portent un champ
orderexplicite, plutôt que de dépendre de l'ordre d'appel. - Les groupes de permissions du core sont réservés en tête de la réponse API, quel que soit l'ordre des appels à
registerPermissions().
Là où l'ordre compte réellement, comme pour la résolution de contenu public, il est déclaré par un champ priority, pas déduit de l'ordre d'enregistrement.
Entre deux modules, il n'y a aucun ordre du tout. Le manifeste généré lance les register() par Promise.all. Un module ne peut donc pas lire, au niveau racine de son register-server.js, ce qu'un autre module a enregistré : il doit passer par onModulesReady().
Qui alimente un registre de composants
Un registre qui contient des composants obéit à une contrainte que les autres n'ont pas : le compilateur RSC doit atteindre ces composants depuis la route qui les rend. C'est ce qui inscrit leurs frontières 'use client' au React Client Manifest de cette route. Sans quoi le rendu lève « Could not find the module … in the React Client Manifest ».
Alimenter le registre depuis instrumentation.js ne suffit pas : c'est une compilation distincte, avec son propre manifeste. Peu importe que le slot Symbol.for soit partagé, la référence client, elle, ne l'est pas.
D'où la règle : tout registre dont les entrées sont des composants doit être alimenté depuis le graphe RSC de la route qui les rend. Concrètement, un manifeste généré, importé par la route ou par le layout :
| Registre | Manifeste | Importé par |
|---|---|---|
__LRVO_PUBLIC_MODULE_PAGES__ | app/.lrvo/public-pages.server.js | app/lrvo/[...path]/page.js |
__LRVO_CUSTOM_BLOCKS__ | app/.lrvo/blocks.server.js | app/layout.js |
__LRVO_TEMPLATES__ | app/.lrvo/templates.server.js | app/layout.js |
Corollaire : ces registres deviennent locaux au graphe qui les alimente. getPublicModulePages() retourne un tableau vide hors de /lrvo/[...path].
Et un seul écrivain. Enregistrer à la fois au boot et depuis le graphe RSC laisserait la copie compilée hors condition react-server gagner une course au rechargement à chaud : la panne reviendrait par intermittence, ce qui est bien pire qu'une panne franche.
__LRVO_PUBLIC_CONTENT_RESOLVERS__ est le prochain sur la liste. Il est encore alimenté au boot par @lrvo/module-pages et @lrvo/module-posts, et il ne s'en sort que parce que PagePublicView et PostPublicView n'atteignent aucun .client.js en direct. Le premier ajouté rejouera exactement ce défaut, et appellera une sous-entrée ./public-content jumelle de ./public-page.
Ce que ces registres ne font pas
Ils ne franchissent pas la frontière processus. En déploiement multi-instance, chaque processus a son propre globalThis. Ce n'est pas un problème pour les registres alimentés au boot, identiques partout, mais c'en est un pour tout état accumulé à l'exécution. Le rate limit est concerné : ses compteurs sont locaux au processus.
Ils ne franchissent pas la frontière serveur/client. Le registre serveur des blocs et celui de l'éditeur sont deux registres distincts, alimentés par deux manifestes distincts. Le registre des templates n'a d'ailleurs délibérément pas d'équivalent client : un manifeste 'use client' tirerait les fichiers de templates dans le graphe client, et le composant client-compilé écraserait l'entrée RSC dans la même Map.
Ils ne valident pas à la lecture. La validation se fait à l'enregistrement, au boot, où l'erreur est immédiate et localisée. registerTemplate lève sur un kind invalide ou un Component absent plutôt que de laisser le premier rendu échouer en production.