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
This commit is contained in:
2026-04-25 14:34:43 -04:00
parent c793bc418c
commit 1b85d6fac7
3 changed files with 76 additions and 20 deletions
+15 -8
View File
@@ -15,24 +15,31 @@ Aucun fichier de configuration manuelle. La plateforme découvre les modules par
## Découverte ## Découverte
Les modules sont activés via un **manifeste statique** généré par la CLI `zen-modules sync` dans le projet consommateur, à `app/.zen/modules.generated.js`. Ce fichier contient des `import * as ...` pour chaque package détecté et appelle `register()` au top level : 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 ```js
// app/.zen/modules.generated.js — AUTO-GÉNÉRÉ // app/.zen/modules.generated.js — AUTO-GÉNÉRÉ (serveur)
import * as m0_zen_module_posts from '@zen/module-posts'; import * as m0_zen_module_posts from '@zen/module-posts';
export const modules = [ export const modules = [
{ name: '@zen/module-posts', exports: m0_zen_module_posts }, { name: '@zen/module-posts', exports: m0_zen_module_posts },
]; ];
await Promise.all(modules.map(m => m.exports.register?.())); await Promise.all(modules.map(m => m.exports.register?.()));
``` ```
Le manifeste est importé deux fois : ```js
- **Côté serveur** par `instrumentation.js` qui le passe à `initializeZen({ modules })`. // app/.zen/modules.client.js — AUTO-GÉNÉRÉ (client)
- **Côté client** par `app/layout.js` (side-effect import). 'use client';
import * as m0_zen_module_posts from '@zen/module-posts';
m0_zen_module_posts.register?.();
export {};
```
Cette importation statique permet à Turbopack/Webpack d'analyser le graphe complet des modules — JSX, `next/headers`, `next/navigation`, frontières `'use client'` — exactement comme pour le code de l'application elle-même. 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 ### Critères de détection
+59 -11
View File
@@ -20,7 +20,8 @@ import { resolve, dirname } from 'node:path';
import { step, done, warn, fail } from '@zen/core/shared/logger'; import { step, done, warn, fail } from '@zen/core/shared/logger';
import { findInstalledModuleNames } from './discover.server.js'; import { findInstalledModuleNames } from './discover.server.js';
const OUTPUT_PATH = 'app/.zen/modules.generated.js'; const OUTPUT_SERVER = 'app/.zen/modules.generated.js';
const OUTPUT_CLIENT = 'app/.zen/modules.client.js';
function safeIdentifier(name, idx) { function safeIdentifier(name, idx) {
// `@zen/module-posts` → `m0_zen_module_posts` // `@zen/module-posts` → `m0_zen_module_posts`
@@ -28,7 +29,7 @@ function safeIdentifier(name, idx) {
return `m${idx}_${cleaned}`; return `m${idx}_${cleaned}`;
} }
function renderManifest(names) { function renderServerManifest(names) {
const header = '// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.\n'; const header = '// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.\n';
if (names.length === 0) { if (names.length === 0) {
@@ -52,12 +53,48 @@ function renderManifest(names) {
'];', '];',
'', '',
'// Top-level await : déclenche register() de chaque module au moment de l\'import', '// Top-level await : déclenche register() de chaque module au moment de l\'import',
'// de ce manifeste, des deux côtés (server bundle + client bundle).', '// côté serveur. instrumentation.js importe ce fichier au boot.',
'await Promise.all(modules.map(m => m.exports.register?.()));', 'await Promise.all(modules.map(m => m.exports.register?.()));',
'', '',
].join('\n'); ].join('\n');
} }
function renderClientManifest(names) {
// Doit être un Client Component pour que les side-effects (registerPage, etc.)
// s\'exécutent dans le bundle browser au moment de l\'hydratation. app/layout.js
// (Server Component) importe ce fichier ; Next.js bascule sur le bundle client
// et exécute les register() lors du chargement de la page.
const header = [
'// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.',
"'use client';",
'',
].join('\n');
if (names.length === 0) {
return header + '\nexport {};\n';
}
const imports = names
.map((name, i) => `import * as ${safeIdentifier(name, i)} from '${name}';`)
.join('\n');
const calls = names
.map((_, i) => `${safeIdentifier(names[i], i)}.register?.();`)
.join('\n');
return [
header,
imports,
'',
'// Fire and forget — registerPage/registerWidget sont synchrones, le reste',
'// (init asynchrone éventuel) ne bloque pas le rendu.',
calls,
'',
'export {};',
'',
].join('\n');
}
async function readIfExists(path) { async function readIfExists(path) {
try { try {
return await readFile(path, 'utf-8'); return await readFile(path, 'utf-8');
@@ -66,20 +103,31 @@ async function readIfExists(path) {
} }
} }
async function writeIfChanged(path, contents) {
const prev = await readIfExists(path);
if (prev === contents) return false;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf-8');
return true;
}
async function syncCommand({ cwd = process.cwd() } = {}) { async function syncCommand({ cwd = process.cwd() } = {}) {
const names = await findInstalledModuleNames({ cwd }); const names = await findInstalledModuleNames({ cwd });
const outputPath = resolve(cwd, OUTPUT_PATH); const count = `(${names.length} module${names.length === 1 ? '' : 's'})`;
const next = renderManifest(names);
const prev = await readIfExists(outputPath);
if (prev === next) { const serverPath = resolve(cwd, OUTPUT_SERVER);
step(`zen-modules: ${OUTPUT_PATH} already up to date (${names.length} module${names.length === 1 ? '' : 's'})`); const clientPath = resolve(cwd, OUTPUT_CLIENT);
const wroteServer = await writeIfChanged(serverPath, renderServerManifest(names));
const wroteClient = await writeIfChanged(clientPath, renderClientManifest(names));
if (!wroteServer && !wroteClient) {
step(`zen-modules: manifests already up to date ${count}`);
return; return;
} }
await mkdir(dirname(outputPath), { recursive: true }); if (wroteServer) done(`zen-modules: wrote ${OUTPUT_SERVER} ${count}`);
await writeFile(outputPath, next, 'utf-8'); if (wroteClient) done(`zen-modules: wrote ${OUTPUT_CLIENT} ${count}`);
done(`zen-modules: wrote ${OUTPUT_PATH} (${names.length} module${names.length === 1 ? '' : 's'})`);
for (const name of names) step(`${name}`); for (const name of names) step(`${name}`);
} }
+2 -1
View File
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { resolve, join } from 'node:path'; import { resolve, join } from 'node:path';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { warn } from '@zen/core/shared/logger'; import { info, warn } from '@zen/core/shared/logger';
import { registerModule } from './registry.js'; import { registerModule } from './registry.js';
/** /**
@@ -43,6 +43,7 @@ export function registerModules(modules) {
createTables: ex.createTables, createTables: ex.createTables,
dropTables: ex.dropTables, dropTables: ex.dropTables,
}); });
info(`zen-modules: registered ${ex.manifest.name}@${ex.manifest.version ?? '?'}`);
} }
} }