import { join } from 'path'; import { EventEmitter } from 'events'; import assert from 'assert'; import { AsyncSeriesWaterfallHook } from 'tapable'; import { existsSync } from 'fs'; import { lodash, chalk } from '@umijs/utils'; import { Command, Option } from 'commander'; import { resolvePresets, pathToObj, resolvePlugins } from './utils/pluginUtils'; import loadDotEnv from './utils/loadDotEnv'; import isPromise from './utils/isPromise'; import BabelRegister from './babelRegister'; import PluginAPI from './pluginAPI'; import { ApplyPluginsType, ConfigChangeType, EnableBy, PluginType, ServiceStage } from './enums'; import Config from '../config'; import { getUserConfigWithKey } from '../config/utils/configUtils'; import getPaths from './getPaths'; // TODO // 1. duplicated key // 2. Logger export default class Service extends EventEmitter { cwd; pkg; skipPluginIds = new Set(); // lifecycle stage stage = ServiceStage.uninitialized; // registered commands commands = {}; // including plugins plugins = {}; // plugin methods pluginMethods = {}; // initial presets and plugins from arguments, config, process.env, and package.json initialPresets = []; // initial plugins from arguments, config, process.env, and package.json initialPlugins = []; _extraPresets = []; _extraPlugins = []; // user config userConfig; configInstance; config = null; // babel register babelRegister; // hooks hooksByPluginId = {}; hooks = {}; // paths paths = {}; env; ApplyPluginsType = ApplyPluginsType; EnableBy = EnableBy; ConfigChangeType = ConfigChangeType; ServiceStage = ServiceStage; args; constructor(opts) { super(); this.cwd = opts.cwd || process.cwd(); // repoDir should be the root dir of repo this.pkg = opts.pkg || this.resolvePackage(); this.env = opts.env || process.env.NODE_ENV; this.fesPkg = opts.fesPkg || {}; assert(existsSync(this.cwd), `cwd ${this.cwd} does not exist.`); // register babel before config parsing this.babelRegister = new BabelRegister(); // load .env or .local.env this.loadEnv(); // get user config without validation this.configInstance = new Config({ cwd: this.cwd, service: this, localConfig: this.env === 'development' }); this.userConfig = this.configInstance.getUserConfig(); // get paths this.paths = getPaths({ cwd: this.cwd, config: this.userConfig, env: this.env }); this.program = new Command(); this.initCommand(); // setup initial plugins const baseOpts = { pkg: this.pkg, cwd: this.cwd }; this.initialPresets = resolvePresets({ ...baseOpts, presets: opts.presets || [], userConfigPresets: this.userConfig.presets || [] }); this.initialPlugins = resolvePlugins({ ...baseOpts, plugins: opts.plugins || [], userConfigPlugins: this.userConfig.plugins || [] }); } setStage(stage) { this.stage = stage; } resolvePackage() { try { // eslint-disable-next-line return require(join(this.cwd, "package.json")); } catch (e) { return {}; } } loadEnv() { const basePath = join(this.cwd, '.env'); const localPath = `${basePath}.local`; loadDotEnv(basePath); loadDotEnv(localPath); if (process.env.FES_ENV) { loadDotEnv(`${basePath}.${process.env.FES_ENV}`); } } async init() { this.setStage(ServiceStage.init); await this.initPresetsAndPlugins(); // hooksByPluginId -> hooks // hooks is mapped with hook key, prepared for applyPlugins() this.setStage(ServiceStage.initHooks); Object.keys(this.hooksByPluginId).forEach((id) => { const hooks = this.hooksByPluginId[id]; hooks.forEach((hook) => { const { key } = hook; hook.pluginId = id; this.hooks[key] = (this.hooks[key] || []).concat(hook); }); }); // plugin is totally ready this.setStage(ServiceStage.pluginReady); await this.applyPlugins({ key: 'onPluginReady', type: ApplyPluginsType.event }); // get config, including: // 1. merge default config // 2. validate this.setStage(ServiceStage.getConfig); await this.setConfig(); // merge paths to keep the this.paths ref this.setStage(ServiceStage.getPaths); // config.outputPath may be modified by plugins if (this.config.outputPath) { this.paths.absOutputPath = join(this.cwd, this.config.outputPath); } const paths = await this.applyPlugins({ key: 'modifyPaths', type: ApplyPluginsType.modify, initialValue: this.paths }); Object.keys(paths).forEach((key) => { this.paths[key] = paths[key]; }); } async setConfig() { const defaultConfig = await this.applyPlugins({ key: 'modifyDefaultConfig', type: this.ApplyPluginsType.modify, initialValue: await this.configInstance.getDefaultConfig() }); this.config = await this.applyPlugins({ key: 'modifyConfig', type: this.ApplyPluginsType.modify, initialValue: this.configInstance.getConfig({ defaultConfig }) }); } async initPresetsAndPlugins() { this.setStage(ServiceStage.initPresets); this._extraPlugins = []; while (this.initialPresets.length) { // eslint-disable-next-line await this.initPreset(this.initialPresets.shift()); } this.setStage(ServiceStage.initPlugins); this._extraPlugins.push(...this.initialPlugins); while (this._extraPlugins.length) { // eslint-disable-next-line await this.initPlugin(this._extraPlugins.shift()); } } getPluginAPI(opts) { const pluginAPI = new PluginAPI(opts); // register built-in methods [ 'onPluginReady', 'modifyPaths', 'onStart', 'modifyDefaultConfig', 'modifyConfig' ].forEach((name) => { pluginAPI.registerMethod({ name, exitsError: false }); }); return new Proxy(pluginAPI, { get: (target, prop) => { // 由于 pluginMethods 需要在 register 阶段可用 // 必须通过 proxy 的方式动态获取最新,以实现边注册边使用的效果 if (this.pluginMethods[prop]) return this.pluginMethods[prop]; if ( [ 'applyPlugins', 'ApplyPluginsType', 'EnableBy', 'ConfigChangeType', 'babelRegister', 'stage', 'ServiceStage', 'paths', 'cwd', 'pkg', 'configInstance', 'userConfig', 'config', 'env', 'args', 'hasPlugins', 'hasPresets', 'setConfig' ].includes(prop) ) { return typeof this[prop] === 'function' ? this[prop].bind(this) : this[prop]; } return target[prop]; } }); } async applyAPI(opts) { let ret = opts.apply()(opts.api); if (isPromise(ret)) { ret = await ret; } return ret || {}; } async initPreset(preset) { const { id, key, apply } = preset; preset.isPreset = true; const api = this.getPluginAPI({ id, key, service: this }); // register before apply this.registerPlugin(preset); const { presets, plugins } = await this.applyAPI({ api, apply }); // register extra presets and plugins if (presets) { assert( Array.isArray(presets), `presets returned from preset ${id} must be Array.` ); // 插到最前面,下个 while 循环优先执行 this._extraPresets.splice( 0, 0, ...presets.map(path => pathToObj({ type: PluginType.preset, path, cwd: this.cwd })) ); } // 深度优先 const extraPresets = lodash.clone(this._extraPresets); this._extraPresets = []; while (extraPresets.length) { // eslint-disable-next-line await this.initPreset(extraPresets.shift()); } if (plugins) { assert( Array.isArray(plugins), `plugins returned from preset ${id} must be Array.` ); this._extraPlugins.push( ...plugins.map(path => pathToObj({ type: PluginType.plugin, path, cwd: this.cwd })) ); } } async initPlugin(plugin) { const { id, key, apply } = plugin; const api = this.getPluginAPI({ id, key, service: this }); // register before apply this.registerPlugin(plugin); await this.applyAPI({ api, apply }); } getPluginOptsWithKey(key) { return getUserConfigWithKey({ key, userConfig: this.userConfig }); } registerPlugin(plugin) { this.plugins[plugin.id] = plugin; } isPluginEnable(pluginId) { // api.skipPlugins() 的插件 if (this.skipPluginIds.has(pluginId)) return false; const { key, enableBy } = this.plugins[pluginId]; // 手动设置为 false if (this.userConfig[key] === false) return false; // 配置开启 if (enableBy === this.EnableBy.config && !(key in this.userConfig)) { return false; } // 函数自定义开启 if (typeof enableBy === 'function') { return enableBy(); } // 注册开启 return true; } hasPresets(presetIds) { return presetIds.every((presetId) => { const preset = this.plugins[presetId]; return preset && preset.isPreset && this.isPluginEnable(presetId); }); } hasPlugins(pluginIds) { return pluginIds.every((pluginId) => { const plugin = this.plugins[pluginId]; return plugin && !plugin.isPreset && this.isPluginEnable(pluginId); }); } async applyPlugins(opts) { const hooks = this.hooks[opts.key] || []; switch (opts.type) { case ApplyPluginsType.add: if ('initialValue' in opts) { assert( Array.isArray(opts.initialValue), 'applyPlugins failed, opts.initialValue must be Array if opts.type is add.' ); } // eslint-disable-next-line const tAdd = new AsyncSeriesWaterfallHook(["memo"]); for (const hook of hooks) { if (!this.isPluginEnable(hook.pluginId)) { continue; } tAdd.tapPromise( { name: hook.pluginId, stage: hook.stage || 0, // @ts-ignore before: hook.before }, async (memo) => { const items = await hook.fn(opts.args); return memo.concat(items); } ); } return tAdd.promise(opts.initialValue || []); case ApplyPluginsType.modify: // eslint-disable-next-line const tModify = new AsyncSeriesWaterfallHook(["memo"]); for (const hook of hooks) { if (!this.isPluginEnable(hook.pluginId)) { continue; } tModify.tapPromise( { name: hook.pluginId, stage: hook.stage || 0, // @ts-ignore before: hook.before }, async memo => hook.fn(memo, opts.args) ); } return tModify.promise(opts.initialValue); case ApplyPluginsType.event: // eslint-disable-next-line const tEvent = new AsyncSeriesWaterfallHook(["_"]); for (const hook of hooks) { if (!this.isPluginEnable(hook.pluginId)) { continue; } tEvent.tapPromise( { name: hook.pluginId, stage: hook.stage || 0, // @ts-ignore before: hook.before }, async () => { await hook.fn(opts.args); } ); } return tEvent.promise(); default: throw new Error( `applyPlugin failed, type is not defined or is not matched, got ${opts.type}.` ); } } initCommand() { this.program .usage(' [options]') .version(`@webank/fes ${this.fesPkg.version}`, '-v, --vers', 'output the current version') .description(chalk.cyan('一个好用的前端应用解决方案')); } parseCommand() { this.program.on('--help', () => { console.log(); console.log( ` Run ${chalk.cyan( 'fes --help' )} for detailed usage of given command.` ); console.log(); }); this.program.commands.forEach(c => c.on('--help', () => console.log())); this.program.parse(process.argv); } async run({ rawArgv = {}, args = {} }) { await this.init(); this.setStage(ServiceStage.run); await this.applyPlugins({ key: 'onStart', type: ApplyPluginsType.event, args: { args } }); this.runCommand({ rawArgv, args }); } async runCommand({ rawArgv = {}, args = {} }) { assert(this.stage >= ServiceStage.init, 'service is not initialized.'); Object.keys(this.commands).forEach((command) => { const commandOptionConfig = this.commands[command]; const program = this.program; let c = program.command(command).description(commandOptionConfig.description); if (Array.isArray(commandOptionConfig.options)) { commandOptionConfig.options.forEach((config) => { const option = new Option(config.name, config.description); if (config.default) { option.default(config.default); } if (config.choices) { option.choices(config.choices); } c = c.addOption(option); }); } if (commandOptionConfig.fn) { c.action(async () => { await commandOptionConfig.fn({ rawArgv, args, options: c.opts(), program }); }); } }); this.parseCommand(); } }