Créer un module

Écrire un package @lrvo/module-*, manifeste, hook register, frontières serveur/client, tables et build.

Un module est un package npm distinct qui ajoute des fonctionnalités à un projet construit avec @lrvo/core, sans aucune modification de code dans le projet consommateur.

npm install @lrvo/module-billing
# ajouter les variables d'environnement du module au .env
npx lrvo-db init   # crée les tables du module et applique ses permissions
npm run dev        # pages admin, sidebar, widgets, API, tout est câblé

Aucun fichier de configuration manuelle : la plateforme découvre les modules en scannant les dépendances du package.json.

Ce guide couvre l'écriture d'un module. Pour la mécanique de découverte, de synchronisation et de boot, voir Cycle de vie d'un module.

Le contrat

Le point d'entrée du package doit exporter :

ExportTypeObligatoire
manifestobjetoui
register() => void | Promise<void>oui
createTablesasync () => { created?, skipped? }si le module a des tables
dropTablesasync () => voidsi le module a des tables
// @lrvo/module-blog — src/index.js
import { MODULE_VERSION } from './version.js';

export const manifest = {
  name: '@lrvo/module-blog',
  version: MODULE_VERSION,
  permissions: [
    { key: 'blog.view',   name: 'Voir les billets',  description: 'Consultation', group_name: 'Blog' },
    { key: 'blog.manage', name: 'Gérer les billets', description: 'CRUD',         group_name: 'Blog' },
  ],
  envVars: [
    { key: 'LRVO_MODULE_BLOG',            required: true,  description: 'Active le module blog (true / false).' },
    { key: 'LRVO_MODULE_BLOG_UPLOAD_DIR', required: false, description: 'Répertoire des médias (défaut public/blog).' },
  ],
};

export async function register() {
  await import('./register-server.js');
}

export { createTables, dropTables } from './db.server.js';

Manifeste

ChampTypeRôle
namestringNom du package, identifiant unique du module.
versionstringVersion, journalisée au boot. Importe-la de src/version.js, généré par node scripts/sync-version.js : un littéral recopié à la main dérive dès la publication suivante, et lrvo-doctor le signale.
requiresCorestringVersion minimale de @lrvo/core. Voir Déclarer la version de core exigée.
permissionsArrayPermissions ajoutées au catalogue. Attribuées au rôle admin au prochain lrvo-db init.
envVarsArrayVariables du module. Voir Déclarer les variables d'environnement.

Déclarer les variables d'environnement

Déclare toutes les variables que le module lit, pas seulement celles qui n'ont pas de valeur par défaut. manifest.envVars n'est pas une liste de réglages obligatoires, c'est le catalogue par lequel lrvo-doctor reconnaît une clé du .env d'un projet. Une variable lue par le module mais absente du manifeste est dénoncée comme n'est déclarée ni par le core ni par un module installé chez tout projet qui la renseigne, ce qui est un faux positif désagréable et impossible à faire taire côté projet.

required répond à une seule question : le module peut-il fonctionner sans cette variable ?

requiredQuandEffet
trueAucun repli n'existe. L'interrupteur LRVO_MODULE_<NOM> du module, typiquement.Absente du .env, elle est bloquante au diagnostic.
falseUne valeur par défaut s'applique, ou la fonctionnalité est facultative.Jamais signalée absente. Reste au catalogue.

Une description en français, avec la valeur par défaut et les bornes quand il y en a. C'est le texte qu'affichent lrvo-doctor et le --fix.

Le core s'applique la même règle à lui-même : il déclare ses propres variables dans src/core/doctor/env.js, au même format à trois champs, et le catalogue du doctor est l'union des deux. Un module n'a donc pas à redéclarer une variable du core.

Une conséquence utile : le générateur de notes de version compare les clés de manifest.envVars entre deux versions. Une variable ajoutée au manifeste est annoncée toute seule dans le CHANGELOG.md du module, et lrvo-update la montre à qui met à jour. Un gabarit comme LRVO_MODULE_POSTS_TYPE_<KEY> en est exclu, puisqu'il ne nomme aucune variable réelle.

Frontières serveur et client

Un module avec une partie admin expose deux entrées, et une troisième s'il sert une page publique.

{
  "exports": {
    ".":             { "import": "./dist/index.js" },
    "./client":      { "import": "./dist/client.js" },
    "./public-page": { "import": "./dist/public-page.js" }
  }
}
EntréeBundleContenu
.serveurmanifest, register(), createTables, dropTables. Le register() tire la chaîne register-server.js : routes API, navigation, fetchers, préfixes de stockage, hooks base.
./clientclient'use client'. Uniquement registerPage({ Component }) et registerWidget({ Component }). Aucun import vers pg, fs, un .server.js ou register-server.js.
./public-pageserveur, graphe RSC de la routeLe seul registerPublicModulePage() du module. Pas de 'use client' sur le fichier lui-même : ses enfants peuvent l'être, c'est même tout l'intérêt.

