docs(modules): update server/client boundary docs and client manifest generation

- update MODULES.md to document dual-entry pattern (main vs ./client) and explain why client entry must not import server-only code
- filter client manifest to only include modules exposing a `./client` subpath export
- add `moduleHasClientEntry` helper in discover.server.js to check package.json exports
- update cli.js to use `moduleHasClientEntry` when rendering the client manifest
- update init.js and modules/index.js to align with new client entry convention
This commit is contained in:
2026-04-25 14:43:00 -04:00
parent 92f3e4c561
commit b460ed0619
5 changed files with 159 additions and 51 deletions
+26 -27
View File
@@ -18,7 +18,7 @@
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';
import { findInstalledModuleNames, moduleHasClientEntry } from './discover.server.js';
const OUTPUT_SERVER = 'app/.zen/modules.generated.js';
const OUTPUT_CLIENT = 'app/.zen/modules.client.js';
@@ -59,40 +59,30 @@ function renderServerManifest(names) {
].join('\n');
}
function renderClientManifest(names) {
function renderClientManifest(clientNames) {
// Doit être un Client Component pour que les side-effects (registerPage, etc.)
// s\'exécutent dans le bundle browser au moment de l\'hydratation. app/layout.js
// (Server Component) importe ce fichier ; Next.js bascule sur le bundle client
// et exécute les register() lors du chargement de la page.
// et exécute les imports `@zen/module-X/client` lors du chargement de la page.
//
// Importe `@zen/module-X/client` (sous-entrée 'use client') et NON le main
// entry du module — le main entry tire createTables/registerApiRoutes/etc.
// qui dépendent de pg/fs/net et ne peuvent pas être bundlés pour le browser.
const header = [
'// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.',
"'use client';",
'',
].join('\n');
if (names.length === 0) {
if (clientNames.length === 0) {
return header + '\nexport {};\n';
}
const imports = names
.map((name, i) => `import * as ${safeIdentifier(name, i)} from '${name}';`)
const imports = clientNames
.map(name => `import '${name}/client';`)
.join('\n');
const calls = names
.map((_, i) => `${safeIdentifier(names[i], i)}.register?.();`)
.join('\n');
return [
header,
imports,
'',
'// Fire and forget — registerPage/registerWidget sont synchrones, le reste',
'// (init asynchrone éventuel) ne bloque pas le rendu.',
calls,
'',
'export {};',
'',
].join('\n');
return [header, imports, '', 'export {};', ''].join('\n');
}
async function readIfExists(path) {
@@ -113,22 +103,31 @@ async function writeIfChanged(path, contents) {
async function syncCommand({ cwd = process.cwd() } = {}) {
const names = await findInstalledModuleNames({ cwd });
const count = `(${names.length} module${names.length === 1 ? '' : 's'})`;
const clientNames = [];
for (const name of names) {
if (await moduleHasClientEntry(name, cwd)) clientNames.push(name);
}
const serverCount = `(${names.length} module${names.length === 1 ? '' : 's'})`;
const clientCount = `(${clientNames.length} client entr${clientNames.length === 1 ? 'y' : 'ies'})`;
const serverPath = resolve(cwd, OUTPUT_SERVER);
const clientPath = resolve(cwd, OUTPUT_CLIENT);
const wroteServer = await writeIfChanged(serverPath, renderServerManifest(names));
const wroteClient = await writeIfChanged(clientPath, renderClientManifest(names));
const wroteClient = await writeIfChanged(clientPath, renderClientManifest(clientNames));
if (!wroteServer && !wroteClient) {
step(`zen-modules: manifests already up to date ${count}`);
step(`zen-modules: manifests already up to date ${serverCount} ${clientCount}`);
return;
}
if (wroteServer) done(`zen-modules: wrote ${OUTPUT_SERVER} ${count}`);
if (wroteClient) done(`zen-modules: wrote ${OUTPUT_CLIENT} ${count}`);
for (const name of names) step(`${name}`);
if (wroteServer) done(`zen-modules: wrote ${OUTPUT_SERVER} ${serverCount}`);
if (wroteClient) done(`zen-modules: wrote ${OUTPUT_CLIENT} ${clientCount}`);
for (const name of names) {
const hasClient = clientNames.includes(name);
step(`${name}${hasClient ? ' (+ /client)' : ''}`);
}
}
function printHelp() {
+88 -10
View File
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { readFileSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path';
import { createRequire } from 'node:module';
import { info, warn } from '@zen/core/shared/logger';
import { registerModule } from './registry.js';
@@ -65,16 +66,46 @@ export async function readJson(path) {
}
}
export async function isThirdPartyModule(name, projectCwd) {
const require = createRequire(join(projectCwd, 'package.json'));
let pkgJsonPath;
try {
pkgJsonPath = require.resolve(`${name}/package.json`);
} catch {
return false;
/**
* 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;
}
const pkg = await readJson(pkgJsonPath);
return Array.isArray(pkg?.keywords) && pkg.keywords.includes('zen-module');
}
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']);
}
/**
@@ -101,6 +132,53 @@ export async function findInstalledModuleNames({ cwd = process.cwd() } = {}) {
return out.sort();
}
/**
* 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 });
const require = createRequire(join(cwd, 'package.json'));
for (const name of names) {
let entryPath;
try {
entryPath = require.resolve(name);
} catch (err) {
warn(`zen-modules: cannot resolve "${name}" — ${err.message}`);
continue;
}
let mod;
try {
mod = await import(entryPath);
} 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.
+1 -1
View File
@@ -1,2 +1,2 @@
export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js';
export { registerModules, findInstalledModuleNames, validateModuleEnvVars } from './discover.server.js';
export { registerModules, findInstalledModuleNames, moduleHasClientEntry, loadModulesForCli, validateModuleEnvVars } from './discover.server.js';
+2 -2
View File
@@ -14,7 +14,7 @@
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
import { done, fail, info, step } from '@zen/core/shared/logger';
import { discoverModules, validateModuleEnvVars } from '../core/modules/discover.server.js';
import { loadModulesForCli, validateModuleEnvVars } from '../core/modules/discover.server.js';
import { getRegisteredModules } from '../core/modules/registry.js';
import { registerPermissions } from '../core/users/permissions-registry.js';
@@ -23,7 +23,7 @@ const CORE_FEATURES = [
];
async function loadModules() {
await discoverModules();
await loadModulesForCli();
const modules = getRegisteredModules();
validateModuleEnvVars(modules);