feat(core)!: introduce runtime extension registry and flat module conventions

BREAKING CHANGE: sup config now derives entries from package.json#exports and a server/client glob instead of manual lists; module structure follows flat + barrel convention with .server.js/.client.js runtime suffixes
This commit is contained in:
2026-04-22 14:13:30 -04:00
parent 61388f04a6
commit 0106bc4ea0
35 changed files with 917 additions and 528 deletions
+62 -78
View File
@@ -1,98 +1,82 @@
import { defineConfig } from 'tsup';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
// Source de vérité #1 : package.json#exports. Donne la liste des points
// d'entrée publics et la liste des self-imports à marquer external.
const exportEntries = Object.values(pkg.exports)
.map(e => e.import).filter(Boolean)
.map(p => p.replace('./dist/', 'src/'));
const selfImports = Object.keys(pkg.exports)
.filter(k => k !== '.' && !k.endsWith('.css'))
.map(k => '@zen/core' + k.slice(1));
// Source de vérité #2 : les fichiers *.server.js et *.client.js sous src/.
// Convention : un tel fichier est *toujours* un point d'entrée non-bundlé —
// soit il fait partie de l'API publique (listé dans exports), soit c'est un
// wiring interne (pages, widgets) qui doit rester un module séparé pour
// préserver les frontières RSC / 'use client'.
function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) walk(full, out);
else if (/\.(server|client)\.js$/.test(name)) out.push(full);
}
return out;
}
const boundaryFiles = walk('src');
// Dédup : un chemin déclaré dans exports ET détecté par la glob ne devient
// pas deux entrées.
const allEntries = [...new Set([...exportEntries, ...boundaryFiles])];
const SHARED_EXTERNALS = [
'react', 'react-dom', 'next',
'pg', 'dotenv', 'dotenv/config', 'resend', 'node-cron',
'@react-email/components', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner',
'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls',
...selfImports,
];
const unbundled = allEntries.filter(e => /\.(server|client)\.js$/.test(e));
const bundled = allEntries.filter(e => !/\.(server|client)\.js$/.test(e));
const esbuildBase = (o) => {
o.loader = { '.js': 'jsx', '.jsx': 'jsx' };
o.jsx = 'automatic';
};
export default defineConfig([
// Main bundled files
{
entry: [
'src/index.js',
'src/features/auth/index.js',
'src/features/auth/actions.js',
'src/features/auth/pages.js',
'src/features/auth/components/index.js',
'src/features/admin/index.js',
'src/features/admin/actions.js',
'src/features/admin/pages.js',
'src/features/admin/components/index.js',
'src/core/users/index.js',
'src/core/users/constants.js',
'src/core/api/index.js',
'src/core/api/route-handler.js',
'src/core/cron/index.js',
'src/core/database/index.js',
'src/core/database/cli.js',
'src/core/email/index.js',
'src/core/email/templates/index.js',
'src/core/storage/index.js',
'src/core/toast/index.js',
'src/core/themes/index.js',
'src/features/provider/index.js',
'src/shared/components/index.js',
'src/shared/Icons.js',
'src/shared/lib/metadata/index.js',
'src/shared/lib/logger.js',
'src/shared/lib/appConfig.js',
'src/shared/lib/rateLimit.js',
],
entry: bundled,
format: ['esm'],
dts: false,
splitting: false,
sourcemap: false,
clean: true,
external: ['react', 'react-dom', 'next', 'pg', 'dotenv', 'dotenv/config', 'resend', '@react-email/components', 'node-cron', 'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls', '@zen/core/users', '@zen/core/users/constants', '@zen/core/api', '@zen/core/cron', '@zen/core/database', '@zen/core/email', '@zen/core/email/templates', '@zen/core/storage', '@zen/core/toast', '@zen/core/features/auth', '@zen/core/features/auth/actions', '@zen/core/features/auth/components', '@zen/core/shared/components', '@zen/core/shared/icons', '@zen/core/shared/logger', '@zen/core/shared/config', '@zen/core/shared/rate-limit', '@zen/core/themes', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'],
noExternal: [],
bundle: true,
banner: {
js: ``,
},
esbuildOptions(options) {
options.loader = {
'.js': 'jsx',
'.jsx': 'jsx',
};
options.jsx = 'automatic';
options.platform = 'neutral';
options.legalComments = 'inline';
external: SHARED_EXTERNALS,
esbuildOptions(o) {
esbuildBase(o);
o.platform = 'neutral';
o.legalComments = 'inline';
},
},
// Page wrappers and server-only files - NOT bundled to preserve boundaries and share instances
{
entry: [
'src/features/auth/page.js',
'src/features/admin/page.js',
'src/features/admin/navigation.server.js',
'src/features/admin/dashboard/registry.js',
'src/features/admin/dashboard/serverRegistry.js',
'src/features/admin/dashboard/widgets/index.server.js',
'src/features/admin/dashboard/widgets/users.server.js',
'src/features/dashboard.server.js',
],
entry: unbundled,
format: ['esm'],
dts: false,
splitting: false,
sourcemap: false,
clean: false, // Don't clean, we already did in first config
external: [
'react',
'react-dom',
'next',
'@zen/core',
'@zen/core/features/auth/pages',
'@zen/core/features/auth/actions',
'@zen/core/features/admin',
'@zen/core/features/admin/pages',
'@zen/core/features/admin/actions',
'@zen/core/features/admin/navigation',
'@zen/core/toast',
],
bundle: false, // Don't bundle these files
esbuildOptions(options) {
options.outbase = 'src';
options.loader = {
'.js': 'jsx',
'.jsx': 'jsx',
};
options.jsx = 'automatic';
clean: false,
bundle: false,
external: SHARED_EXTERNALS,
esbuildOptions(o) {
esbuildBase(o);
o.outbase = 'src';
},
},
]);