From b460ed061913e7099740147c4acf5e5e5ef07915 Mon Sep 17 00:00:00 2001 From: Hyko Date: Sat, 25 Apr 2026 14:43:00 -0400 Subject: [PATCH] docs(modules): update server/client boundary docs and client manifest generation - update MODULES.md to document dual-entry pattern (main vs ./client) and explain why client entry must not import server-only code - filter client manifest to only include modules exposing a `./client` subpath export - add `moduleHasClientEntry` helper in discover.server.js to check package.json exports - update cli.js to use `moduleHasClientEntry` when rendering the client manifest - update init.js and modules/index.js to align with new client entry convention --- docs/MODULES.md | 53 ++++++++++++---- src/core/modules/cli.js | 53 ++++++++-------- src/core/modules/discover.server.js | 98 ++++++++++++++++++++++++++--- src/core/modules/index.js | 2 +- src/features/init.js | 4 +- 5 files changed, 159 insertions(+), 51 deletions(-) diff --git a/docs/MODULES.md b/docs/MODULES.md index 0ea87a9..b2f2ebc 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -34,11 +34,12 @@ 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?.(); +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 @@ -240,18 +241,48 @@ Convention : préfixer toutes les tables par `zen__` pour éviter les co ## 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. +Un module avec partie admin expose **deux entrées** dans son `package.json#exports` : -```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. +```json +{ + "exports": { + ".": { "import": "./dist/index.js" }, + "./client": { "import": "./dist/client.js" } + } +} ``` -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. +| 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' }); +``` --- diff --git a/src/core/modules/cli.js b/src/core/modules/cli.js index 3350724..932fd56 100644 --- a/src/core/modules/cli.js +++ b/src/core/modules/cli.js @@ -18,7 +18,7 @@ import { writeFile, mkdir, readFile } from 'node:fs/promises'; import { resolve, dirname } from 'node:path'; import { step, done, warn, fail } from '@zen/core/shared/logger'; -import { findInstalledModuleNames } from './discover.server.js'; +import { findInstalledModuleNames, moduleHasClientEntry } from './discover.server.js'; const OUTPUT_SERVER = 'app/.zen/modules.generated.js'; const OUTPUT_CLIENT = 'app/.zen/modules.client.js'; @@ -59,40 +59,30 @@ function renderServerManifest(names) { ].join('\n'); } -function renderClientManifest(names) { +function renderClientManifest(clientNames) { // 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. + // et exécute les imports `@zen/module-X/client` lors du chargement de la page. + // + // Importe `@zen/module-X/client` (sous-entrée 'use client') et NON le main + // entry du module — le main entry tire createTables/registerApiRoutes/etc. + // qui dépendent de pg/fs/net et ne peuvent pas être bundlés pour le browser. const header = [ '// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.', "'use client';", '', ].join('\n'); - if (names.length === 0) { + if (clientNames.length === 0) { return header + '\nexport {};\n'; } - const imports = names - .map((name, i) => `import * as ${safeIdentifier(name, i)} from '${name}';`) + const imports = clientNames + .map(name => `import '${name}/client';`) .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'); + return [header, imports, '', 'export {};', ''].join('\n'); } async function readIfExists(path) { @@ -113,22 +103,31 @@ async function writeIfChanged(path, contents) { async function syncCommand({ cwd = process.cwd() } = {}) { const names = await findInstalledModuleNames({ cwd }); - const count = `(${names.length} module${names.length === 1 ? '' : 's'})`; + const clientNames = []; + for (const name of names) { + if (await moduleHasClientEntry(name, cwd)) clientNames.push(name); + } + + const serverCount = `(${names.length} module${names.length === 1 ? '' : 's'})`; + const clientCount = `(${clientNames.length} client entr${clientNames.length === 1 ? 'y' : 'ies'})`; 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)); + const wroteClient = await writeIfChanged(clientPath, renderClientManifest(clientNames)); if (!wroteServer && !wroteClient) { - step(`zen-modules: manifests already up to date ${count}`); + step(`zen-modules: manifests already up to date ${serverCount} ${clientCount}`); return; } - 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}`); + if (wroteServer) done(`zen-modules: wrote ${OUTPUT_SERVER} ${serverCount}`); + if (wroteClient) done(`zen-modules: wrote ${OUTPUT_CLIENT} ${clientCount}`); + for (const name of names) { + const hasClient = clientNames.includes(name); + step(` → ${name}${hasClient ? ' (+ /client)' : ''}`); + } } function printHelp() { diff --git a/src/core/modules/discover.server.js b/src/core/modules/discover.server.js index 39efe5d..e102f14 100644 --- a/src/core/modules/discover.server.js +++ b/src/core/modules/discover.server.js @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; -import { resolve, join } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { resolve, join, dirname } from 'node:path'; import { createRequire } from 'node:module'; import { info, warn } from '@zen/core/shared/logger'; import { registerModule } from './registry.js'; @@ -65,16 +66,46 @@ export async function readJson(path) { } } -export async function isThirdPartyModule(name, projectCwd) { - const require = createRequire(join(projectCwd, 'package.json')); - let pkgJsonPath; - try { - pkgJsonPath = require.resolve(`${name}/package.json`); - } catch { - return false; +/** + * Résout le chemin du `package.json` d'un module installé. Ne dépend PAS de + * `require.resolve(name)` (qui peut échouer si la sous-entrée `./package.json` + * n'est pas exposée par `exports`) ni de `import.meta.resolve` (variable selon + * la version Node). On remonte simplement depuis `projectCwd` en cherchant + * `node_modules//package.json` à chaque niveau — c'est le mécanisme de + * résolution npm standard. + */ +function resolveModulePackageJson(name, projectCwd) { + let dir = projectCwd; + while (true) { + const candidate = join(dir, 'node_modules', name, 'package.json'); + try { + const pkg = JSON.parse(readFileSync(candidate, 'utf-8')); + return { path: candidate, pkg }; + } catch { + // pas trouvé à ce niveau, remonter + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; } - const pkg = await readJson(pkgJsonPath); - return Array.isArray(pkg?.keywords) && pkg.keywords.includes('zen-module'); +} + +export async function isThirdPartyModule(name, projectCwd) { + const found = resolveModulePackageJson(name, projectCwd); + return Array.isArray(found?.pkg?.keywords) && found.pkg.keywords.includes('zen-module'); +} + +/** + * Vérifie si un module installé expose une sous-entrée `./client` dans son + * `package.json#exports`. Utilisé par le CLI `zen-modules sync` pour décider + * si le manifeste client doit l'importer. Les modules sans partie cliente + * (modules purement back-end / API / DB) ne sont pas inclus dans le manifeste + * client — ça évite d'embarquer leur graphe d'imports serveur dans le bundle + * browser. + */ +export async function moduleHasClientEntry(name, projectCwd) { + const found = resolveModulePackageJson(name, projectCwd); + return Boolean(found?.pkg?.exports?.['./client']); } /** @@ -101,6 +132,53 @@ export async function findInstalledModuleNames({ cwd = process.cwd() } = {}) { return out.sort(); } +/** + * Variante "Node-only" du chargement de modules — utilisée par le CLI + * `zen-db init` qui ne passe jamais par un bundler. Charge dynamiquement + * chaque module depuis `node_modules/` du projet, pour que le CLI ait accès + * à `manifest`, `createTables`, `dropTables`. Ne déclenche PAS `register()` + * (la chaîne register-server tirerait des imports Next.js incompatibles avec + * le contexte CLI). + * + * À ne PAS utiliser depuis le runtime Next.js — utiliser le manifeste statique + * via `initializeZen({ modules })`. + */ +export async function loadModulesForCli({ cwd = process.cwd() } = {}) { + const names = await findInstalledModuleNames({ cwd }); + const require = createRequire(join(cwd, 'package.json')); + + for (const name of names) { + let entryPath; + try { + entryPath = require.resolve(name); + } catch (err) { + warn(`zen-modules: cannot resolve "${name}" — ${err.message}`); + continue; + } + + let mod; + try { + mod = await import(entryPath); + } catch (err) { + warn(`zen-modules: failed to import "${name}" — ${err.message}`); + continue; + } + + if (!mod.manifest || typeof mod.register !== 'function') { + warn(`zen-modules: "${name}" missing manifest/register — skipping`); + continue; + } + + registerModule({ + manifest: mod.manifest, + register: mod.register, + createTables: mod.createTables, + dropTables: mod.dropTables, + }); + info(`zen-modules: loaded ${mod.manifest.name}@${mod.manifest.version ?? '?'}`); + } +} + /** * Valide les variables d'environnement requises par chaque module. * Ne lance pas — log un warning pour chaque variable absente. diff --git a/src/core/modules/index.js b/src/core/modules/index.js index df7761c..c894bdb 100644 --- a/src/core/modules/index.js +++ b/src/core/modules/index.js @@ -1,2 +1,2 @@ export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js'; -export { registerModules, findInstalledModuleNames, validateModuleEnvVars } from './discover.server.js'; +export { registerModules, findInstalledModuleNames, moduleHasClientEntry, loadModulesForCli, validateModuleEnvVars } from './discover.server.js'; diff --git a/src/features/init.js b/src/features/init.js index ca28e14..9f30d68 100644 --- a/src/features/init.js +++ b/src/features/init.js @@ -14,7 +14,7 @@ import { createTables as authCreate, dropTables as authDrop } from './auth/db.js'; import { done, fail, info, step } from '@zen/core/shared/logger'; -import { discoverModules, validateModuleEnvVars } from '../core/modules/discover.server.js'; +import { loadModulesForCli, validateModuleEnvVars } from '../core/modules/discover.server.js'; import { getRegisteredModules } from '../core/modules/registry.js'; import { registerPermissions } from '../core/users/permissions-registry.js'; @@ -23,7 +23,7 @@ const CORE_FEATURES = [ ]; async function loadModules() { - await discoverModules(); + await loadModulesForCli(); const modules = getRegisteredModules(); validateModuleEnvVars(modules);