Files
core/docs/MODULES.md
T
hykocx 1b85d6fac7 docs(modules): add client manifest generation and update discovery docs
- introduce `OUTPUT_CLIENT` constant and `renderClientManifest` for `'use client'` bundle
- rename `renderManifest` to `renderServerManifest` for clarity
- update `sync` command to write both server and client manifests
- update `findInstalledModuleNames` to support custom package path resolution
- rewrite MODULES.md to explain dual-manifest architecture and client hydration rationale
2026-04-25 14:34:43 -04:00

340 lines
12 KiB
Markdown

# Modules externes `@zen/module-*`
Un **module** est un package npm distinct qui ajoute des fonctionnalités à un projet construit avec `@zen/core` — sans aucune modification de code dans le projet consommateur.
```bash
npm install @zen/module-billing
# ajouter les variables d'env documentées dans le README du module
npx zen-db init # crée les tables du module et seed ses permissions
npm run dev # tout est câblé : pages admin, sidebar, widgets, API, /zen/<module>/...
```
Aucun fichier de configuration manuelle. La plateforme découvre les modules par scan des dépendances `package.json`.
---
## Découverte
Les modules sont activés via deux **manifestes statiques** générés par la CLI `zen-modules sync` dans le projet consommateur :
- `app/.zen/modules.generated.js` — manifeste serveur (importé par `instrumentation.js`).
- `app/.zen/modules.client.js` — manifeste client (`'use client'`, importé par `app/layout.js`).
Les deux fichiers contiennent les mêmes `import * as ... from '@zen/module-X'` mais déclenchent `register()` dans leur bundle respectif. La séparation est nécessaire parce que `app/layout.js` est un Server Component : son graphe d'imports tourne dans le bundle serveur. Pour que les `registerPage()` / `registerWidget()` peuplent aussi le registre côté browser au moment de l'hydratation, il faut un fichier `'use client'` dédié.
```js
// app/.zen/modules.generated.js — AUTO-GÉNÉRÉ (serveur)
import * as m0_zen_module_posts from '@zen/module-posts';
export const modules = [
{ name: '@zen/module-posts', exports: m0_zen_module_posts },
];
await Promise.all(modules.map(m => m.exports.register?.()));
```
```js
// app/.zen/modules.client.js — AUTO-GÉNÉRÉ (client)
'use client';
import * as m0_zen_module_posts from '@zen/module-posts';
m0_zen_module_posts.register?.();
export {};
```
L'importation statique (le `import * as ...`) permet à Turbopack/Webpack d'analyser le graphe complet du module — JSX, `next/headers`, `next/navigation`, frontières `'use client'` — exactement comme pour le code de l'application elle-même.
### Critères de détection
`zen-modules sync` scanne `dependencies` + `devDependencies` du `package.json` du projet et inclut tout package matchant :
- **Préfixe officiel** : `@zen/module-*`
- **Préfixe non-scopé** : `zen-module-*`
- **Tiers** : tout package dont le `package.json` contient `"keywords": ["zen-module"]`
### Quand resync ?
Le template `@zen/start` câble la CLI dans :
```json
{
"scripts": {
"postinstall": "zen-modules sync",
"dev": "zen-modules sync && next dev",
"build": "zen-modules sync && next build"
}
}
```
`postinstall` couvre `npm install @zen/module-X`. Les hooks `dev`/`build` couvrent les retraits / changements de version qui ne déclenchent pas de re-install. La commande est idempotente — pas d'écriture si le contenu est identique.
`app/.zen/modules.generated.js` est gitignoré : régénéré localement, jamais commit.
---
## Forme d'un module
Le point d'entrée du package (`main` ou `exports["."]`) doit exporter :
```js
// @zen/module-blog/src/index.js (compilé vers dist/index.js par tsup)
export const manifest = {
name: '@zen/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: 'CRUD', group_name: 'Blog' },
],
envVars: [
{ key: 'BLOG_UPLOAD_DIR', required: false, description: 'Répertoire des médias' },
],
};
export async function register() {
// Tous les enregistrements runtime se font ici (voir API ci-dessous).
await import('./register-server.js');
}
export { createTables, dropTables } from './db.js';
```
| Export | Type | Obligatoire |
|--------|------|-------------|
| `manifest` | objet (voir ci-dessous) | oui |
| `register` | `() => void \| Promise<void>` | oui |
| `createTables` | `async () => { created?: string[], skipped?: string[] }` | si le module a des tables |
| `dropTables` | `async () => void` | si le module a des tables |
### Manifest
| Champ | Type | Description |
|-------|------|-------------|
| `name` | `string` | Nom du package (utilisé comme identifiant unique). |
| `version` | `string` | Version du module (logguée au boot). |
| `permissions` | `Array` | Permissions ajoutées au catalogue. Auto-attribuées au rôle `admin` au prochain `zen-db init`. |
| `envVars` | `Array` | Variables d'env du module ; les `required: true` absentes émettent un warning au boot. |
---
## API d'enregistrement
Toutes ces fonctions s'utilisent depuis le hook `register()` du module.
### Permissions
Déclarées dans `manifest.permissions`. Le core les enregistre automatiquement avant le seed BD et les attribue au rôle `admin`. À la connexion, l'admin peut les distribuer à d'autres rôles via `/admin/roles`.
### Sidebar admin
```js
import { registerNavSection, registerNavItem } from '@zen/core/features/admin';
registerNavSection({ id: 'blog', title: 'Blog', icon: 'Notebook01Icon', order: 40 });
registerNavItem({
id: 'blog-posts',
label: 'Billets',
icon: 'Notebook01Icon',
href: '/admin/blog',
sectionId: 'blog',
permission: 'blog.view',
});
```
### Pages admin
```js
import { registerPage } from '@zen/core/features/admin';
import BlogAdminPage from './admin/BlogAdminPage.client.js';
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
```
Rendue sous `/admin/blog`.
### Widgets dashboard
```js
// côté serveur
import { registerWidgetFetcher } from '@zen/core/features/admin';
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));
// côté client
'use client';
import { registerWidget } from '@zen/core/features/admin';
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });
```
### Routes API
```js
import { registerApiRoutes } from '@zen/core/api';
import { defineApiRoutes, apiSuccess } from '@zen/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 router applique automatiquement la session, le rate-limit et la vérification de permission. Les routes sont accessibles sous `/zen/api/*`.
| Champ `auth` | Comportement |
|--------------|--------------|
| `'public'` | Aucune session requise. |
| `'user'` | Session valide. |
| `'admin'` | Session avec permission `admin.access`. La permission granulaire `permission` est aussi vérifiée si fournie. |
### Pages publiques `/zen/<module>/...`
```js
import { registerPublicModulePage } from '@zen/core/public-pages';
import BlogPublicPage from './public/BlogPublicPage.js';
registerPublicModulePage({ moduleName: 'blog', Component: BlogPublicPage });
```
URL : `/zen/blog/<...>`. Le composant reçoit `{ params, segments }` :
```js
function BlogPublicPage({ params, segments }) {
// /zen/blog/post/abc-123 → segments = ['post', 'abc-123']
if (segments[0] === 'post') return <PostView id={segments[1]} />;
return <BlogIndex />;
}
```
Le namespace `api` est réservé aux routes API et ne peut être utilisé comme `moduleName`.
### Migrations BD
```js
// db.js
import { query, tableExists } from '@zen/core/database';
const TABLES = [
{ name: 'zen_blog_posts', sql: `CREATE TABLE zen_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`);
}
}
```
Convention : préfixer toutes les tables par `zen_<module>_` pour éviter les collisions.
---
## Frontières serveur/client
Comme pour le core (voir [DEV.md](DEV.md)), les fichiers du module portent les suffixes `.server.js` / `.client.js`. Le hook `register()` côté serveur est appelé par le core ; les enregistrements client (widgets, par ex.) doivent être triggés par un import dans le bundle client — typiquement via le composant client lui-même qui appelle `registerWidget()` à l'import.
```js
// Pattern recommandé pour un module avec partie client :
// src/register-server.js (importé par register())
import './admin/BlogAdminPage.client.js'; // chaîne d'imports vers client
import './widgets/BlogWidget.server.js';
// ... registerNavItem, registerPage, registerApiRoutes, etc.
```
Le bundle Next.js du projet consommateur traverse le graphe d'import et inclut les composants client dans le bundle client. Côté serveur, seules les fonctions et fetchers serveur sont chargés.
---
## Variables d'environnement
Toute variable requise par le module doit être déclarée dans `manifest.envVars` et documentée dans le `README.md` du module. Les variables `required: true` absentes génèrent un warning au boot — elles ne crashent pas le serveur, le module gère son propre fallback.
---
## Squelette minimal d'un module
```
@zen/module-blog/
├── package.json # name: "@zen/module-blog", main: "./dist/index.js"
├── tsup.config.js # build avec bundle: false, loader JSX, outbase: 'src'
├── README.md # documente les env vars et la configuration
├── src/
│ ├── index.js # exporte manifest, register, createTables, dropTables
│ ├── db.server.js # createTables/dropTables
│ ├── register-server.js # imports déclencheurs (chargé par register())
│ ├── api.server.js # routes API (registerApiRoutes)
│ ├── admin/
│ │ ├── BlogAdminPage.client.js # registerPage + composant
│ │ └── widgets/... # registerWidgetFetcher + registerWidget
│ └── public/
│ └── BlogPublicPage.js # registerPublicModulePage
└── dist/ # généré par `npm run build` — c'est ce qui est publié
```
Le champ `files` dans `package.json` publie **uniquement** `dist/`, `README.md` et `LICENSE` :
```json
{
"main": "./dist/index.js",
"exports": { ".": { "import": "./dist/index.js" } },
"files": ["dist", "README.md", "LICENSE"],
"scripts": {
"build": "tsup",
"prepublishOnly": "npm run build"
}
}
```
---
## Build avant publish
**Tout module `@zen/module-*` est pré-compilé avant publication**, comme `@zen/core` lui-même. Le build `tsup` avec `bundle: false` transforme le JSX, préserve les directives `'use client'` en haut des fichiers compilés, et garde un fichier d'entrée par fichier de sortie pour respecter les frontières RSC quand le projet consommateur bundle le module.
C'est ce qui permet au manifeste statique généré par `zen-modules sync` de simplement faire `import * as ... from '@zen/module-X'` et de laisser Turbopack/Webpack composer le reste : aucune transformation runtime n'est requise côté consommateur.
### Exemple de `tsup.config.js` minimal
```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 qu'un module est correctement compilé
Avant `npm publish`, ouvrir `dist/` et confirmer qu'aucun fichier ne contient de syntaxe JSX brute (chercher `<` suivi d'une majuscule). Tous les composants doivent apparaître sous forme d'appels `jsx(...)` ou `React.createElement(...)`.
---
## Cycle de vie complet
| Étape | Côté core | Côté module |
|-------|-----------|-------------|
| Install | — | `npm install @zen/module-X` |
| Configuration | — | Ajout des `envVars` au `.env` |
| Migration BD | `zen-db init` scanne, charge le module, registerPermissions(), seed, createTables() | `createTables()` exécuté |
| Boot serveur | `instrumentation.js``initializeZen()` scanne, charge, registerPermissions(), `register()` | `register()` exécuté côté serveur |
| Premier render client | bundle client traverse les imports → composants client enregistrés | `registerWidget()` exécuté côté client |
| Runtime | router dispatch les requêtes, admin résout les pages/widgets via le registre | aucun travail supplémentaire |