Files
core/src/core/modules/discover.server.js
T
hykocx cb547f6400 docs(core): update server boundary rules and fix db import paths
- document `.server.js` suffix requirement for node-only imports in DEV.md
- add client-safe subentries table and server-only barrel warnings in MODULES.md
- fix `crud.js` and `database/index.js` to import from `db.server.js`
- replace `createRequire` with `pathToFileURL` in `discover.server.js` for ESM-only modules
- update admin navigation and registry to use safe client-compatible imports
- bump version to 1.4.132
2026-04-25 15:05:26 -04:00

207 lines
7.3 KiB
JavaScript

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 ?? '<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,
});
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/<name>/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 ?? ''}`);
}
}
}
}