UNPKG

dalao-proxy

Version:

An expandable HTTP proxy based on the plug-in system for frontend developers with request caching request mock and development!

727 lines (639 loc) 22.2 kB
const chalk = require('chalk'); const path = require('path'); const { pluginResolver } = require('@dalao-proxy/utils'); const EventEmitter = require('events'); const defaultConfig = require('../../config'); const { getType, defineProxy } = require('../utils'); const FILE_INDEX = 'index.js'; const FILE_COMMANDER = 'commander.js'; const FILE_CONFIGURE = 'configure.js'; const FILE_EXPORTS = 'exports.js'; const FILE_PACKAGE = 'package.json'; function noop() { } function nextNoop(context, next) { next && next(null); } function nextChunkNoop(context, next) { next && next(null, context.chunk); } function isNoOptionFileError(error) { return error instanceof Error && error.code === 'MODULE_NOT_FOUND' && !!error.message.match( new RegExp(`\\b(${[FILE_COMMANDER, FILE_CONFIGURE, FILE_EXPORTS].join('|')})`) ); } /** * Judge the plugin is build-in or not and return plugin name * @param {String} id * @returns {String} plugin name */ function isBuildIn(id) { return id.match(/^BuildIn\:plugin\/(.+)$/i); } function createUid() { return Math.random().toString(36).substr(2) + Date.now().toString(36); } class Register extends EventEmitter { constructor() { super(); this.registerMapper = {}; this.lineCommand = []; } /** * @private * trigger field listeners * @param {String} field the field of `program.context` to set * @param {*} value * @param {Function} callback return the value after `configure` */ _trigger(field, value, callback) { const registerSetters = this.registerMapper[field] || []; let index = 0, total = registerSetters.length; if (!total) { callback(value); this.emit('context:' + field, value); return; } let lastValue = value; let currentSetter = registerSetters[index]; executeSetter(currentSetter, () => { callback(lastValue); this.emit('context:' + field, lastValue); }); function executeSetter(setter, cb) { try { setter.call(null, { ...lastValue }, (err, returnValue) => { if (!err) { // check type if (getType(returnValue) === getType(value)) { // remember last value after setter lastValue = returnValue; } else { console.warn(chalk.yellow(`Plugin warning: The plugin [${setter.plugin.name}] can't change the type of value while configuring the field [${field}].`)); } } next(); }); } catch (error) { console.warn(`Error occurred when configure field '${field}'`, error); next(); } function next() { // execute next setter if (index < total - 1) { currentSetter = registerSetters[++index]; executeSetter(currentSetter, cb); } else { cb(); } } } } _reset() { this.registerMapper = {}; this.removeAllListeners(); } /** * configure * @param {String} field the field of `program.context` to set * @param {Function} registerSetter register the setter which can access context when the field value is assigned * Will receive two parameters * - `value` the value of the field * - `callback(err, value)` must be called when done */ configure(field, registerSetter) { if (!getType(registerSetter, 'Function')) { throw new Error('registerSetter must be a function'); } if (this.registerMapper[field]) { this.registerMapper[field].push(registerSetter); } else { this.registerMapper[field] = [registerSetter]; } } /** * * @param {string} childPluginName * @param {string} childField * @param {(config: object) => object} childConfigGenerator */ setChildPlugin(childPluginName, childField, childConfigGenerator) { this.configure("config", (config, callback) => { config.plugins.push([ childPluginName, { defaultEnable: true, optionsField: childField, _childId: childConfigGenerator._childId }, ]); config[childField] = childConfigGenerator(config); callback(null, config); }); } addLineCommand(cmd, ...cmds) { if (Array.isArray(cmd)) { this.lineCommand.push(...cmd); } else { this.lineCommand.push(cmd, ...cmds); } this.lineCommand = [...new Set(this.lineCommand)]; } } const register = new Register(); const configure = Register.prototype.configure; const setChildPlugin = Register.prototype.setChildPlugin; const modifiedPluginIds = new Set(); /** * @type {Plugin[]} */ let modifiedPlugins = []; /** * @type {Plugin[]} */ const childPlugins = []; const childPluginConfigs = []; /** * @type {Map<Plugin, string>} */ const childPluginMaps = new Map(); const childPluginShortIds = new Set(); /** * @typedef {{ * defaultEnable?: boolean; * enableField?: string; * optionsField: string | string[]; * dependFields?: string[]; * _childId?: string; * }} PluginSetting */ class Plugin { /** * @param {string} pluginName * @param {import('../context')} context * @param {PluginSetting} setting */ constructor(pluginName, context, setting = {}) { this.id = this.shortId = setting._childId || createUid(); if (setting.optionsField) { this.id += `-${setting.optionsField}`; } /** * @type {string} */ this.name = pluginName; /** * @type {PluginSetting} */ this._overrideSetting = setting || {}; this.meta = {}; /** * @type {PluginSetting} */ this.setting; this.config; this.parser; this.configure = null; this.middleware = {}; this.commander = null; /** * @type {import('../context')} */ this.context = context; this.register = register; this.exports = {}; this._indexPath = ''; this._packagejsonPath; this._configurePath; this._commanderPath; this._isRuntimeChildPlugin; try { const { indexPath, commanderPath, configurePath, packagejsonPath, exportsPath } = Plugin.resolvePaths(this.name); this._indexPath = indexPath; this._packagejsonPath = packagejsonPath; this._commanderPath = commanderPath; this._configurePath = configurePath; this._exportsPath = exportsPath; this.load(); if (Plugin.isRuntimeChildPlugin(this)) { childPlugins.push(this); } } catch (error) { let pluginErrResult; if (pluginErrResult = error.message.match(/Cannot\sfind\smodule\s'(.+)'/)) { console.log(chalk.red(`${pluginErrResult[0]}. Please check if module '${pluginErrResult[1]}' is installed`)); } else { console.error(error); } this.meta.enabled = false; this.meta.error = error; } } /** * @public * Try to load plugin middleware, commander */ load() { const setting = this.setting = this.loadSetting(); const config = this.loadPluginConfig(); const enable = Plugin.resolveEnable(config, setting); this.config = defineProxy(config, { setter: () => { if (!modifiedPluginIds.has(this.id)) { modifiedPluginIds.add(this.id); modifiedPlugins.push(this); } } }); if (enable && !this.meta.enabled) { this.middleware = require(this._indexPath); if (isBuildIn(this.name)) { this.meta = { isBuildIn: true, version: defaultConfig.version }; } else { this.meta = require(this._packagejsonPath); } try { this.commander = require(this._commanderPath); this._extendCmds(); this.exports = require(this._exportsPath); } catch (error) { if (!isNoOptionFileError(error)) { console.error(error); } } this.meta.enabled = true; } } /** * @public * load plugin setting, try to load configure file */ loadSetting() { try { // try load `configure.js` file this.configure = require(this._configurePath); return Plugin.resolveSetting(this); } catch (error) { if (!isNoOptionFileError(error)) { console.error(error); } return Plugin.defaultSetting(this); } } /** * Resolve plugin config from `setting.configField` */ loadPluginConfig() { let pluginConfig; // child plugin alaways read from parsed config // because the config is provided by parent plugin if (Plugin.isRuntimeChildPlugin(this)) { pluginConfig = Plugin.resolveOptionsConfigs(this, this.context.config); } else { // read config from parsed config object // always when plugin has been modified if (modifiedPluginIds.has(this.id)) { pluginConfig = Plugin.resolveOptionsConfigs(this, this.context.config); } // read config from rawConfig else { pluginConfig = Plugin.resolveOptionsConfigs(this, this.context.rawConfig); } } const dependConfigs = Plugin.resolveDependConfigs(this); const parserFnArgs = [...pluginConfig, ...dependConfigs]; const parser = this.parser = Plugin.resolveConfigParser(this); const parsedConfig = parser.apply(this, parserFnArgs) || {}; // resolve plugin enable config parsedConfig[this.setting.enableField] = pluginConfig[0] && pluginConfig[0][this.setting.enableField]; return parsedConfig; } static defaultSetting(plugin) { return { defaultEnable: plugin.defaultEnable || false, optionsField: plugin.name, enableField: 'enable', dependFields: [] }; } static defaultConfigParser() { return function defaultParser(config) { return config; }; } /** * Resolve Plugin Paths * @param {String} pluginName */ static resolvePaths(pluginName) { const resolvedPaths = { indexPath: null, commanderPath: null, configurePath: null, packagejsonPath: null, exportsPath: null, }; let matched = isBuildIn(pluginName); if (matched) { const buildInPluginPath = path.resolve(__dirname, matched[1]); resolvedPaths.indexPath = path.resolve(buildInPluginPath, FILE_INDEX); resolvedPaths.configurePath = path.resolve(buildInPluginPath, FILE_CONFIGURE); resolvedPaths.commanderPath = path.resolve(buildInPluginPath, FILE_COMMANDER); resolvedPaths.packagejsonPath = path.resolve(buildInPluginPath, FILE_PACKAGE); resolvedPaths.exportsPath = path.resolve(buildInPluginPath, FILE_EXPORTS); } else { const basePath = pluginResolver(pluginName); resolvedPaths.indexPath = path.join(basePath, FILE_INDEX); resolvedPaths.configurePath = path.join(basePath, FILE_CONFIGURE); resolvedPaths.commanderPath = path.join(basePath, FILE_COMMANDER); resolvedPaths.packagejsonPath = path.join(basePath, FILE_PACKAGE); resolvedPaths.exportsPath = path.join(basePath, FILE_EXPORTS); } return resolvedPaths; } static resolveSetting(plugin) { const defaultSetting = Plugin.defaultSetting(plugin); const configure = plugin.configure; if (configure && typeof configure === 'object') { const setting = configure.setting; if (typeof setting === 'function') { return Object.assign({}, defaultSetting, setting.call(plugin), plugin._overrideSetting); } else { return Object.assign({}, defaultSetting, setting, plugin._overrideSetting); } } else { return Object.assign({}, defaultSetting, plugin._overrideSetting); } } static resolveConfigParser(plugin) { const defaultConfigParser = Plugin.defaultConfigParser(); const configure = plugin.configure; if (configure && typeof configure === 'object') { const parser = configure.parser; if (typeof parser === 'function') { return parser; } else { return defaultConfigParser; } } else { return defaultConfigParser; } } /** * @param {Plugin} plugin * @param {any} config * @returns {any[]} */ static resolveOptionsConfigs(plugin, config) { const { optionsField } = plugin.setting; if (Array.isArray(optionsField)) { return optionsField.map(field => { return config && config[field]; }); } else { return [config && config[optionsField]]; } } /** * @param {Plugin} plugin * @returns {any[]} */ static resolveDependConfigs(plugin) { const config = plugin.context.config; const dependConfigs = []; if (plugin.setting.dependFields && plugin.setting.dependFields.length) { plugin.setting.dependFields.forEach(depField => { dependConfigs.push( depField.split('.').reduce(((depConfig, curField) => { return depConfig && depConfig[curField]; }), config) ); }); } return dependConfigs; } static resolveEnable(config, setting) { let pluginEnable; const userEnable = pluginEnable = config[setting.enableField]; if (userEnable === undefined || userEnable === null) { config[setting.enableField] = pluginEnable = setting.defaultEnable; } return pluginEnable; } static resolveSettingFromConfig(configName) { if (typeof (configName) === 'string') { return { name: configName, setting: {} }; } else if (Array.isArray(configName)) { const [pluginName, pluginSetting] = configName; return { name: pluginName, setting: pluginSetting || {} }; } else { console.warn(chalk.red('[' + configName + '] is not a valid plugin name format')); return { name: configName, setting: {} }; } } static isSamePluginConfig(configNameA, configNameB) { const { name: nameA, setting: settingA } = Plugin.resolveSettingFromConfig(configNameA); const { name: nameB, setting: settingB } = Plugin.resolveSettingFromConfig(configNameB); return nameA === nameB && isSameOptionField(settingA.optionsField, settingB.optionsField); } /** * @private * Register commanders or listeners */ _extendCmds() { if (this.commander && typeof (this.commander) === 'function') { const plugin = this; // why? binding the corresponding plugin to the setter method Register.prototype.configure = function configureWrapper(field, registerSetter) { registerSetter.plugin = plugin; configure.call(this, field, registerSetter); }; Register.prototype.setChildPlugin = function setSubPluginWrapper(childPluginName, childField, childConfigGenerator) { if (!childConfigGenerator._childId) { childConfigGenerator._childId = createUid(); } const shortId = childConfigGenerator._childId; if (!childPluginShortIds.has(shortId)) { const childPluginConfList = childPluginMaps.get(plugin) || []; childPluginConfList.push(childConfigGenerator._childId); childPluginMaps.set(plugin, childPluginConfList); childPluginShortIds.add(shortId); // only execute once for one child plugin setChildPlugin.call(this, childPluginName, childField, childConfigGenerator); } }; this.commander.call(this, this.context.program, register, this.config); } } /** * @private * Call exposed hook functions defined in user plugins, if not exist use replacement function as fallback * @param {String} method method name * @param {Function} replacement default backup function * @param {...any} args */ _methodWrapper(method, replacement, ...args) { const definedHook = this.middleware[method]; if (definedHook && typeof (definedHook) === 'function') { definedHook.call(this, ...args); } else { replacement(...args); } } beforeCreate(context) { this._methodWrapper('beforeCreate', noop, context); } onRequest(context, next) { this._methodWrapper('onRequest', nextNoop, context, next); } onRouteMatch(context, next) { this._methodWrapper('onRouteMatch', nextNoop, context, next); } beforeProxy(context, next) { this._methodWrapper('beforeProxy', nextNoop, context, next); } onProxySetup(context) { this._methodWrapper('onProxySetup', nextNoop, context); } onProxyRespond(context, next) { this._methodWrapper('onProxyRespond', nextNoop, context, next); } onProxyDataRespond(context, next) { this._methodWrapper('onProxyDataRespond', nextNoop, context, next); } afterProxy(context) { this._methodWrapper('afterProxy', noop, context); } onPipeRequest(context, next) { this._methodWrapper('onPipeRequest', nextChunkNoop, context, next); } onPipeResponse(context, next) { this._methodWrapper('onPipeResponse', nextChunkNoop, context, next); } } Plugin.childPlugins = childPlugins; Plugin.childPluginConfigs = childPluginConfigs; Plugin.childPluginMaps = childPluginMaps; Plugin.modifiedPluginIds = modifiedPluginIds; Plugin.modifiedPlugins = modifiedPlugins; Plugin.AllMiddlewares = [ 'beforeCreate', 'onRequest', 'onRouteMatch', 'beforeProxy', 'onProxySetup', 'onProxyRespond', 'onProxyDataRespond', 'afterProxy', 'onPipeRequest', 'onPipeResponse' ]; Plugin.FILES = { INDEX: FILE_INDEX, PACKAGE: FILE_PACKAGE, COMMANDER: FILE_COMMANDER, CONFIGURE: FILE_CONFIGURE, EXPORTS: FILE_EXPORTS, }; class PluginInterrupt { constructor(plugin, lifehook, message) { this.plugin = plugin; this.lifehook = lifehook; this.message = message; } toString() { return `[Plugin ${this.plugin.name}(${this.plugin.id}):${this.lifehook}] ${this.message}`; } } Plugin.PluginInterrupt = PluginInterrupt; Plugin.isRuntimeChildPlugin = function isRuntimeChildPlugin(plugin) { if (plugin._isRuntimeChildPlugin) { return true; } for (const config of childPluginConfigs) { const { name, setting } = Plugin.resolveSettingFromConfig(config); if (plugin.name === name && isSameOptionField(plugin.setting.optionsField, setting.optionsField)) { plugin._isRuntimeChildPlugin = true; return true; } } } function isSameOptionField(f1, f2) { const ff1 = Array.isArray(f1) ? f1.join('') : f1; const ff2 = Array.isArray(f2) ? f2.join('') : f2; return ff1 === ff2; }; /** * Watch config.plugins fields */ function watchPluginConfig(config) { config.plugins = defineProxy(config.plugins, { setter(t, p, v) { if (!isNaN(p) && p > t.length - 1) { // new runtime child plugin let found; childPluginConfigs.forEach(config => { if (Plugin.isSamePluginConfig(config, v)) { found = true; } }); if (!found) { childPluginConfigs.push(v); } } } }); } function reloadModifiedPlugins() { Plugin.modifiedPlugins.forEach(plugin => { try { plugin.load(); } catch (error) { let pluginErrResult; if (pluginErrResult = error.message.match(/Cannot\sfind\smodule\s'(.+)'/)) { console.log(chalk.red(`${pluginErrResult[0]}. Please check if module '${pluginErrResult[1]}' is installed`)); } else { console.error(error); } } }); Plugin.modifiedPluginIds.clear(); Plugin.modifiedPlugins = modifiedPlugins = []; } module.exports = { Plugin, PluginInterrupt, Register, register, watchPluginConfig, reloadModifiedPlugins };