La séparation est obligatoire : tout ce qui est importé statiquement depuis l'entrée client finit dans le bundle navigateur, où pg, fs et next/headers échouent.

Chaque entrée a son point d'accroche dans le projet hôte, et il n'est pas interchangeable : . est tiré par instrumentation.js, ./client par <LrvoModulesClient /> dans app/layout.js, ./public-page par app/lrvo/[...path]/page.js. Voir Cycle de vie d'un module.

Ne jamais déclarer "sideEffects": false dans le package.json d'un module : les manifestes générés reposent sur des imports dont l'effet est justement d'exister.

// src/register-server.js — appelé par register()
import { registerNavItem, registerNavSection, registerPageMeta } from '@lrvo/core/features/admin';
import { registerApiRoutes } from '@lrvo/core/api';
import { routes } from './api.server.js';

registerNavSection({ id: 'blog', title: 'Blog', icon: 'BookBookmark02Icon', order: 40 });
registerNavItem({ id: 'blog-posts', label: 'Billets', icon: 'BookBookmark02Icon', href: '/admin/blog', sectionId: 'blog', permission: 'blog.view' });
registerPageMeta({ slug: 'blog', title: 'Blog', description: 'Gestion des billets' });
registerApiRoutes(routes);
// src/client.js — chargé par le manifeste client uniquement
'use client';
import { registerPage } from '@lrvo/core/features/admin';
import BlogAdminPage from './admin/BlogAdminPage.client.js';

registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });

Aucun composant .client.js n'est importé depuis register-server.js, et aucun fichier serveur depuis client.js.

Sous-entrées sûres côté client

Le client.js ne doit jamais importer un barrel mixte. Le catalogue complet est dans Points d'entrée.

En résumé, sont sûrs : @lrvo/core/users/constants, @lrvo/core/features/admin, .../admin/components, @lrvo/core/media-registry/client, @lrvo/core/blocks-registry/client, .../fields-ui, @lrvo/core/themes, @lrvo/core/toast, @lrvo/core/shared/icons, .../components, .../hooks.

Ne le sont pas : @lrvo/core/users complet, @lrvo/core/database, @lrvo/core/api, @lrvo/core/storage.

Pas de next/headers au top-level

Tout fichier atteignable depuis register() ne doit pas importer next/headers ni next/navigation au top-level. L'import doit être lazy dans le handler. La raison est détaillée dans Frontières serveur/client.

export async function handleSomething(request) {
  const { cookies } = await import('next/headers');
}

Enregistrer des fonctionnalités

Tous ces appels se font depuis register-server.js ou client.js, selon leur côté.

Identique aux extensions in-projet. Voir Étendre l'admin.

registerNavSection({ id: 'blog', title: 'Blog', icon: 'BookBookmark02Icon', order: 40 });
registerNavItem({ id: 'blog-posts', label: 'Billets', icon: 'BookBookmark02Icon', href: '/admin/blog', sectionId: 'blog', permission: 'blog.view' });

Widgets

// serveur
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));

// client
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });

Routes API

import { registerApiRoutes, defineApiRoutes } from '@lrvo/core/api';

const routes = defineApiRoutes([
  { path: '/blog/posts',     method: 'GET',  handler: handleListPosts,  auth: 'admin', permission: 'blog.view' },
  { path: '/blog/posts',     method: 'POST', handler: handleCreatePost, auth: 'admin', permission: 'blog.manage' },
  { path: '/blog/posts/:id', method: 'GET',  handler: handleGetPost,    auth: 'public' },
]);

registerApiRoutes(routes);

