feat(modules): add external module registration and defineModule support

- Add `./modules/define` export path pointing to `defineModule.js`
- Implement `registerExternalModules()` to handle modules passed via `zen.config.js`, with env var gating (`ZEN_MODULE_<NAME>=true`)
- Extract `buildAdminConfig()` helper to consolidate admin navigation/page config building
- Refactor `loadModuleConfig()` to use `buildAdminConfig()` and simplify public routes check
- Improve `initializeModuleTables()` to gracefully skip modules without `db.js` instead of erroring
- Update module discovery JSDoc to reflect external module registration support
This commit is contained in:
2026-04-12 13:39:56 -04:00
parent 4983a24325
commit 99a56d2c39
13 changed files with 871 additions and 146 deletions
+56
View File
@@ -0,0 +1,56 @@
/**
* defineModule — helper to declare a ZEN module.
*
* Used for both internal modules (src/modules/) and external npm packages.
*
* @param {Object} config - Module configuration
* @returns {Object} Normalized module configuration
*/
export function defineModule(config) {
if (!config || typeof config !== 'object') {
throw new Error('[defineModule] Config must be an object.');
}
if (!config.name || typeof config.name !== 'string') {
throw new Error('[defineModule] Field "name" is required (e.g. "invoice").');
}
return {
// Identity
version: '1.0.0',
displayName: config.name.charAt(0).toUpperCase() + config.name.slice(1),
description: '',
// Dependencies and environment variables
dependencies: [],
envVars: [],
// Admin UI
navigation: null,
adminPages: {},
pageResolver: null,
// Public pages
publicPages: {},
publicRoutes: [],
dashboardWidgets: [],
// Server actions for public pages
actions: {},
// SEO metadata generators
metadata: {},
// Database (optional) — { createTables, dropTables }
db: null,
// Initialization callback (optional) — setup(ctx)
setup: null,
// Spread last so all fields above can be overridden
...config,
// Internal marker — do not override
__isZenModule: true,
};
}
+134 -28
View File
@@ -1,6 +1,7 @@
/**
* Module Discovery System
* Auto-discovers and registers modules from the modules directory
* Auto-discovers and registers modules from the modules directory.
* Also handles registration of external modules passed via zen.config.js.
*/
import { registerModule, clearRegistry } from './registry.js';
@@ -95,27 +96,7 @@ async function loadModuleConfig(moduleName) {
try {
const config = await import(`../../modules/${moduleName}/module.config.js`);
const moduleConfig = config.default || config;
// Build admin config with navigation and pages
let adminConfig = undefined;
if (moduleConfig.navigation || moduleConfig.adminPages) {
adminConfig = {};
if (moduleConfig.navigation) {
adminConfig.navigation = moduleConfig.navigation;
}
// Extract admin page paths (keys only, not the lazy components)
// This allows getAdminPage() to know which paths belong to this module
if (moduleConfig.adminPages) {
adminConfig.pages = {};
for (const path of Object.keys(moduleConfig.adminPages)) {
// Store true as a marker that this path exists
// The actual component is loaded client-side via modules.pages.js
adminConfig.pages[path] = true;
}
}
}
// Extract server-side relevant data
return {
name: moduleConfig.name || moduleName,
displayName: moduleConfig.displayName || moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
@@ -123,12 +104,12 @@ async function loadModuleConfig(moduleName) {
description: moduleConfig.description || `${moduleName} module`,
dependencies: moduleConfig.dependencies || [],
envVars: moduleConfig.envVars || [],
// Admin configuration (navigation + page paths)
admin: adminConfig,
// Public routes metadata (not components)
public: moduleConfig.publicRoutes ? {
routes: moduleConfig.publicRoutes
} : undefined,
// Admin config: navigation + page path markers (components loaded client-side)
admin: buildAdminConfig(moduleConfig),
// Public routes metadata (components loaded client-side)
public: moduleConfig.publicRoutes?.length
? { routes: moduleConfig.publicRoutes }
: undefined,
};
} catch (error) {
console.log(`[Module Discovery] No module.config.js for ${moduleName}, using defaults`);
@@ -182,6 +163,131 @@ async function loadModuleComponents(moduleName) {
return components;
}
/**
* Register external modules provided via zen.config.js.
* Skips any module whose ZEN_MODULE_<NAME>=true env var is not set.
*
* @param {Array} modules - Array of module configs created with defineModule()
* @returns {Promise<Object>} { registered, skipped, errors }
*/
export async function registerExternalModules(modules = []) {
const registered = [];
const skipped = [];
const errors = [];
for (const moduleConfig of modules) {
const moduleName = moduleConfig?.name;
if (!moduleName) {
errors.push({ module: '(unknown)', error: 'Missing "name" field.' });
continue;
}
try {
if (!isModuleEnabledInEnv(moduleName)) {
skipped.push(moduleName);
console.log(`[External Modules] Skipped ${moduleName} (not enabled)`);
continue;
}
// Build registry entry from the external module config
const adminConfig = buildAdminConfig(moduleConfig);
const moduleData = {
name: moduleName,
displayName: moduleConfig.displayName || moduleName,
version: moduleConfig.version || '1.0.0',
description: moduleConfig.description || '',
dependencies: moduleConfig.dependencies || [],
envVars: moduleConfig.envVars || [],
admin: adminConfig,
public: moduleConfig.publicRoutes?.length
? { routes: moduleConfig.publicRoutes }
: undefined,
actions: moduleConfig.actions || {},
metadata: moduleConfig.metadata || {},
db: moduleConfig.db
? { init: moduleConfig.db.createTables, drop: moduleConfig.db.dropTables }
: undefined,
cron: moduleConfig.cron || undefined,
api: moduleConfig.api || undefined,
enabled: true,
external: true,
};
registerModule(moduleName, moduleData);
// Call setup(ctx) if provided
if (typeof moduleConfig.setup === 'function') {
const ctx = await buildModuleContext();
await moduleConfig.setup(ctx);
}
registered.push(moduleName);
console.log(`[External Modules] Registered ${moduleName}`);
} catch (error) {
errors.push({ module: moduleName, error: error.message });
console.error(`[External Modules] Error registering ${moduleName}:`, error);
}
}
if (registered.length > 0 || skipped.length > 0) {
console.log(
`[External Modules] Done. Registered: ${registered.length}, Skipped: ${skipped.length}, Errors: ${errors.length}`
);
}
return { registered, skipped, errors };
}
/**
* Build admin config object from a module config (shared with loadModuleConfig).
* @param {Object} moduleConfig
* @returns {Object|undefined}
*/
function buildAdminConfig(moduleConfig) {
if (!moduleConfig.navigation && !moduleConfig.adminPages) return undefined;
const adminConfig = {};
if (moduleConfig.navigation) {
adminConfig.navigation = moduleConfig.navigation;
}
if (moduleConfig.adminPages) {
adminConfig.pages = {};
for (const path of Object.keys(moduleConfig.adminPages)) {
adminConfig.pages[path] = true;
}
}
return adminConfig;
}
/**
* Build the context object injected into module setup() callbacks.
* All services are lazy-initialized internally — importing them is safe.
* @returns {Promise<Object>} ctx
*/
async function buildModuleContext() {
const [db, email, storage, payments] = await Promise.all([
import('../../core/database/index.js'),
import('../../core/email/index.js'),
import('../../core/storage/index.js'),
import('../../core/payments/index.js'),
]);
return {
db,
email,
storage,
payments,
config: {
get: (key) => process.env[key],
},
};
}
/**
* Reset module discovery (useful for testing)
*/
+1
View File
@@ -6,6 +6,7 @@
// Discovery
export {
discoverModules,
registerExternalModules,
isModuleEnabledInEnv,
resetDiscovery
} from './discovery.js';
+27 -7
View File
@@ -1,12 +1,32 @@
'use client';
import { useState } from 'react';
import { ToastProvider, ToastContainer } from '@hykocx/zen/toast';
import { registerExternalModulePages } from '../../modules/modules.pages.js';
export function ZenProvider({ children }) {
return (
<ToastProvider>
{children}
<ToastContainer />
</ToastProvider>
);
/**
* ZenProvider — root client provider for the ZEN CMS.
*
* Pass external module configs via the `modules` prop so their
* admin pages and public pages are available to the client router.
*
* @param {Object} props
* @param {Array} props.modules - External module configs from zen.config.js
* @param {ReactNode} props.children
*/
export function ZenProvider({ modules = [], children }) {
// Register external module pages once, synchronously, before first render.
// useState initializer runs exactly once and does not cause a re-render.
useState(() => {
if (modules.length > 0) {
registerExternalModulePages(modules);
}
});
return (
<ToastProvider>
{children}
<ToastContainer />
</ToastProvider>
);
}
+4
View File
@@ -18,6 +18,7 @@ export {
export {
// Discovery & initialization
discoverModules,
registerExternalModules,
initializeModules,
initializeModuleDatabases,
startModuleCronJobs,
@@ -45,5 +46,8 @@ export {
isModuleRegistered,
} from '../core/modules/index.js';
// Client-side module pages registry
export { registerExternalModulePages } from './modules.pages.js';
// Public pages system
export * from './pages.js';
+27 -37
View File
@@ -1,32 +1,20 @@
/**
* Module Database Initialization
* Initializes enabled module database tables
*
* IMPORTANT: When creating a new module, add its createTables import below
* and add it to MODULE_DB_INITIALIZERS.
*/
// Import createTables functions from each module
// These are bundled together so they're available at runtime
import { createTables as createPostsTables } from './posts/db.js';
/**
* Module database initializers
* Maps module names to their createTables functions
* Module Database Initialization (CLI)
*
* Add new modules here:
* Initializes DB tables for each enabled module.
* Modules are auto-discovered from AVAILABLE_MODULES —
* no manual registration needed when adding a new module.
*/
const MODULE_DB_INITIALIZERS = {
posts: createPostsTables,
};
import { AVAILABLE_MODULES } from './modules.registry.js';
/**
* Check if a module is enabled in the environment
* Check if a module is enabled via environment variable
* @param {string} moduleName - Module name
* @returns {boolean}
*/
function isModuleEnabled(moduleName) {
const envKey = `ZEN_MODULE_${moduleName.toUpperCase()}`;
const envKey = `ZEN_MODULE_${moduleName.toUpperCase().replace(/-/g, '_')}`;
return process.env[envKey] === 'true';
}
@@ -37,36 +25,38 @@ function isModuleEnabled(moduleName) {
export async function initModules() {
const created = [];
const skipped = [];
console.log('\nInitializing module databases...');
for (const [moduleName, createTables] of Object.entries(MODULE_DB_INITIALIZERS)) {
for (const moduleName of AVAILABLE_MODULES) {
if (!isModuleEnabled(moduleName)) {
console.log(`- Skipped ${moduleName} (not enabled)`);
continue;
}
try {
if (typeof createTables === 'function') {
console.log(`\nInitializing ${moduleName} module tables...`);
const result = await createTables();
if (result?.created) {
created.push(...result.created);
}
if (result?.skipped) {
skipped.push(...result.skipped);
}
console.log(`\nInitializing ${moduleName} module tables...`);
const db = await import(`./${moduleName}/db.js`);
if (typeof db.createTables === 'function') {
const result = await db.createTables();
if (result?.created) created.push(...result.created);
if (result?.skipped) skipped.push(...result.skipped);
console.log(`${moduleName} module initialized`);
} else {
console.log(`- ${moduleName} has no createTables function`);
}
} catch (error) {
console.error(`Error initializing ${moduleName}:`, error.message);
if (error.code === 'ERR_MODULE_NOT_FOUND' || error.message?.includes('Cannot find module')) {
console.log(`- ${moduleName} has no db.js (skipped)`);
} else {
console.error(`Error initializing ${moduleName}:`, error.message);
}
}
}
return { created, skipped };
}
+18 -19
View File
@@ -1,37 +1,36 @@
/**
* Module Actions Registry (Server-Side)
*
* Import server actions for public pages (/zen/*) and dashboard.
* Admin pages import actions directly from modules.
* See modules.registry.js for full module creation guide.
*
* Usage in consuming Next.js app:
* ```
* import { MODULE_ACTIONS, MODULE_DASHBOARD_ACTIONS } from '@hykocx/zen/modules/actions';
*
* // Access module actions
* const { getInvoiceByTokenAction } = MODULE_ACTIONS.invoice;
*
* // Get dashboard stats
* const stats = await MODULE_DASHBOARD_ACTIONS.invoice();
* ```
*
* Static registry for internal module server actions.
* External modules registered via zen.config.js are resolved through the runtime registry.
*
* Usage:
* import { getModuleActions } from '@hykocx/zen/modules/actions';
* const { getInvoiceByToken } = getModuleActions('invoice');
*/
// Register module actions (for public pages)
import { getModule } from '../core/modules/registry.js';
// Static actions for internal modules (add entries here for new internal modules)
export const MODULE_ACTIONS = {
posts: {},
};
// Register dashboard stats actions (for admin dashboard)
// Static dashboard stats actions for internal modules
export const MODULE_DASHBOARD_ACTIONS = {};
/**
* Get actions for a specific module
* Get actions for a specific module.
* Checks the static registry first, then the runtime registry for external modules.
*
* @param {string} moduleName - Module name
* @returns {Object} Module actions object or empty object
*/
export function getModuleActions(moduleName) {
return MODULE_ACTIONS[moduleName] || {};
if (MODULE_ACTIONS[moduleName]) return MODULE_ACTIONS[moduleName];
// External modules declare their actions in their defineModule() config
return getModule(moduleName)?.actions ?? {};
}
/**
+33 -27
View File
@@ -1,51 +1,57 @@
/**
* Module Metadata Registry (Server-Side)
*
* Import metadata generators from modules for SEO/dynamic metadata.
* See modules.registry.js for full module creation guide.
*
* Usage in Next.js page.js:
* ```
* import { MODULE_METADATA } from '@hykocx/zen/modules/metadata';
*
* export async function generateMetadata({ params }) {
* return await MODULE_METADATA.invoice.generateInvoicePaymentMetadata(params.token);
* }
* ```
*
* Static registry for internal module SEO metadata generators.
* External modules registered via zen.config.js are resolved through the runtime registry.
*
* Usage:
* import { getMetadataGenerator } from '@hykocx/zen/modules/metadata';
* const fn = getMetadataGenerator('invoice', 'payment');
* const meta = await fn(params.token);
*/
// Register module metadata
import { getModule } from '../core/modules/registry.js';
// Static metadata for internal modules (add entries here for new internal modules)
export const MODULE_METADATA = {};
/**
* Get metadata generators for a specific module
* Get metadata generators for a specific module.
* Checks the static registry first, then the runtime registry for external modules.
*
* @param {string} moduleName - Module name
* @returns {Object|null} Module metadata object or null
*/
export function getModuleMetadata(moduleName) {
return MODULE_METADATA[moduleName] || null;
return MODULE_METADATA[moduleName] || getModule(moduleName)?.metadata || null;
}
/**
* Get a specific metadata generator function
* Get a specific metadata generator function.
* Checks the static registry first, then the runtime registry for external modules.
*
* @param {string} moduleName - Module name
* @param {string} generatorName - Name of the metadata generator function (e.g., 'payment', 'pdf', 'receipt')
* @param {string} generatorName - Metadata generator key (e.g., 'payment', 'pdf')
* @returns {Function|null} Metadata generator function or null
*/
export function getMetadataGenerator(moduleName, generatorName) {
const metadata = MODULE_METADATA[moduleName];
if (!metadata) return null;
// Check the default export first (where the route type mapping is)
if (metadata.default && typeof metadata.default[generatorName] === 'function') {
return metadata.default[generatorName];
if (metadata) {
if (metadata.default && typeof metadata.default[generatorName] === 'function') {
return metadata.default[generatorName];
}
if (typeof metadata[generatorName] === 'function') {
return metadata[generatorName];
}
}
// Fallback to direct named export
if (typeof metadata[generatorName] === 'function') {
return metadata[generatorName];
// External modules declare metadata in their defineModule() config
const externalMetadata = getModule(moduleName)?.metadata;
if (externalMetadata && typeof externalMetadata[generatorName] === 'function') {
return externalMetadata[generatorName];
}
return null;
}
+60 -10
View File
@@ -2,19 +2,41 @@
/**
* Module Pages Registry (Client-Side)
*
* Import module configs and register them here.
* See modules.registry.js for full module creation guide.
*
* Static registry for internal modules (imported explicitly for proper code splitting).
* External modules are registered at runtime via registerExternalModulePages().
*
* To add an internal module:
* 1. Import its config below
* 2. Add it to MODULE_CONFIGS
*/
// Import module configs
// Import module configs — add new internal modules here
import postsConfig from './posts/module.config.js';
// Register module configs
// Internal module configs — add new modules here
const MODULE_CONFIGS = {
posts: postsConfig,
};
// Runtime registry for external modules (populated by ZenProvider via registerExternalModulePages)
const EXTERNAL_MODULE_CONFIGS = new Map();
/**
* Register external module configs at runtime.
* Called by ZenProvider when the app starts.
* Idempotent — safe to call multiple times with the same modules.
*
* @param {Array} modules - Array of module configs created with defineModule()
*/
export function registerExternalModulePages(modules = []) {
for (const mod of modules) {
if (mod?.name) {
EXTERNAL_MODULE_CONFIGS.set(mod.name, mod);
}
}
}
/**
* Build admin pages from all module configs
*/
@@ -46,13 +68,27 @@ export const MODULE_DASHBOARD_WIDGETS = Object.entries(MODULE_CONFIGS).reduce((a
}, {});
/**
* Get admin page loader for a specific module and path
* Get admin page loader for a specific module and path.
* Checks external modules first, then internal modules.
*
* @param {string} moduleName - Module name
* @param {string} path - Admin path
* @returns {React.LazyExoticComponent|null} Lazy component or null
*/
export function getModulePageLoader(moduleName, path) {
// Use custom resolver first (for dynamic paths not known at build time)
// Check external modules first
const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName);
if (externalConfig) {
if (typeof externalConfig.pageResolver === 'function') {
const resolved = externalConfig.pageResolver(path);
if (resolved) return resolved;
}
if (externalConfig.adminPages?.[path]) {
return externalConfig.adminPages[path];
}
}
// Fall back to internal modules
const config = MODULE_CONFIGS[moduleName];
if (config?.pageResolver) {
const resolved = config.pageResolver(path);
@@ -60,31 +96,45 @@ export function getModulePageLoader(moduleName, path) {
}
const modulePages = MODULE_ADMIN_PAGES[moduleName];
if (modulePages && modulePages[path]) {
if (modulePages?.[path]) {
return modulePages[path];
}
return null;
}
/**
* Get public page loader for a specific module
* Get public page loader for a specific module.
* Checks external modules first, then internal modules.
*
* @param {string} moduleName - Module name
* @returns {React.LazyExoticComponent|null} Lazy component or null
*/
export function getModulePublicPageLoader(moduleName) {
const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName);
if (externalConfig?.publicPages?.default) {
return externalConfig.publicPages.default;
}
const modulePages = MODULE_PUBLIC_PAGES[moduleName];
if (modulePages?.default) {
return modulePages.default;
}
return null;
}
/**
* Get module navigation config
* Get module navigation config.
* Checks external modules first, then internal modules.
*
* @param {string} moduleName - Module name
* @returns {Object|null} Navigation config or null
*/
export function getModuleNavigation(moduleName) {
const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName);
if (externalConfig?.navigation) return externalConfig.navigation;
const config = MODULE_CONFIGS[moduleName];
return config?.navigation || null;
}
+32 -18
View File
@@ -3,7 +3,7 @@
* Initialize all ZEN services and modules using dynamic module discovery
*/
import { discoverModules, startModuleCronJobs, stopModuleCronJobs } from '../../core/modules/index.js';
import { discoverModules, registerExternalModules, startModuleCronJobs, stopModuleCronJobs } from '../../core/modules/index.js';
// Use globalThis to persist initialization flag across module reloads
const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
@@ -16,26 +16,32 @@ const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
* Alternative: Call this function manually in your root layout
*
* @example
* // instrumentation.js (Recommended)
* // instrumentation.js (Recommended) — internal modules only
* export async function register() {
* if (process.env.NEXT_RUNTIME === 'nodejs') {
* const { initializeZen } = await import('@hykocx/zen');
* await initializeZen();
* }
* }
*
*
* @example
* // app/layout.js (Alternative)
* import { initializeZen } from '@hykocx/zen';
* initializeZen();
*
* @param {Object} options - Initialization options
* @param {boolean} options.skipCron - Skip cron job initialization
* @param {boolean} options.skipDb - Skip database initialization
* // instrumentation.js — with external modules from zen.config.js
* import zenConfig from './zen.config.js';
* export async function register() {
* if (process.env.NEXT_RUNTIME === 'nodejs') {
* const { initializeZen } = await import('@hykocx/zen');
* await initializeZen(zenConfig);
* }
* }
*
* @param {Object} config - Configuration object
* @param {Array} config.modules - External module configs (from zen.config.js)
* @param {boolean} config.skipCron - Skip cron job initialization
* @param {boolean} config.skipDb - Skip database initialization
* @returns {Promise<Object>} Initialization result
*/
export async function initializeZen(options = {}) {
const { skipCron = false, skipDb = true } = options;
export async function initializeZen(config = {}) {
const { modules: externalModules = [], skipCron = false, skipDb = true } = config;
// Only run on server-side
if (typeof window !== 'undefined') {
@@ -57,21 +63,29 @@ export async function initializeZen(options = {}) {
};
try {
// Step 1: Discover and register all enabled modules
// This reads from modules.registry.js and loads each module's config files
// Step 1: Discover and register internal modules (from modules.registry.js)
result.discovery = await discoverModules();
const enabledCount = result.discovery.enabled?.length || 0;
const skippedCount = result.discovery.skipped?.length || 0;
if (enabledCount > 0) {
console.log(`✓ ZEN: Discovered ${enabledCount} enabled module(s): ${result.discovery.enabled.join(', ')}`);
console.log(`✓ ZEN: Discovered ${enabledCount} internal module(s): ${result.discovery.enabled.join(', ')}`);
}
if (skippedCount > 0) {
console.log(`⚠ ZEN: Skipped ${skippedCount} disabled module(s): ${result.discovery.skipped.join(', ')}`);
}
// Step 2: Register external modules from zen.config.js (if any)
if (externalModules.length > 0) {
result.external = await registerExternalModules(externalModules);
if (result.external.registered.length > 0) {
console.log(`✓ ZEN: Registered ${result.external.registered.length} external module(s): ${result.external.registered.join(', ')}`);
}
}
// Step 2: Start cron jobs for all enabled modules
// Step 3: Start cron jobs for all enabled modules (internal + external)
if (!skipCron) {
result.cron = await startModuleCronJobs();