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
This commit is contained in:
2026-04-25 15:05:26 -04:00
parent 0b32e8aa97
commit cb547f6400
8 changed files with 61 additions and 21 deletions
+2
View File
@@ -58,6 +58,8 @@ Tout fichier épinglé à une frontière Next.js porte le suffixe dans son nom :
Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de vérité**. Le build compile l'intégralité de `src/` avec `bundle: false` — chaque fichier reste un module séparé, ce qui permet à Next.js de respecter les frontières RSC et `'use client'` sans que le bundler ne fusionne les modules. Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de vérité**. Le build compile l'intégralité de `src/` avec `bundle: false` — chaque fichier reste un module séparé, ce qui permet à Next.js de respecter les frontières RSC et `'use client'` sans que le bundler ne fusionne les modules.
**Tout fichier qui `import 'pg'`, `'fs'`, `'net'`, `'node:*'` doit porter `.server.js`.** Sans ce suffixe, le fichier est réputé neutre — un barrel client peut le ré-exporter par mégarde, et Turbopack/Webpack tracent la chaîne d'imports statiques jusqu'à `pg` dans le bundle browser. L'erreur `Module not found: Can't resolve 'dns'` côté client est typiquement causée par un fichier serveur sans suffixe atteint via un barrel mixte.
--- ---
## Build et configuration tsup ## Build et configuration tsup
+18
View File
@@ -284,6 +284,24 @@ import BlogAdminPage from './admin/BlogAdminPage.client.js';
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' }); registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
``` ```
### Sous-entrées `@zen/core/*` safe-pour-client
Le `client.js` d'un module ne doit jamais importer un barrel mixte (un barrel qui ré-exporte du code serveur à côté de constantes client-safe). Si on le fait, Turbopack/Webpack tracent toute la chaîne d'imports statiques et ramènent `pg`/`fs`/`next/headers` dans le bundle browser — qui crashe avec `Module not found: Can't resolve 'dns'` ou équivalent.
Sous-entrées explicitement safe pour un import depuis un fichier `'use client'` :
| Sous-entrée | Contenu |
|-------------|---------|
| `@zen/core/users/constants` | `PERMISSIONS`, `PERMISSION_DEFINITIONS`, `getPermissionGroups` — aucun import serveur. |
| `@zen/core/features/admin` | `registerPage`, `registerWidget`, `registerNavItem`, `registerNavSection`, `buildNavigationSections`. Neutre côté boundary. |
| `@zen/core/features/admin/components` | Composants client. |
| `@zen/core/themes` | Tokens/utilitaires de thème. |
| `@zen/core/toast` | API toast côté client. |
| `@zen/core/shared/icons` | Composants d'icônes. |
| `@zen/core/shared/components` | Composants partagés. |
Tout ce qui n'est pas dans cette liste — en particulier `@zen/core/users` (barrel complet), `@zen/core/database`, `@zen/core/api`, `@zen/core/storage` — est du code serveur. Ne JAMAIS l'importer depuis un fichier `'use client'` ou un fichier transitivement importé par un `'use client'`.
--- ---
## Variables d'environnement ## Variables d'environnement
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@zen/core", "name": "@zen/core",
"version": "1.4.131", "version": "1.4.132",
"description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.", "description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.",
"repository": { "repository": {
"type": "git", "type": "git",
+1 -1
View File
@@ -3,7 +3,7 @@
* Provides convenient methods for Create, Read, Update, Delete operations * Provides convenient methods for Create, Read, Update, Delete operations
*/ */
import { query, queryOne, queryAll } from './db.js'; import { query, queryOne, queryAll } from './db.server.js';
/** /**
* Filter a data object to only the columns present in allowedColumns. * Filter a data object to only the columns present in allowedColumns.
+1 -1
View File
@@ -14,7 +14,7 @@ export {
closePool, closePool,
testConnection, testConnection,
tableExists tableExists
} from './db.js'; } from './db.server.js';
// CRUD helper functions // CRUD helper functions
export { export {
+19 -8
View File
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path'; import { resolve, join, dirname } from 'node:path';
import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url';
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';
@@ -132,6 +132,20 @@ export async function findInstalledModuleNames({ cwd = process.cwd() } = {}) {
return out.sort(); 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 * Variante "Node-only" du chargement de modules — utilisée par le CLI
* `zen-db init` qui ne passe jamais par un bundler. Charge dynamiquement * `zen-db init` qui ne passe jamais par un bundler. Charge dynamiquement
@@ -145,20 +159,17 @@ export async function findInstalledModuleNames({ cwd = process.cwd() } = {}) {
*/ */
export async function loadModulesForCli({ cwd = process.cwd() } = {}) { export async function loadModulesForCli({ cwd = process.cwd() } = {}) {
const names = await findInstalledModuleNames({ cwd }); const names = await findInstalledModuleNames({ cwd });
const require = createRequire(join(cwd, 'package.json'));
for (const name of names) { for (const name of names) {
let entryPath; const found = resolveModulePackageJson(name, cwd);
try { if (!found) {
entryPath = require.resolve(name); warn(`zen-modules: cannot find package "${name}" in node_modules`);
} catch (err) {
warn(`zen-modules: cannot resolve "${name}" — ${err.message}`);
continue; continue;
} }
let mod; let mod;
try { try {
mod = await import(entryPath); mod = await import(resolveModuleEntryUrl(found));
} catch (err) { } catch (err) {
warn(`zen-modules: failed to import "${name}" — ${err.message}`); warn(`zen-modules: failed to import "${name}" — ${err.message}`);
continue; continue;
+1 -1
View File
@@ -5,7 +5,7 @@ import {
getNavItems, getNavItems,
} from './registry.js'; } from './registry.js';
import { isDevkitEnabled } from '../../shared/lib/appConfig.js'; import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
import { PERMISSIONS } from '@zen/core/users'; import { PERMISSIONS } from '@zen/core/users/constants';
// Sections et items core — enregistrés à l'import de ce module. // Sections et items core — enregistrés à l'import de ce module.
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 }); registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
+18 -9
View File
@@ -7,17 +7,26 @@
* - navItem : une entrée de la sidebar admin (section optionnelle pour grouper). * - navItem : une entrée de la sidebar admin (section optionnelle pour grouper).
* - page : un composant rendu sous /admin/<slug>. * - page : un composant rendu sous /admin/<slug>.
* *
* Les instances de module sont séparées entre le bundle serveur et le bundle * Les Maps sont stockées sur `globalThis` via `Symbol.for` pour survivre :
* client de Next.js ; c'est attendu : les fetchers vivent côté serveur, les * 1. au hot-reload de Next.js dev (sinon les enregistrements disparaissent).
* Composants côté client. Les navItems et les pages sont enregistrés côté * 2. à la double-instanciation du fichier — l'instrumentation hook tourne en
* neutre et visibles des deux côtés. * Node natif (require ESM), tandis que les Server Components passent par
* le bundle Turbopack/Webpack. Sans `globalThis`, les nav items poussés
* par `register-server.js` au boot ne seraient pas visibles côté Server
* Component qui rend la sidebar — la sidebar resterait vide.
*/ */
const widgetFetchers = new Map(); // id -> async () => data const REGISTRY_KEY = Symbol.for('__ZEN_ADMIN_REGISTRY__');
const widgetComponents = new Map(); // id -> { Component, order } if (!globalThis[REGISTRY_KEY]) {
const navItems = new Map(); // id -> { id, label, icon, href, order, sectionId } globalThis[REGISTRY_KEY] = {
const navSections = new Map(); // id -> { id, title, icon, order } widgetFetchers: new Map(), // id -> async () => data
const pages = new Map(); // slug -> { slug, Component, title? } widgetComponents: new Map(), // id -> { Component, order, permission }
navItems: new Map(), // id -> { id, label, icon, href, order, sectionId, position, permission }
navSections: new Map(), // id -> { id, title, icon, order }
pages: new Map(), // slug -> { slug, Component, title?, breadcrumbLabel? }
};
}
const { widgetFetchers, widgetComponents, navItems, navSections, pages } = globalThis[REGISTRY_KEY];
// ---- Widgets --------------------------------------------------------------- // ---- Widgets ---------------------------------------------------------------