From 1b85d6fac75df60e386c18489b7a3a41dde3f8fc Mon Sep 17 00:00:00 2001 From: Hyko Date: Sat, 25 Apr 2026 14:34:43 -0400 Subject: [PATCH] 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 --- docs/MODULES.md | 23 ++++++---- src/core/modules/cli.js | 70 ++++++++++++++++++++++++----- src/core/modules/discover.server.js | 3 +- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/docs/MODULES.md b/docs/MODULES.md index 636384e..0ea87a9 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -15,24 +15,31 @@ Aucun fichier de configuration manuelle. La plateforme découvre les modules par ## 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 -// 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'; - export const modules = [ { name: '@zen/module-posts', exports: m0_zen_module_posts }, ]; - await Promise.all(modules.map(m => m.exports.register?.())); ``` -Le manifeste est importé deux fois : -- **Côté serveur** par `instrumentation.js` qui le passe à `initializeZen({ modules })`. -- **Côté client** par `app/layout.js` (side-effect import). +```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 {}; +``` -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 diff --git a/src/core/modules/cli.js b/src/core/modules/cli.js index 1090a87..3350724 100644 --- a/src/core/modules/cli.js +++ b/src/core/modules/cli.js @@ -20,7 +20,8 @@ import { resolve, dirname } from 'node:path'; import { step, done, warn, fail } from '@zen/core/shared/logger'; 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) { // `@zen/module-posts` → `m0_zen_module_posts` @@ -28,7 +29,7 @@ function safeIdentifier(name, idx) { 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'; 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', - '// 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?.()));', '', ].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) { try { 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() } = {}) { const names = await findInstalledModuleNames({ cwd }); - const outputPath = resolve(cwd, OUTPUT_PATH); - const next = renderManifest(names); - const prev = await readIfExists(outputPath); + const count = `(${names.length} module${names.length === 1 ? '' : 's'})`; - if (prev === next) { - step(`zen-modules: ${OUTPUT_PATH} already up to date (${names.length} module${names.length === 1 ? '' : 's'})`); + const serverPath = resolve(cwd, OUTPUT_SERVER); + 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; } - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, next, 'utf-8'); - done(`zen-modules: wrote ${OUTPUT_PATH} (${names.length} module${names.length === 1 ? '' : 's'})`); + if (wroteServer) done(`zen-modules: wrote ${OUTPUT_SERVER} ${count}`); + if (wroteClient) done(`zen-modules: wrote ${OUTPUT_CLIENT} ${count}`); for (const name of names) step(` → ${name}`); } diff --git a/src/core/modules/discover.server.js b/src/core/modules/discover.server.js index a244784..39efe5d 100644 --- a/src/core/modules/discover.server.js +++ b/src/core/modules/discover.server.js @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import { resolve, join } from 'node:path'; 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'; /** @@ -43,6 +43,7 @@ export function registerModules(modules) { createTables: ex.createTables, dropTables: ex.dropTables, }); + info(`zen-modules: registered ${ex.manifest.name}@${ex.manifest.version ?? '?'}`); } }