Files
core/src/core/modules/cli.js
T
hykocx f14731e554 fix(cli): export ZenModulesClient component from client manifest to ensure side-effects execute in browser
- update `renderClientManifest` to export a `ZenModulesClient` React component instead of `export {}`
- update docs to explain why rendering the component is required under Next.js 15+/Turbopack and add usage example in `app/layout.js`
2026-04-25 15:15:27 -04:00

175 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Zen Modules CLI
*
* Génère `app/.zen/modules.generated.js` à partir des dépendances `@zen/module-*`
* détectées dans le `package.json` du projet consommateur. Le fichier généré est
* ensuite importé par `instrumentation.js` (côté serveur) et par `app/layout.js`
* (côté client) pour rendre les modules visibles aux deux bundles Next.js.
*
* Usage : `npx zen-modules sync`
*
* Conçu pour être appelé depuis postinstall + dev/build du projet consommateur.
* Idempotent : si le contenu généré est identique au fichier existant, aucune
* écriture n'est effectuée.
*/
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, moduleHasClientEntry } from './discover.server.js';
const OUTPUT_SERVER = 'app/.zen/modules.generated.js';
const OUTPUT_CLIENT = 'app/.zen/modules.client.js';
function safeIdentifier(name, idx) {
// `@zen/module-posts` → `m0_zen_module_posts`
const cleaned = name.replace(/[^a-zA-Z0-9]/g, '_');
return `m${idx}_${cleaned}`;
}
function renderServerManifest(names) {
const header = '// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.\n';
if (names.length === 0) {
return header + '\nexport const modules = [];\n';
}
const imports = names
.map((name, i) => `import * as ${safeIdentifier(name, i)} from '${name}';`)
.join('\n');
const entries = names
.map((name, i) => ` { name: '${name}', exports: ${safeIdentifier(name, i)} },`)
.join('\n');
return [
header,
imports,
'',
'export const modules = [',
entries,
'];',
'',
'// Top-level await : déclenche register() de chaque module au moment de l\'import',
'// côté serveur. instrumentation.js importe ce fichier au boot.',
'await Promise.all(modules.map(m => m.exports.register?.()));',
'',
].join('\n');
}
function renderClientManifest(clientNames) {
// Exporte un Component React `ZenModulesClient` que le layout consommateur doit
// RENDRE dans son tree (`<ZenModulesClient />`). C'est le seul moyen fiable
// sous Next.js 15+/Turbopack pour garantir que les side-effects top-level
// (registerPage, registerWidget) s'exécutent côté browser. Un simple
// `import './.zen/modules.client.js'` dans un Server Component met bien le
// fichier dans le bundle client mais n'en exécute jamais le code top-level
// — la transformation 'use client' s'applique aux Components, pas aux
// side-effect imports orphelins.
//
// 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');
const body = [
'export default function ZenModulesClient() {',
' return null;',
'}',
'',
].join('\n');
if (clientNames.length === 0) return header + '\n' + body;
const imports = clientNames.map(name => `import '${name}/client';`).join('\n');
return [header, imports, '', body].join('\n');
}
async function readIfExists(path) {
try {
return await readFile(path, 'utf-8');
} catch {
return null;
}
}
async function writeIfChanged(path, contents) {
const prev = await readIfExists(path);
if (prev === contents) return false;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf-8');
return true;
}
async function syncCommand({ cwd = process.cwd() } = {}) {
const names = await findInstalledModuleNames({ cwd });
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(clientNames));
if (!wroteServer && !wroteClient) {
step(`zen-modules: manifests already up to date ${serverCount} ${clientCount}`);
return;
}
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() {
console.log(`
Zen Modules CLI
Usage:
npx zen-modules <command>
Commands:
sync Régénère app/.zen/modules.generated.js à partir des @zen/module-*
déclarés dans le package.json du projet courant.
help Affiche cette aide.
`);
}
const [, , command] = process.argv;
try {
switch (command) {
case 'sync':
await syncCommand();
break;
case 'help':
case '--help':
case '-h':
case undefined:
printHelp();
break;
default:
warn(`Unknown command: ${command}`);
printHelp();
process.exit(1);
}
} catch (err) {
fail(`zen-modules: ${err.message}`);
process.exit(1);
}