Le routeur applique la session, la limitation de débit et la vérification de permission. Les routes sont servies sous /lrvo/api/*. Voir Ajouter une route API.

Pages publiques du module

L'appel va dans src/public-page.js, exposé par la sous-entrée ./public-page. Jamais dans register-server.js.

// src/public-page.js
import { registerPublicModulePage } from '@lrvo/core/public-pages';
import BlogPublicPage from './public/BlogPublicPage.server.js';

registerPublicModulePage({ moduleName: 'blog', Component: BlogPublicPage });

Servies sous /lrvo/blog/…. Le composant reçoit { params, searchParams, segments } et fait son propre routage. searchParams porte la chaîne de requête résolue, ce qui permet de lire un retour de redirection externe comme ?paiement=succes. Le moduleName api est réservé.

La raison du placement : register-server.js est atteint depuis instrumentation.js, une compilation distincte de celle de la route. Un composant enregistré là échoue au rendu dès qu'il atteint un fichier 'use client', sur « Could not find the module … in the React Client Manifest ». La sous-entrée ./public-page, elle, est référencée par app/.lrvo/public-pages.server.js que la route importe : le compilateur RSC y arrive donc depuis la route, et les frontières client entrent au manifeste. Détail dans Pages publiques.

Déclare aussi requiresCore dans le manifeste : sur un core trop ancien pour générer ce manifeste, personne n'enregistre la page et elle répond 404 sans rien dire.

Contenu public à la racine

Pour servir du contenu directement sous /<slug>, voir Contenu public et templates.

Médias, SEO, blocs

Migrations

Le module possède ses tables et leur cycle de vie.

// src/db.server.js
import { query, tableExists } from '@lrvo/core/database';

const TABLES = [
  { name: 'lrvo_blog_posts', sql: `CREATE TABLE lrvo_blog_posts (…)` },
];

export async function createTables() {
  const created = [];
  const skipped = [];
  for (const t of TABLES) {
    if (await tableExists(t.name)) { skipped.push(t.name); continue; }
    await query(t.sql);
    created.push(t.name);
  }
  return { created, skipped };
}

export async function dropTables() {
  for (const t of [...TABLES].reverse()) {
    await query(`DROP TABLE IF EXISTS "${t.name}" CASCADE`);
  }
}

Deux règles :

  • Préfixer les tables par lrvo_<module>_, pour éviter toute collision avec le core ou un autre module.
  • createTables doit être idempotent, en testant tableExists. lrvo-db init est relancé à chaque ajout de module.

dropTables parcourt la liste à l'envers, pour que les tables dépendantes tombent avant celles dont elles dépendent.

Faire évoluer le schéma

createTables() crée le schéma initial, rien de plus. Sur une base où la table existe déjà, il court-circuite : une colonne ajoutée après coup n'y arrivera jamais.

Toute évolution passe donc par une migration versionnée, dans src/migrations.js :

// src/migrations.js
export const migrations = [
  {
    id: '001-add-excerpt',
    description: 'Ajoute la colonne excerpt.',
    up: async (ctx) => {
      await ctx.sql(`ALTER TABLE lrvo_blog_posts ADD COLUMN excerpt TEXT`);
    },
  },
];
// src/index.js
export { createTables, dropTables } from './db.server.js';
export { migrations } from './migrations.js';

Ne pas glisser un ALTER TABLE … IF NOT EXISTS dans createTables(). C'est ce qui a produit, dans les modules officiels, une accumulation de correctifs rejoués à chaque démarrage pour l'éternité, marqués LEGACY dans le code. Une migration s'applique une fois et cesse.

Les pièges à connaître, notamment l'obligation d'utiliser ctx.tableExists plutôt que la fonction importée, sont dans Écrire une migration.

Déclarer la version de core exigée

export const manifest = {
  name: '@lrvo/module-blog',
  version: MODULE_VERSION,
  requiresCore: '1.0.57',
};

requiresCore est un plancher, pas une plage. Il doit correspondre à la version de core qui a introduit la sous-entrée la plus récente utilisée par le module. Le déclarer permet à lrvo-update de résoudre un jeu de versions cohérent, et au boot de refuser une combinaison inexécutable avec un message qui nomme le module.

Un module qui expose ./public-page doit impérativement le déclarer. Sur un core qui ne sait pas générer app/.lrvo/public-pages.server.js, rien n'enregistre la page : elle répondrait 404 sans le moindre message, ce qui est bien pire qu'un refus au boot.

Le peerDependencies du package.json porte la même contrainte pour npm :

"peerDependencies": { "@lrvo/core": ">=1.0.57 <2.0.0" }

Classes Tailwind

Aucune action requise. Le core.css importé par le projet déclare une directive @source qui couvre node_modules/@lrvo/module-*/dist/**/*.js. Les classes utilisées dans les composants du module sont générées automatiquement, y compris les variantes responsives et les valeurs arbitraires.

Deux conditions : le package est publié sous le nom @lrvo/module-<nom>, et les fichiers compilés sont dans dist/. Voir Styles.

Écrire des jetons, pas des couleurs

Un module s'affiche dans des projets qui ont chacun leur thème. Ses composants écrivent donc les jetons du core (bg-surface, text-fg-muted, border-line, bg-primary text-primary-fg, text-danger), jamais une nuance de palette ni une variante dark: de couleur. Sinon, le module reste noir et blanc au milieu d'un admin passé à la couleur de la marque. Voir Design system.

Le core fournit le même garde-fou que pour lui même, à lancer sur le dist/ du module :

node ../core/scripts/check-colors.js dist

Une couleur choisie par l'utilisateur, stockée en base, est une donnée : elle s'affiche telle quelle, en style inline, et le garde-fou ne la voit pas. Une palette catégorielle qui distingue des types doit être déclarée comme exception dans le script, avec sa raison.

Squelette minimal

