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
This commit is contained in:
@@ -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 ?? '<unknown>';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user