cb547f6400
- 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
207 lines
7.3 KiB
JavaScript
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 ?? ''}`);
|
|
}
|
|
}
|
|
}
|
|
}
|