import { readFile } from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { pathToFileURL } from 'node:url'; import { info, warn } from '@zen/core/shared/logger'; import { registerModule } from './registry.js'; /** * Sources de modules `@zen/module-*` activés dans le projet consommateur. * * 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 : * * 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?.())); * * 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, }); info(`zen-modules: registered ${ex.manifest.name}@${ex.manifest.version ?? '?'}`); } } // --------------------------------------------------------------------------- // Helpers de scan (utilisés par le CLI `zen-modules sync` et l'env validation). // --------------------------------------------------------------------------- const NAME_PREFIX = /^(@zen\/module-|zen-module-)/; export function isCandidateName(name) { return NAME_PREFIX.test(name); } export async function readJson(path) { try { return JSON.parse(await readFile(path, 'utf-8')); } catch { return null; } } /** * 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; } } 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']); } /** * 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 findInstalledModuleNames({ cwd = process.cwd() } = {}) { const pkg = await readJson(resolve(cwd, 'package.json')); if (!pkg) return []; const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}), }; const out = []; for (const name of Object.keys(allDeps)) { if (isCandidateName(name) || (await isThirdPartyModule(name, cwd))) { out.push(name); } } return out.sort(); } /** * Résout l'URL ESM du main entry d'un module à partir de son `package.json`. * On lit `exports['.'].import` puis `main`, sinon `./index.js` par défaut. * Pas de `require.resolve` : un module `@zen/module-*` peut déclarer uniquement * la condition `"import"` (ESM-only) — la résolution CJS échouerait alors avec * "No exports main defined". Comme on a déjà localisé le `package.json` via * `resolveModulePackageJson`, on construit l'URL nous-mêmes. */ function resolveModuleEntryUrl(found) { const pkgDir = dirname(found.path); const main = found.pkg?.exports?.['.']?.import ?? found.pkg?.main ?? './index.js'; return pathToFileURL(resolve(pkgDir, main)).href; } /** * 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 }); for (const name of names) { const found = resolveModulePackageJson(name, cwd); if (!found) { warn(`zen-modules: cannot find package "${name}" in node_modules`); continue; } let mod; try { mod = await import(resolveModuleEntryUrl(found)); } 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. */ export function validateModuleEnvVars(modules) { for (const mod of modules) { const envVars = mod.manifest?.envVars ?? []; for (const v of envVars) { if (v.required && !process.env[v.key]) { warn(`zen-modules: ${mod.manifest.name} requires env var "${v.key}" — ${v.description ?? ''}`); } } } }