Premier module
Écrire, construire, installer et vérifier un module @lrvo/module-* de bout en bout.
Un module est un package npm auto-découvert. C'est la voie à prendre dès qu'une fonctionnalité doit servir dans un deuxième projet.
Ce parcours construit un module @lrvo/module-blog minimal mais complet : table, permissions, page d'admin, entrée de navigation, route API.
La référence complète est dans Créer un module.
1. Créer le package
mkdir lrvo-module-blog
cd lrvo-module-blog
npm init -y
npm install --save-dev tsup
npm install @lrvo/core --save-peer{
"name": "@lrvo/module-blog",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": { "import": "./dist/index.js" },
"./client": { "import": "./dist/client.js" }
},
"files": ["dist", "README.md", "LICENSE"],
"scripts": {
"build": "tsup",
"prepublishOnly": "npm run build"
}
}Les deux entrées ne sont pas optionnelles. L'entrée principale tire du code serveur, incompatible avec le bundle navigateur ; l'entrée ./client ne contient que les composants.
Le module de ce tutoriel n'a qu'une partie admin. S'il servait aussi une page publique sous /lrvo/blog/…, il lui faudrait une troisième entrée, ./public-page : voir Créer un module.
// 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',
});2. Le point d'entrée
// src/index.js
export const manifest = {
name: '@lrvo/module-blog',
version: '1.0.0',
permissions: [
{ key: 'blog.view', name: 'Voir les billets', description: 'Consultation', group_name: 'Blog' },
{ key: 'blog.manage', name: 'Gérer les billets', description: 'Créer, modifier, supprimer', group_name: 'Blog' },
],
envVars: [],
};
export async function register() {
await import('./register-server.js');
}
export { createTables, dropTables } from './db.server.js';3. Les tables
// src/db.server.js
import { query, tableExists, create, find } from '@lrvo/core/database';
const TABLES = [
{
name: 'lrvo_blog_posts',
sql: `CREATE TABLE lrvo_blog_posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content JSONB DEFAULT '[]'::jsonb,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
},
];
export async function createTables() {
const created = [], 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`);
}
}
export const listPosts = () => find('lrvo_blog_posts', {}, { orderBy: 'created_at DESC', limit: 50 });
export const createPost = (data) => create('lrvo_blog_posts', data, { allowedColumns: ['id', 'title', 'slug', 'content'] });
export const countPosts = async () => (await listPosts()).length;Deux règles : préfixer les tables par lrvo_<module>_, et rendre createTables idempotent en testant tableExists.
4. La route API
// src/api.server.js
import { defineApiRoutes, apiSuccess, apiError } from '@lrvo/core/api';
import { listPosts, createPost } from './db.server.js';
import { generateId } from '@lrvo/core/users';
async function handleList() {
return apiSuccess({ posts: await listPosts() });
}
async function handleCreate(request) {
const body = await request.json();
if (!body.title?.trim()) return apiError('Bad Request', 'Le titre est requis.');
const post = await createPost({ id: generateId(), ...body });
return apiSuccess({ success: true, post, message: 'Billet créé.' });
}
export const routes = defineApiRoutes([
{ path: '/blog/posts', method: 'GET', handler: handleList, auth: 'admin', permission: 'blog.view' },
{ path: '/blog/posts', method: 'POST', handler: handleCreate, auth: 'admin', permission: 'blog.manage' },
]);L'authentification se déclare dans la route, jamais dans le handler.
5. Les enregistrements serveur
// src/register-server.js
import { registerNavSection, registerNavItem, registerPageMeta } from '@lrvo/core/features/admin';
import { registerApiRoutes } from '@lrvo/core/api';
import { registerWidgetFetcher } from '@lrvo/core/features/admin';
import { routes } from './api.server.js';
import { countPosts } from './db.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);
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));Aucun composant .client.js n'est importé ici. Ils vivent dans l'entrée client.
6. L'entrée client
// src/client.js
'use client';
import { registerPage, registerWidget } from '@lrvo/core/features/admin';
import { StatCard } from '@lrvo/core/shared/components';
import BlogAdminPage from './admin/BlogAdminPage.client.js';
function BlogWidget({ data, loading }) {
return <StatCard title="Billets" value={loading ? '-' : String(data?.count ?? 0)} loading={loading} />;
}
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });// src/admin/BlogAdminPage.client.js
'use client';
import { useEffect, useState } from 'react';
import { AdminHeader } from '@lrvo/core/features/admin/components';
import { Table } from '@lrvo/core/shared/components';
export default function BlogAdminPage() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/lrvo/api/blog/posts')
.then((r) => r.json())
.then((d) => setPosts(d.posts ?? []));
}, []);
return (
<div className="flex flex-col gap-6">
<AdminHeader title="Billets" description="Billets du blog." />
<Table
columns={[
{ key: 'title', label: 'Titre', render: (row) => <strong>{row.title}</strong> },
{ key: 'slug', label: 'Slug' },
{ key: 'created_at', label: 'Créé le', type: 'date' },
]}
rows={posts}
/>
</div>
);
}Ici le fetch est légitime : il part du navigateur vers l'API. L'interdiction de self-fetch ne vise que le code serveur.
7. Construire et vérifier
npm run buildOuvrir dist/ et contrôler deux choses :
- Aucun fichier ne contient de JSX brut. Chercher un
<suivi d'une majuscule : tout doit être en appelsjsx(...). - Les fichiers issus de
.client.jscommencent bien par'use client'.
8. Installer dans un projet
En local, avant publication :
cd ../mon-projet
npm install ../lrvo-module-blog
npx lrvo-db init
npm run devpostinstall déclenche lrvo-modules sync, qui régénère les manifestes. lrvo-db init crée la table et attribue les permissions au rôle admin.
À la relance, la section « Blog » apparaît dans la sidebar, la page répond sous /admin/blog, le widget est sur le tableau de bord, et l'API sous /lrvo/api/blog/posts.
Si rien n'apparaît
- Le module est-il dans le manifeste ? Ouvrir
app/.lrvo/modules.generated.js. - Le composant client est-il rendu ? Vérifier que
<LrvoModulesClient />est dans le JSX deapp/layout.js, pas seulement importé. - La sous-entrée
./clientest-elle déclarée ? Sans elle,registerPageetregisterWidgetne tournent jamais. - Les tables existent-elles ? Relancer
npx lrvo-db init. - La permission est-elle attribuée ? Vérifier dans
/admin/roles.
La liste complète est dans Cycle de vie d'un module.
9. Publier
npm publishVoir Publication.
Étape suivante
Déploiement met le projet en production.