cb547f6400
- document `.server.js` suffix requirement for node-only imports in DEV.md - add client-safe subentries table and server-only barrel warnings in MODULES.md - fix `crud.js` and `database/index.js` to import from `db.server.js` - replace `createRequire` with `pathToFileURL` in `discover.server.js` for ESM-only modules - update admin navigation and registry to use safe client-compatible imports - bump version to 1.4.132
389 lines
15 KiB
Markdown
389 lines
15 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 '@zen/module-posts/client';
|
|
export {};
|
|
```
|
|
|
|
Le manifeste client importe la **sous-entrée `./client`** de chaque module (et non son main entry). C'est essentiel : le main entry tire `createTables` / `registerApiRoutes` / la chaîne `register-server.js` qui dépend de `pg`, `fs`, `next/headers`, etc. — code serveur incompatible avec le bundle browser. Seuls les modules qui exposent `./client` dans leur `package.json#exports` sont inclus dans le manifeste client ; les modules purement back-end (API/DB) sont absents du bundle browser.
|
|
|
|
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
|
|
|
|
Un module avec partie admin expose **deux entrées** dans son `package.json#exports` :
|
|
|
|
```json
|
|
{
|
|
"exports": {
|
|
".": { "import": "./dist/index.js" },
|
|
"./client": { "import": "./dist/client.js" }
|
|
}
|
|
}
|
|
```
|
|
|
|
| Entrée | Bundle | Contenu |
|
|
|--------|--------|---------|
|
|
| `.` (main) | serveur | `manifest`, `register()`, `createTables`, `dropTables`. Le `register()` tire la chaîne `register-server.js` (API routes, navigation, fetchers, storage prefixes, hooks DB). |
|
|
| `./client` | client | `'use client'` ; uniquement `registerPage({ Component })` et `registerWidget({ Component })`. Aucun import vers `pg`, `fs`, `.server.js` ou `register-server.js`. |
|
|
|
|
Le manifeste serveur importe `@zen/module-X` (main) ; le manifeste client importe `@zen/module-X/client`. La séparation est obligatoire : tout ce qui est statiquement importé depuis l'entrée client finit dans le bundle browser, et `pg` / `fs` / `next/headers` y crashent.
|
|
|
|
```js
|
|
// src/index.js (entrée serveur)
|
|
export const manifest = { /* ... */ };
|
|
export async function register() { await import('./register-server.js'); }
|
|
export { createTables, dropTables } from './db.server.js';
|
|
```
|
|
|
|
```js
|
|
// src/register-server.js (server-only — appelée par register())
|
|
import { registerNavItem, registerNavSection } from '@zen/core/features/admin';
|
|
import { registerApiRoutes } from '@zen/core/api';
|
|
import { routes } from './api.server.js';
|
|
// PAS d'import de Component .client.js ici — ils vivent dans client.js.
|
|
registerNavSection({ /* ... */ });
|
|
registerApiRoutes(routes);
|
|
```
|
|
|
|
```js
|
|
// src/client.js (entrée client — chargée par le manifeste client uniquement)
|
|
'use client';
|
|
import { registerPage } from '@zen/core/features/admin';
|
|
import BlogAdminPage from './admin/BlogAdminPage.client.js';
|
|
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
|
|
```
|
|
|
|
### Sous-entrées `@zen/core/*` safe-pour-client
|
|
|
|
Le `client.js` d'un module ne doit jamais importer un barrel mixte (un barrel qui ré-exporte du code serveur à côté de constantes client-safe). Si on le fait, Turbopack/Webpack tracent toute la chaîne d'imports statiques et ramènent `pg`/`fs`/`next/headers` dans le bundle browser — qui crashe avec `Module not found: Can't resolve 'dns'` ou équivalent.
|
|
|
|
Sous-entrées explicitement safe pour un import depuis un fichier `'use client'` :
|
|
|
|
| Sous-entrée | Contenu |
|
|
|-------------|---------|
|
|
| `@zen/core/users/constants` | `PERMISSIONS`, `PERMISSION_DEFINITIONS`, `getPermissionGroups` — aucun import serveur. |
|
|
| `@zen/core/features/admin` | `registerPage`, `registerWidget`, `registerNavItem`, `registerNavSection`, `buildNavigationSections`. Neutre côté boundary. |
|
|
| `@zen/core/features/admin/components` | Composants client. |
|
|
| `@zen/core/themes` | Tokens/utilitaires de thème. |
|
|
| `@zen/core/toast` | API toast côté client. |
|
|
| `@zen/core/shared/icons` | Composants d'icônes. |
|
|
| `@zen/core/shared/components` | Composants partagés. |
|
|
|
|
Tout ce qui n'est pas dans cette liste — en particulier `@zen/core/users` (barrel complet), `@zen/core/database`, `@zen/core/api`, `@zen/core/storage` — est du code serveur. Ne JAMAIS l'importer depuis un fichier `'use client'` ou un fichier transitivement importé par un `'use client'`.
|
|
|
|
---
|
|
|
|
## 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 |
|