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
+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;
}