From 94ab6c36cb1c6e5b4650cdfab4154ffed9c5d7bd Mon Sep 17 00:00:00 2001 From: Hyko Date: Sat, 25 Apr 2026 14:24:56 -0400 Subject: [PATCH] docs(modules): update module discovery architecture to static manifest approach - replace dynamic import strategy with static manifest generated by `zen-modules sync` cli - add `zen-modules` binary entry point in `package.json` - add `cli.js` implementing the `zen-modules sync` command - update `discover.server.js` to consume static manifest instead of scanning at runtime - update `index.js` to reflect new module registration flow - update `init.js` to accept pre-resolved modules from manifest - revise docs to document manifest format, sync triggers, and build requirements --- docs/MODULES.md | 49 +++++++++-- package.json | 3 +- src/core/modules/README.md | 34 ++++++-- src/core/modules/cli.js | 121 ++++++++++++++++++++++++++ src/core/modules/discover.server.js | 127 +++++++++++++--------------- src/core/modules/index.js | 1 + src/shared/lib/init.js | 30 ++++--- 7 files changed, 269 insertions(+), 96 deletions(-) create mode 100644 src/core/modules/cli.js diff --git a/docs/MODULES.md b/docs/MODULES.md index d8870ac..636384e 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -15,13 +15,50 @@ Aucun fichier de configuration manuelle. La plateforme découvre les modules par ## 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 : +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 : + +```js +// app/.zen/modules.generated.js — AUTO-GÉNÉRÉ +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). + +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. + +### 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"]` -Pour chaque module trouvé, le core vérifie qu'il exporte les bons symboles, puis l'enregistre. +### 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. --- @@ -253,13 +290,11 @@ Le champ `files` dans `package.json` publie **uniquement** `dist/`, `README.md` --- -## Build obligatoire avant publish +## Build 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. +**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. -**Pourquoi.** Le core découvre les modules via `import(/* turbopackIgnore */ name)` dans [`discover.server.js`](../src/core/modules/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 `` 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. +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 diff --git a/package.json b/package.json index 177dbe0..f023753 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "release": "npm version patch --no-git-tag-version && npm i && git add package.json package-lock.json && git commit -m \"chore: bump version to $(node -p \"require('./package.json').version\")\" && git push && npm publish" }, "bin": { - "zen-db": "./dist/core/database/cli.js" + "zen-db": "./dist/core/database/cli.js", + "zen-modules": "./dist/core/modules/cli.js" }, "dependencies": { "@headlessui/react": "^2.0.0", diff --git a/src/core/modules/README.md b/src/core/modules/README.md index 808d9d0..55cfc2f 100644 --- a/src/core/modules/README.md +++ b/src/core/modules/README.md @@ -1,16 +1,34 @@ # Modules -Registre des modules `@zen/module-*` chargés dans le projet consommateur. Voir [docs/MODULES.md](../../../docs/MODULES.md) pour le guide complet de création d'un module. +Registre runtime des modules `@zen/module-*` activés dans le projet consommateur. Voir [docs/MODULES.md](../../../docs/MODULES.md) pour le guide complet de création d'un module. + +## Architecture + +Les modules sont activés via un **manifeste statique** généré dans le projet consommateur (`app/.zen/modules.generated.js`). Le manifeste fait des `import * as ...` statiques pour chaque package et appelle `register()` au top level. Importé par `instrumentation.js` (serveur) et `app/layout.js` (client), il rend l'arbre d'imports du module visible aux deux bundles Next.js — Turbopack et Webpack le bundlent comme n'importe quel autre fichier source. + +Le manifeste est régénéré par `npx zen-modules sync` (typiquement depuis le `postinstall` + les scripts `dev` / `build` du projet). ## API ```js -import { registerModule, getRegisteredModules } from '@zen/core/modules'; - -// Le core utilise discoverModules() pour peupler ce registre automatiquement. -// La plupart des consommateurs n'appellent jamais registerModule() directement. +import { + registerModules, + registerModule, + getRegisteredModules, + getRegisteredModule, + findInstalledModuleNames, + validateModuleEnvVars, +} from '@zen/core/modules'; ``` +| Fonction | Usage | +|----------|-------| +| `registerModules(modules)` | Appelée par `initializeZen({ modules })`. Peuple le registre interne à partir du manifeste. | +| `registerModule(mod)` | Bas niveau — utilisé par `registerModules`. | +| `getRegisteredModules()` | Retourne tous les modules connus (utilisé par `zen-db init` et l'env validation). | +| `findInstalledModuleNames({ cwd })` | Scan readonly du `package.json` du projet — utilisé par le CLI `zen-modules sync`. | +| `validateModuleEnvVars(modules)` | Logge un warning par variable d'env requise absente. | + ## Forme attendue d'un module Le point d'entrée d'un package `@zen/module-X` doit exporter : @@ -21,3 +39,9 @@ Le point d'entrée d'un package `@zen/module-X` doit exporter : | `register` | `() => void \| Promise` | oui | | `createTables` | `async () => { created?, skipped? }` | si le module a des tables | | `dropTables` | `async () => void` | si le module a des tables | + +Le code du module doit être pré-compilé avant publication (transformation JSX, voir [docs/MODULES.md](../../../docs/MODULES.md)). + +## CLI + +`npx zen-modules sync` — régénère `app/.zen/modules.generated.js`. Idempotent : pas d'écriture si le contenu est inchangé. diff --git a/src/core/modules/cli.js b/src/core/modules/cli.js new file mode 100644 index 0000000..1090a87 --- /dev/null +++ b/src/core/modules/cli.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +/** + * Zen Modules CLI + * + * Génère `app/.zen/modules.generated.js` à partir des dépendances `@zen/module-*` + * détectées dans le `package.json` du projet consommateur. Le fichier généré est + * ensuite importé par `instrumentation.js` (côté serveur) et par `app/layout.js` + * (côté client) pour rendre les modules visibles aux deux bundles Next.js. + * + * Usage : `npx zen-modules sync` + * + * Conçu pour être appelé depuis postinstall + dev/build du projet consommateur. + * Idempotent : si le contenu généré est identique au fichier existant, aucune + * écriture n'est effectuée. + */ + +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'; + +const OUTPUT_PATH = 'app/.zen/modules.generated.js'; + +function safeIdentifier(name, idx) { + // `@zen/module-posts` → `m0_zen_module_posts` + const cleaned = name.replace(/[^a-zA-Z0-9]/g, '_'); + return `m${idx}_${cleaned}`; +} + +function renderManifest(names) { + const header = '// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.\n'; + + if (names.length === 0) { + return header + '\nexport const modules = [];\n'; + } + + const imports = names + .map((name, i) => `import * as ${safeIdentifier(name, i)} from '${name}';`) + .join('\n'); + + const entries = names + .map((name, i) => ` { name: '${name}', exports: ${safeIdentifier(name, i)} },`) + .join('\n'); + + return [ + header, + imports, + '', + 'export const modules = [', + entries, + '];', + '', + '// 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).', + 'await Promise.all(modules.map(m => m.exports.register?.()));', + '', + ].join('\n'); +} + +async function readIfExists(path) { + try { + return await readFile(path, 'utf-8'); + } catch { + return null; + } +} + +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); + + if (prev === next) { + step(`zen-modules: ${OUTPUT_PATH} already up to date (${names.length} module${names.length === 1 ? '' : 's'})`); + 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'})`); + for (const name of names) step(` → ${name}`); +} + +function printHelp() { + console.log(` +Zen Modules CLI + +Usage: + npx zen-modules + +Commands: + sync Régénère app/.zen/modules.generated.js à partir des @zen/module-* + déclarés dans le package.json du projet courant. + help Affiche cette aide. +`); +} + +const [, , command] = process.argv; + +try { + switch (command) { + case 'sync': + await syncCommand(); + break; + case 'help': + case '--help': + case '-h': + case undefined: + printHelp(); + break; + default: + warn(`Unknown command: ${command}`); + printHelp(); + process.exit(1); + } +} catch (err) { + fail(`zen-modules: ${err.message}`); + process.exit(1); +} diff --git a/src/core/modules/discover.server.js b/src/core/modules/discover.server.js index 85849b1..a244784 100644 --- a/src/core/modules/discover.server.js +++ b/src/core/modules/discover.server.js @@ -1,34 +1,62 @@ import { readFile } from 'node:fs/promises'; import { resolve, join } from 'node:path'; import { createRequire } from 'node:module'; -import { info, warn, fail } from '@zen/core/shared/logger'; -import { registerModule, getRegisteredModule } from './registry.js'; +import { warn } from '@zen/core/shared/logger'; +import { registerModule } from './registry.js'; /** - * Découverte automatique des modules `@zen/module-*` installés dans le projet consommateur. + * Sources de modules `@zen/module-*` activés dans le projet consommateur. * - * Stratégie : - * 1. Lire `package.json` du process.cwd() (le projet consommateur, pas @zen/core). - * 2. Pour chaque dépendance dont le nom matche `^@zen/module-` ou `^zen-module-`, - * résoudre son point d'entrée et l'importer. - * 3. Pour les noms qui ne matchent pas le préfixe, fallback : lire - * `keywords` du package.json du package — si "zen-module" est présent, charger. - * 4. Valider la forme du module (manifest, register, createTables/dropTables) et - * l'enregistrer via registerModule(). + * Le projet consommateur fournit un manifeste statique (généré par + * `npx zen-modules sync`) et le passe à `initializeZen({ modules })`. Le + * manifeste a la forme : * - * Cette fonction ne lance PAS les hooks register() — elle se contente de découvrir - * et d'enregistrer les modules dans le registre. Le boot (initializeZen) et le CLI - * (zen-db) consomment ensuite getRegisteredModules() selon leurs besoins. + * import * as posts from '@zen/module-posts'; + * export const modules = [{ name: '@zen/module-posts', exports: posts }]; + * await Promise.all(modules.map(m => m.exports.register?.())); * - * Idempotente : appeler plusieurs fois ne charge chaque module qu'une seule fois. + * Le `import *` rend l'arbre d'imports du module visible aux deux bundles + * Next.js (server + client) ; Turbopack/Webpack le bundlent comme n'importe + * quel autre fichier source. C'est ce qui permet au module de référencer du + * JSX, `next/headers`, `next/navigation`, etc. — chaque côté reçoit la bonne + * condition. + * + * Cette fonction ne fait QUE peupler le registre interne du core (pour que + * `getRegisteredModules()` retourne les bons objets côté serveur). Le top-level + * await dans le manifeste a déjà appelé `register()` au moment de son import. */ +export function registerModules(modules) { + if (!Array.isArray(modules)) return; + + for (const entry of modules) { + const ex = entry?.exports; + const name = entry?.name ?? ex?.manifest?.name ?? ''; + + if (!ex?.manifest || typeof ex.register !== 'function') { + warn(`zen-modules: "${name}" missing manifest/register — skipping`); + continue; + } + + registerModule({ + manifest: ex.manifest, + register: ex.register, + createTables: ex.createTables, + dropTables: ex.dropTables, + }); + } +} + +// --------------------------------------------------------------------------- +// Helpers de scan (utilisés par le CLI `zen-modules sync` et l'env validation). +// --------------------------------------------------------------------------- + const NAME_PREFIX = /^(@zen\/module-|zen-module-)/; -function isCandidate(name) { +export function isCandidateName(name) { return NAME_PREFIX.test(name); } -async function readJson(path) { +export async function readJson(path) { try { return JSON.parse(await readFile(path, 'utf-8')); } catch { @@ -36,8 +64,7 @@ async function readJson(path) { } } -async function isThirdPartyModule(name, projectCwd) { - // Fallback pour les modules tiers : on regarde le keywords du package. +export async function isThirdPartyModule(name, projectCwd) { const require = createRequire(join(projectCwd, 'package.json')); let pkgJsonPath; try { @@ -49,68 +76,28 @@ async function isThirdPartyModule(name, projectCwd) { return Array.isArray(pkg?.keywords) && pkg.keywords.includes('zen-module'); } -async function loadModule(name) { - if (getRegisteredModule(name)) return; // déjà chargé - - let mod; - try { - // Node résout via node_modules à partir du module appelant ; en pratique - // depuis dist/core/modules/ dans @zen/core (lui-même installé chez le - // consommateur), Node remonte jusqu'aux node_modules du consommateur. - mod = await import(/* turbopackIgnore: true */ /* webpackIgnore: true */ name); - } catch (error) { - fail(`zen-modules: failed to import "${name}" — ${error.message}`); - return; - } - - if (!mod.manifest || typeof mod.register !== 'function') { - warn(`zen-modules: "${name}" missing required exports (manifest, register) — skipping`); - return; - } - - registerModule({ - manifest: mod.manifest, - register: mod.register, - createTables: mod.createTables, - dropTables: mod.dropTables, - }); - info(`zen-modules: discovered ${mod.manifest.name}@${mod.manifest.version ?? '?'}`); -} - /** - * Découvre et enregistre tous les modules installés dans le projet consommateur. - * - * @param {object} [options] - * @param {string} [options.cwd] - Répertoire racine du projet consommateur. - * @returns {Promise<{ loaded: string[] }>} + * Scanne `package.json` du projet consommateur et retourne la liste des noms + * de packages `@zen/module-*` (ou compatibles). N'effectue AUCUN import — le + * CLI `zen-modules sync` consomme cette liste pour générer le manifeste + * statique. */ -export async function discoverModules({ cwd = process.cwd() } = {}) { - const pkgPath = resolve(cwd, 'package.json'); - const pkg = await readJson(pkgPath); - if (!pkg) { - warn(`zen-modules: no package.json at ${pkgPath} — skipping discovery`); - return { loaded: [] }; - } +export async function findInstalledModuleNames({ cwd = process.cwd() } = {}) { + const pkg = await readJson(resolve(cwd, 'package.json')); + if (!pkg) return []; const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}), }; - const candidates = []; + const out = []; for (const name of Object.keys(allDeps)) { - if (isCandidate(name)) { - candidates.push(name); - } else if (await isThirdPartyModule(name, cwd)) { - candidates.push(name); + if (isCandidateName(name) || (await isThirdPartyModule(name, cwd))) { + out.push(name); } } - - for (const name of candidates) { - await loadModule(name); - } - - return { loaded: candidates }; + return out.sort(); } /** diff --git a/src/core/modules/index.js b/src/core/modules/index.js index 1db46b9..df7761c 100644 --- a/src/core/modules/index.js +++ b/src/core/modules/index.js @@ -1 +1,2 @@ export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js'; +export { registerModules, findInstalledModuleNames, validateModuleEnvVars } from './discover.server.js'; diff --git a/src/shared/lib/init.js b/src/shared/lib/init.js index 643044b..d586006 100644 --- a/src/shared/lib/init.js +++ b/src/shared/lib/init.js @@ -21,13 +21,22 @@ import { routes as authRoutes } from '../../features/auth/api.js'; import { storageAccessPolicies } from '../../features/auth/storage-policies.js'; import { PERMISSION_DEFINITIONS } from '../../core/users/constants.js'; import { registerPermissions, clearRegisteredPermissions } from '../../core/users/permissions-registry.js'; -import { discoverModules, validateModuleEnvVars } from '../../core/modules/discover.server.js'; +import { registerModules, validateModuleEnvVars } from '../../core/modules/discover.server.js'; import { getRegisteredModules, clearRegisteredModules } from '../../core/modules/registry.js'; -import { done, warn, fail } from './logger.js'; +import { done, warn } from './logger.js'; const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__'); -export async function initializeZen() { +/** + * @param {object} [options] + * @param {Array<{ name: string, exports: object }>} [options.modules] + * Manifeste de modules statique généré par `npx zen-modules sync` dans le + * projet consommateur. Le top-level await dans le manifeste a déjà déclenché + * `exports.register()` au moment de son import — initializeZen() se contente + * d'enregistrer chaque module dans le registre interne pour que `getRegisteredModules` + * retourne les bons objets côté serveur. + */ +export async function initializeZen({ modules = [] } = {}) { if (typeof window !== 'undefined') { return { skipped: true, reason: 'client-side' }; } @@ -44,19 +53,14 @@ export async function initializeZen() { registerStoragePolicies(storageAccessPolicies); registerPermissions(PERMISSION_DEFINITIONS); - // Découverte et activation des modules @zen/module-* - await discoverModules(); - const modules = getRegisteredModules(); - validateModuleEnvVars(modules); - for (const mod of modules) { + // Activation des modules @zen/module-* via le manifeste statique fourni. + registerModules(modules); + const registered = getRegisteredModules(); + validateModuleEnvVars(registered); + for (const mod of registered) { if (Array.isArray(mod.manifest?.permissions)) { registerPermissions(mod.manifest.permissions); } - try { - await mod.register(); - } catch (error) { - fail(`zen-modules: ${mod.manifest.name} register() threw — ${error.message}`); - } } done('ZEN: ready');