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:
+42
-11
@@ -34,11 +34,12 @@ await Promise.all(modules.map(m => m.exports.register?.()));
|
|||||||
```js
|
```js
|
||||||
// app/.zen/modules.client.js — AUTO-GÉNÉRÉ (client)
|
// app/.zen/modules.client.js — AUTO-GÉNÉRÉ (client)
|
||||||
'use client';
|
'use client';
|
||||||
import * as m0_zen_module_posts from '@zen/module-posts';
|
import '@zen/module-posts/client';
|
||||||
m0_zen_module_posts.register?.();
|
|
||||||
export {};
|
export {};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Le manifeste client importe la **sous-entrée `./client`** de chaque module (et non son main entry). C'est essentiel : le main entry tire `createTables` / `registerApiRoutes` / la chaîne `register-server.js` qui dépend de `pg`, `fs`, `next/headers`, etc. — code serveur incompatible avec le bundle browser. Seuls les modules qui exposent `./client` dans leur `package.json#exports` sont inclus dans le manifeste client ; les modules purement back-end (API/DB) sont absents du bundle browser.
|
||||||
|
|
||||||
L'importation statique (le `import * as ...`) permet à Turbopack/Webpack d'analyser le graphe complet du module — JSX, `next/headers`, `next/navigation`, frontières `'use client'` — exactement comme pour le code de l'application elle-même.
|
L'importation statique (le `import * as ...`) permet à Turbopack/Webpack d'analyser le graphe complet du module — JSX, `next/headers`, `next/navigation`, frontières `'use client'` — exactement comme pour le code de l'application elle-même.
|
||||||
|
|
||||||
### Critères de détection
|
### Critères de détection
|
||||||
@@ -240,18 +241,48 @@ Convention : préfixer toutes les tables par `zen_<module>_` pour éviter les co
|
|||||||
|
|
||||||
## Frontières serveur/client
|
## Frontières serveur/client
|
||||||
|
|
||||||
Comme pour le core (voir [DEV.md](DEV.md)), les fichiers du module portent les suffixes `.server.js` / `.client.js`. Le hook `register()` côté serveur est appelé par le core ; les enregistrements client (widgets, par ex.) doivent être triggés par un import dans le bundle client — typiquement via le composant client lui-même qui appelle `registerWidget()` à l'import.
|
Un module avec partie admin expose **deux entrées** dans son `package.json#exports` :
|
||||||
|
|
||||||
```js
|
```json
|
||||||
// Pattern recommandé pour un module avec partie client :
|
{
|
||||||
|
"exports": {
|
||||||
// src/register-server.js (importé par register())
|
".": { "import": "./dist/index.js" },
|
||||||
import './admin/BlogAdminPage.client.js'; // chaîne d'imports vers client
|
"./client": { "import": "./dist/client.js" }
|
||||||
import './widgets/BlogWidget.server.js';
|
}
|
||||||
// ... registerNavItem, registerPage, registerApiRoutes, etc.
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Le bundle Next.js du projet consommateur traverse le graphe d'import et inclut les composants client dans le bundle client. Côté serveur, seules les fonctions et fetchers serveur sont chargés.
|
| Entrée | Bundle | Contenu |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| `.` (main) | serveur | `manifest`, `register()`, `createTables`, `dropTables`. Le `register()` tire la chaîne `register-server.js` (API routes, navigation, fetchers, storage prefixes, hooks DB). |
|
||||||
|
| `./client` | client | `'use client'` ; uniquement `registerPage({ Component })` et `registerWidget({ Component })`. Aucun import vers `pg`, `fs`, `.server.js` ou `register-server.js`. |
|
||||||
|
|
||||||
|
Le manifeste serveur importe `@zen/module-X` (main) ; le manifeste client importe `@zen/module-X/client`. La séparation est obligatoire : tout ce qui est statiquement importé depuis l'entrée client finit dans le bundle browser, et `pg` / `fs` / `next/headers` y crashent.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/index.js (entrée serveur)
|
||||||
|
export const manifest = { /* ... */ };
|
||||||
|
export async function register() { await import('./register-server.js'); }
|
||||||
|
export { createTables, dropTables } from './db.server.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/register-server.js (server-only — appelée par register())
|
||||||
|
import { registerNavItem, registerNavSection } from '@zen/core/features/admin';
|
||||||
|
import { registerApiRoutes } from '@zen/core/api';
|
||||||
|
import { routes } from './api.server.js';
|
||||||
|
// PAS d'import de Component .client.js ici — ils vivent dans client.js.
|
||||||
|
registerNavSection({ /* ... */ });
|
||||||
|
registerApiRoutes(routes);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/client.js (entrée client — chargée par le manifeste client uniquement)
|
||||||
|
'use client';
|
||||||
|
import { registerPage } from '@zen/core/features/admin';
|
||||||
|
import BlogAdminPage from './admin/BlogAdminPage.client.js';
|
||||||
|
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+26
-27
@@ -18,7 +18,7 @@
|
|||||||
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
||||||
import { resolve, dirname } from 'node:path';
|
import { resolve, dirname } from 'node:path';
|
||||||
import { step, done, warn, fail } from '@zen/core/shared/logger';
|
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_SERVER = 'app/.zen/modules.generated.js';
|
||||||
const OUTPUT_CLIENT = 'app/.zen/modules.client.js';
|
const OUTPUT_CLIENT = 'app/.zen/modules.client.js';
|
||||||
@@ -59,40 +59,30 @@ function renderServerManifest(names) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderClientManifest(names) {
|
function renderClientManifest(clientNames) {
|
||||||
// Doit être un Client Component pour que les side-effects (registerPage, etc.)
|
// 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
|
// 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
|
// (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 = [
|
const header = [
|
||||||
'// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.',
|
'// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.',
|
||||||
"'use client';",
|
"'use client';",
|
||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
if (names.length === 0) {
|
if (clientNames.length === 0) {
|
||||||
return header + '\nexport {};\n';
|
return header + '\nexport {};\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
const imports = names
|
const imports = clientNames
|
||||||
.map((name, i) => `import * as ${safeIdentifier(name, i)} from '${name}';`)
|
.map(name => `import '${name}/client';`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
const calls = names
|
return [header, imports, '', 'export {};', ''].join('\n');
|
||||||
.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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readIfExists(path) {
|
async function readIfExists(path) {
|
||||||
@@ -113,22 +103,31 @@ async function writeIfChanged(path, contents) {
|
|||||||
|
|
||||||
async function syncCommand({ cwd = process.cwd() } = {}) {
|
async function syncCommand({ cwd = process.cwd() } = {}) {
|
||||||
const names = await findInstalledModuleNames({ 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 serverPath = resolve(cwd, OUTPUT_SERVER);
|
||||||
const clientPath = resolve(cwd, OUTPUT_CLIENT);
|
const clientPath = resolve(cwd, OUTPUT_CLIENT);
|
||||||
|
|
||||||
const wroteServer = await writeIfChanged(serverPath, renderServerManifest(names));
|
const wroteServer = await writeIfChanged(serverPath, renderServerManifest(names));
|
||||||
const wroteClient = await writeIfChanged(clientPath, renderClientManifest(names));
|
const wroteClient = await writeIfChanged(clientPath, renderClientManifest(clientNames));
|
||||||
|
|
||||||
if (!wroteServer && !wroteClient) {
|
if (!wroteServer && !wroteClient) {
|
||||||
step(`zen-modules: manifests already up to date ${count}`);
|
step(`zen-modules: manifests already up to date ${serverCount} ${clientCount}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wroteServer) done(`zen-modules: wrote ${OUTPUT_SERVER} ${count}`);
|
if (wroteServer) done(`zen-modules: wrote ${OUTPUT_SERVER} ${serverCount}`);
|
||||||
if (wroteClient) done(`zen-modules: wrote ${OUTPUT_CLIENT} ${count}`);
|
if (wroteClient) done(`zen-modules: wrote ${OUTPUT_CLIENT} ${clientCount}`);
|
||||||
for (const name of names) step(` → ${name}`);
|
for (const name of names) {
|
||||||
|
const hasClient = clientNames.includes(name);
|
||||||
|
step(` → ${name}${hasClient ? ' (+ /client)' : ''}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function printHelp() {
|
function printHelp() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
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 { createRequire } from 'node:module';
|
||||||
import { info, warn } from '@zen/core/shared/logger';
|
import { info, warn } from '@zen/core/shared/logger';
|
||||||
import { registerModule } from './registry.js';
|
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'));
|
* Résout le chemin du `package.json` d'un module installé. Ne dépend PAS de
|
||||||
let pkgJsonPath;
|
* `require.resolve(name)` (qui peut échouer si la sous-entrée `./package.json`
|
||||||
try {
|
* n'est pas exposée par `exports`) ni de `import.meta.resolve` (variable selon
|
||||||
pkgJsonPath = require.resolve(`${name}/package.json`);
|
* la version Node). On remonte simplement depuis `projectCwd` en cherchant
|
||||||
} catch {
|
* `node_modules/<name>/package.json` à chaque niveau — c'est le mécanisme de
|
||||||
return false;
|
* 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();
|
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.
|
* Valide les variables d'environnement requises par chaque module.
|
||||||
* Ne lance pas — log un warning pour chaque variable absente.
|
* Ne lance pas — log un warning pour chaque variable absente.
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js';
|
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';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
|
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
|
||||||
import { done, fail, info, step } from '@zen/core/shared/logger';
|
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 { getRegisteredModules } from '../core/modules/registry.js';
|
||||||
import { registerPermissions } from '../core/users/permissions-registry.js';
|
import { registerPermissions } from '../core/users/permissions-registry.js';
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ const CORE_FEATURES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function loadModules() {
|
async function loadModules() {
|
||||||
await discoverModules();
|
await loadModulesForCli();
|
||||||
const modules = getRegisteredModules();
|
const modules = getRegisteredModules();
|
||||||
validateModuleEnvVars(modules);
|
validateModuleEnvVars(modules);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user