Files
core/docs/MODULES.md
T
hykocx d6befcfa91 docs(modules): update module structure to reflect tsup build workflow
- fix entry point path from `index.js` to `src/index.js` compiled to `dist/index.js`
- update file tree to show `src/` as source root and `dist/` as published output
- update `package.json` example with correct `main`, `exports`, `files`, and build scripts
- add explanation of mandatory pre-build step before publish
- document why JSX must be compiled (turbopackIgnore + Node native resolution)
- add minimal `tsup.config.js` example with `bundle: false` and JSX loader
- add verification step to check compiled output before publishing
2026-04-25 13:13:07 -04:00

11 KiB

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.

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

Au boot et au lancement de zen-db init, le core scanne dependencies + devDependencies du package.json du projet consommateur et charge 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"]

Pour chaque module trouvé, le core vérifie qu'il exporte les bons symboles, puis l'enregistre.


Forme d'un module

Le point d'entrée du package (main ou exports["."]) doit exporter :

// @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

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

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

// 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

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>/...

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 } :

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

// 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), 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.

// 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 :

{
  "main": "./dist/index.js",
  "exports": { ".": { "import": "./dist/index.js" } },
  "files": ["dist", "README.md", "LICENSE"],
  "scripts": {
    "build": "tsup",
    "prepublishOnly": "npm run build"
  }
}

Build obligatoire avant publish

Tout module @zen/module-* doit être pré-compilé avant publication. Les fichiers du module ne doivent jamais contenir de JSX brut au runtime — le JSX doit être transformé en React.createElement (ou équivalent) par le build du module lui-même.

Pourquoi. Le core découvre les modules via import(/* turbopackIgnore */ name) dans discover.server.js ; le commentaire turbopackIgnore est nécessaire pour empêcher Turbopack/Webpack de tenter de bundler un nom de package dynamique. Conséquence : tout l'arbre d'imports transitifs du module est résolu et exécuté par Node natif, sans transformation JSX. Un fichier .client.js qui contient <MyComponent /> au runtime fait planter register() avec Unexpected token '<'.

Le module doit donc utiliser le même setup que @zen/core : un build tsup avec bundle: false, qui transforme JSX → JS standard tout en préservant la structure de fichiers (un fichier d'entrée → un fichier de sortie). Le 'use client' est conservé en haut des fichiers .client.js compilés, ce qui permet à Next.js de respecter les frontières RSC quand le projet consommateur bundle le module.

Exemple de tsup.config.js minimal

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.jsinitializeZen() 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