@lrvo/module-blog/
├── package.json          name: "@lrvo/module-blog", main: "./dist/index.js"
├── tsup.config.js
├── README.md             installation, activation, lien vers la documentation
├── scripts/
│   └── sync-version.js       génère src/version.js depuis package.json#version
├── src/
│   ├── index.js              manifest, register, createTables, dropTables
│   ├── version.js            généré — MODULE_VERSION, lu par le manifeste
│   ├── client.js             'use client' — registerPage, registerWidget
│   ├── public-page.js        registerPublicModulePage, lu depuis le graphe RSC
│   ├── db.server.js          createTables / dropTables
│   ├── register-server.js    enregistrements serveur, chargé par register()
│   ├── api.server.js         routes API
│   ├── admin/
│   │   └── BlogAdminPage.client.js
│   └── public/
│       └── BlogPublicPage.server.js
└── dist/                 généré par le build, c'est ce qui est publié
{
  "main": "./dist/index.js",
  "exports": {
    ".":             { "import": "./dist/index.js" },
    "./client":      { "import": "./dist/client.js" },
    "./public-page": { "import": "./dist/public-page.js" }
  },
  "files": ["dist", "README.md", "LICENSE"],
  "scripts": {
    "build": "tsup",
    "sync:version": "node ./scripts/sync-version.js",
    "prepublishOnly": "node ./scripts/sync-version.js --check && npm run build",
    "audit": "npm audit --omit=dev --omit=peer"
  },
  "peerDependencies": {
    "@lrvo/core": ">=1.0.70",
    "next": ">=14.0.0",
    "react": "^19.0.0"
  }
}

scripts/sync-version.js est vendu à l'identique dans tous les dépôts de l'écosystème : copie-le depuis @lrvo/core. Le --check en prepublishOnly empêche de publier un src/version.js périmé.

Où vit la documentation d'un module

La documentation d'usage d'un module officiel vit dans le core, sous docs/06-modules/. Le README.md du module reste court : installation, activation, et un lien vers cette section.

La raison tient au point de lecture. Le champ files d'un module ne publie que dist, README.md et LICENSE : un dossier docs/ dans le dépôt du module n'atteint jamais le projet qui l'installe. La documentation du core, elle, est livrée sous node_modules/@lrvo/core/dist/docs/. Un projet y trouve donc la plateforme et ses modules au même endroit, hors ligne.

Les conventions de code, de rédaction, de commit et de publication sont communes à tout l'écosystème et vivent dans Contribution. Elles ne se recopient pas dans le dépôt d'un module.

Pour un module tiers, publié hors de cet écosystème, le README.md reste le seul point d'entrée : le rendre autonome.

Dépendances d'un module

Trois règles, qui déterminent combien de travail coûtera la prochaine alerte de sécurité.

Tout ce que le site fournit déjà va en peerDependencies. @lrvo/core, next et react bien sûr, mais aussi une librairie lourde que Next installe de son côté. @lrvo/module-medias déclare sharp en peer pour cette raison : le site n'en garde qu'une copie, dans une version qu'il contrôle. Un module qui la mettrait en dependencies en imposerait une seconde, à corriger séparément.

Ce qui reste en dependencies est la vraie surface du module. C'est ce que npm run audit inspecte, et le seul endroit où une alerte se corrige dans le dépôt du module. Trois des quatre modules officiels ont zéro dependencies.

Aucun bloc overrides. npm ne l'honore que depuis le package.json racine du projet installé : un overrides publié dans un module est ignoré par les sites. Le bloc unique de l'écosystème vit dans @lrvo/start. Voir Sécurité.

Build avant publication

Tout module est pré-compilé avant publication, comme @lrvo/core. Le build tsup avec bundle: false transforme le JSX, préserve les directives 'use client' en tête des fichiers compilés, et garde un fichier de sortie par fichier d'entrée pour respecter les frontières RSC quand le projet consommateur bundle le module.

C'est ce qui permet au manifeste statique de faire un simple import * as et de laisser Turbopack composer le reste : aucune transformation runtime n'est requise côté consommateur.

// tsup.config.js
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/**/*.js', 'src/**/*.jsx'],
  format: ['esm'],
  outDir: 'dist',
  outbase: 'src',
  bundle: false,
  splitting: false,
  clean: true,
  loader: { '.js': 'jsx' },
  jsx: 'automatic',
});

Vérifier la compilation

Avant npm publish, ouvrir dist/ et confirmer qu'aucun fichier ne contient de JSX brut : chercher un < suivi d'une majuscule. Tous les composants doivent apparaître sous forme d'appels jsx(...) ou React.createElement(...).

Vérifier aussi que les fichiers issus de .client.js commencent bien par 'use client'.

Publier

Même procédure que le core. Voir Publication.

Sur cette page