UNPKG

@kitmi/jacaranda

Version:

JavaScript application framework

412 lines (347 loc) 16.1 kB
import path from 'node:path'; import { _, url as urlUtil, text, isPlainObject, eachAsync_ } from '@kitmi/utils'; import { ApplicationError, InvalidConfiguration, InvalidArgument } from '@kitmi/types'; import { defaultRoutableOpts } from './defaultServerOpts'; import { tryLoadFrom_ } from './helpers/loadModuleFrom_'; const Routable = (T) => class extends T { /** * @param {string} name - The name of the routable instance. * @param {object} [options] - Routable options * @property {string} [options.controllersPath='actions'] - Relative path of controller source files * @property {string} [options.publicPath='public'] - Relative path of front-end static files */ constructor(name, options) { super(name, { ...defaultRoutableOpts, ...options }); /** * Frontend static files path. * @member {string} **/ this.publicPath = this.toAbsolutePath(this.options.publicPath); this.controllersPath = path.resolve(this.sourcePath, this.options.controllersPath); this.routable = true; } async start_() { /** * Middleware factory registry. * @member {object} */ this._middlewareFactories = {}; await super.start_(); if ( this.options.logMiddlewareRegistry && (this.options.logLevel === 'verbose' || this.options.logLevel === 'debug') ) { this.log('verbose', 'Registered middlewares:', { middlewares: this.registry.middlewares ? Object.keys(this.registry.middlewares) : [], }); } return this; } async stop_() { delete this._middlewareFactories; return super.stop_(); } /** * Get the app module by route or alias. * @param {String|WebModule} fromApp * @returns {WebModule} */ getOtherApp(fromApp) { let _app; if (typeof fromApp === 'object' && fromApp.isModule) { return fromApp; } if (typeof fromApp !== 'string') { throw new InvalidArgument('"fromApp" must be a string or WebModule'); } if (fromApp.startsWith('/')) { _app = this.server.getAppByRoute(fromApp); } else { _app = this.server.getAppByAlias(fromApp); } return _app; } /** * Register the factory method of a named middleware. * @param {string} name - The name of the middleware * @param {function} factoryMethod - The factory method of a middleware */ registerMiddlewareFactory(name, factoryMethod) { if (typeof factoryMethod !== 'function') { if (factoryMethod.__esModule && typeof factoryMethod.default === 'function') { factoryMethod = factoryMethod.default; } else { throw new InvalidArgument('Invalid middleware factory: ' + name); } } if (name in this._middlewareFactories) { throw new ApplicationError('Middleware "' + name + '" already registered!'); } this._middlewareFactories[name] = factoryMethod; this.log('verbose', `Registered named middleware [${name}].`); } /** * Get the factory method of a middleware from module hierarchy. * @param name * @returns {Promise.<function>} */ async getMiddlewareFactory_(name) { const factory = this._middlewareFactories[name]; if (factory != null) { return factory; } const registryEntry = await tryLoadFrom_( this, 'Middleware', { registry: { name, path: 'middlewares', }, project: { name, path: path.join(this.sourcePath, 'middlewares'), }, }, true /* no throw */ ); if (registryEntry != null) { this._middlewareFactories[name] = registryEntry; return registryEntry; } if (this.server && !this.isServer) { return this.server.getMiddlewareFactory_(name); } throw new ApplicationError(`Middleware "${name}" not found in middleware registry.`); } /** * Use middlewares by creating middleware instances (asynchronously) from factory methods and attach them to a router. * @param {Router} router * @param {*} middlewares - Can be an array of middleware entries or a key-value list of registerred middlewares * @returns {Routable} */ async useMiddlewares_(router, middlewares) { let middlewareFactory, middleware; let middlewareFunctions = []; if (isPlainObject(middlewares)) { await eachAsync_(middlewares, async (options, name) => { middlewareFactory = await this.getMiddlewareFactory_(name); middleware = await middlewareFactory(options, this); middlewareFunctions.push({ name, middleware }); }); } else { middlewares = _.castArray(middlewares); await eachAsync_(middlewares, async (middlewareEntry) => { let type = typeof middlewareEntry; if (type === 'string') { // [ 'namedMiddleware' ] middlewareFactory = await this.getMiddlewareFactory_(middlewareEntry); middleware = await middlewareFactory(undefined, this); middlewareFunctions.push({ name: middlewareEntry, middleware }); } else if (type === 'function') { middlewareFunctions.push({ name: middlewareEntry.name || 'unamed middleware', middleware: middlewareEntry, }); } else if (Array.isArray(middlewareEntry)) { // [ [ 'namedMiddleware', config ] ] if (middlewareEntry.length === 0) { throw new InvalidConfiguration( 'Empty array found as middleware entry!', this, 'middlewares' ); } middlewareFactory = await this.getMiddlewareFactory_(middlewareEntry[0]); middleware = await middlewareFactory( middlewareEntry.length > 1 ? middlewareEntry[1] : null, this ); middlewareFunctions.push({ name: middlewareEntry[0], middleware }); } else { if (!isPlainObject(middlewareEntry) || !('name' in middlewareEntry)) { throw new InvalidConfiguration('Invalid middleware entry!', this, 'middlewares'); } middlewareFactory = await this.getMiddlewareFactory_(middlewareEntry.name); middleware = await middlewareFactory(middlewareEntry.options, this); middlewareFunctions.push({ name: middlewareEntry.name, middleware }); } }); } middlewareFunctions.forEach(({ name, middleware }) => { if (Array.isArray(middleware)) { middleware.forEach((m) => this.useMiddleware(router, m, name)); } else { this.useMiddleware(router, middleware, name); } }); return this; } /** * Add a route to a router, skipped while the server running in deaf mode. * @param router * @param method * @param route * @param actions */ async addRoute_(router, method, route, actions) { let handlers = [], middlewareFactory; if (isPlainObject(actions)) { await eachAsync_(actions, async (options, name) => { middlewareFactory = await this.getMiddlewareFactory_(name); handlers.push(this._wrapMiddlewareTracer(await middlewareFactory(options, this), name)); }); } else { actions = _.castArray(actions); let lastIndex = actions.length - 1; await eachAsync_(actions, async (action, i) => { let type = typeof action; if (i === lastIndex) { // last middleware may be an action if (type === 'string' && action.lastIndexOf('.') > 0) { action = { name: 'action', options: action, }; type = 'object'; } } if (type === 'string') { // [ 'namedMiddleware' ] middlewareFactory = await this.getMiddlewareFactory_(action); let middleware = await middlewareFactory(null, this); //in case it's register by the middlewareFactory feature if (Array.isArray(middleware)) { middleware.forEach((middlewareItem, i) => handlers.push( this._wrapMiddlewareTracer( middlewareItem, `${action}-${i}` + (middleware.name ? '-' + middleware.name : '') ) ) ); } else { handlers.push(this._wrapMiddlewareTracer(middleware, action)); } } else if (type === 'function') { handlers.push(this._wrapMiddlewareTracer(action)); } else if (Array.isArray(action)) { if (action.length === 0 || action.length > 2) { throw new InvalidConfiguration('Invalid middleware entry!', this, 'middlewares'); } middlewareFactory = await this.getMiddlewareFactory_(action[0]); handlers.push( this._wrapMiddlewareTracer( await middlewareFactory(action.length > 1 ? action[1] : undefined, this) ) ); } else { if (typeof action !== 'object' || !('name' in action)) { throw new InvalidConfiguration('Invalid middleware entry!', this, 'middlewares'); } middlewareFactory = await this.getMiddlewareFactory_(action.name); handlers.push( this._wrapMiddlewareTracer(await middlewareFactory(action.options, this), action.name) ); } }); } router[method](route, ...handlers); let endpoint = text.dropIfEndsWith( router.opts.prefix ? urlUtil.join(this.route, router.opts.prefix, route) : urlUtil.join(this.route, route), '/' ); this.log('verbose', `Route "${method}:${endpoint}" is added from app [${this.name}].`); return this; } requireFeatures(features, middleware) { const hasNotEnabled = _.find(_.castArray(features), (feature) => !this.enabled(feature)); if (hasNotEnabled) { throw new InvalidConfiguration( `Middleware "${middleware}" requires "${hasNotEnabled}" feature to be enabled.`, this, `middlewares.${middleware}` ); } } /** * Attach a router to this app module, skipped while the server running in deaf mode * @param {Router} nestedRouter */ addRouter(nestedRouter, baseRoute) { if (this.router == null) { // if mount to server level this.createServerModuleRouter(); } this.router.attach(nestedRouter, baseRoute); return this; } createServerModuleRouter() { if (!this.isServer) { throw new ApplicationError('Only the server instance can create a server module router.'); } this.router = this.engine.createModuleRouter(this); this.engine.mount('/', this.router); } /** * Translate a relative path and query parameters if any to a url path * @param {string} relativePath - Relative path * @param {...*} [pathOrQuery] - Queries * @returns {string} */ toWebPath(relativePath, ...pathOrQuery) { let url, query; if (pathOrQuery && pathOrQuery.length > 0 && (pathOrQuery.length > 1 || pathOrQuery[0] !== undefined)) { if (_.isObject(pathOrQuery[pathOrQuery.length - 1])) { query = pathOrQuery.pop(); } pathOrQuery.unshift(relativePath); url = urlUtil.join(this.route, ...pathOrQuery); } else { url = urlUtil.join(this.route, relativePath); } url = text.ensureStartsWith(url, '/'); if (query) { url = urlUtil.appendQuery(url, query); url = url.replace('/?', '?'); } return url; } /** * Attach a middleware to a router. * @param {Router} router - The router to attach the middleware * @param {function} middleware - (ctx, next) => () * @param {String} name - The name of the middleware */ useMiddleware(router, middleware, name) { if (typeof middleware !== 'function') { throw new InvalidArgument('Invalid middleware.', { name, middleware }); } router.use(this._wrapMiddlewareTracer(middleware, name)); this.log('verbose', `Attached middleware [${name}].`); } middlewareConfig(config, typeInfo, name) { return this.sanitize(config, typeInfo, name, 'middlewares'); } _wrapMiddlewareTracer(middleware, name) { if (this.options.traceMiddlewares) { return async (ctx, next) => { this.log('debug', `Step in middleware "${name || middleware.name}" ...`); let ret = await middleware(ctx, next); this.log('debug', `Step out from middleware "${name || middleware.name}".`); return ret; }; } return middleware; } _getFeatureFallbackPath() { let pathArray = super._getFeatureFallbackPath(); pathArray.splice(1, 0, path.resolve(__dirname, 'webFeatures')); return pathArray; } }; export default Routable;