UNPKG

egg-ts-helper

Version:
510 lines (431 loc) 14.9 kB
import chokidar from 'chokidar'; import assert from 'node:assert'; import { EventEmitter } from 'node:events'; import fs from 'node:fs'; import crypto from 'node:crypto'; import chalk from 'chalk'; import path from 'node:path'; import * as generator from './generator'; import { get as deepGet, set as deepSet } from 'dot-prop'; import { declMapping, dtsComment, dtsCommentRE } from './config'; import Watcher, { WatchItem } from './watcher'; import { BaseGenerator } from './generators/base'; import * as utils from './utils'; import { CompilerOptions } from 'typescript'; import glob from 'globby'; import { debuglog } from 'node:util'; const isInUnitTest = process.env.NODE_ENV === 'test'; const debug = debuglog('egg-ts-helper/core'); declare global { interface PlainObject<T = any> { [key: string]: T; } } export interface TsHelperOption { cwd?: string; framework?: string; frameworkVersion?: string; typings?: string; generatorConfig?: { [key: string]: WatchItem | boolean }; /** @deprecated alias of generatorConfig, has been deprecated */ watchDirs?: { [key: string]: WatchItem | boolean }; caseStyle?: string | ((...args: any[]) => string); watch?: boolean; watchOptions?: chokidar.WatchOptions; autoRemoveJs?: boolean; throttle?: number; execAtInit?: boolean; customLoader?: any; configFile?: string | string[]; silent?: boolean; } export type TsHelperConfig = typeof defaultConfig & { id: string; eggInfo: utils.EggInfoResult; customLoader: any; tsConfig: CompilerOptions; }; export type TsGenConfig = { name: string; dir: string; dtsDir: string; fileList: string[]; file?: string; } & WatchItem; export interface GeneratorResult { dist: string; content?: string; } type GeneratorAllResult = GeneratorResult | GeneratorResult[]; type GeneratorCbResult<T> = T | Promise<T>; export type TsGenerator<T = GeneratorAllResult | void> = (( config: TsGenConfig, baseConfig: TsHelperConfig, tsHelper: TsHelper, ) => GeneratorCbResult<T>) & { defaultConfig?: WatchItem; }; export const defaultConfig = { cwd: utils.convertString(process.env.ETS_CWD, process.cwd()), framework: utils.convertString(process.env.ETS_FRAMEWORK, 'egg'), frameworkVersion: utils.convertString(process.env.ETS_FRAMEWORK_VERSION, ''), typings: utils.convertString(process.env.ETS_TYPINGS, './typings'), caseStyle: utils.convertString(process.env.ETS_CASE_STYLE, 'lower'), autoRemoveJs: utils.convertString(process.env.ETS_AUTO_REMOVE_JS, true), throttle: utils.convertString(process.env.ETS_THROTTLE, 500), watch: utils.convertString(process.env.ETS_WATCH, false), watchOptions: undefined, execAtInit: utils.convertString(process.env.ETS_EXEC_AT_INIT, false), silent: utils.convertString(process.env.ETS_SILENT, isInUnitTest), generatorConfig: {} as PlainObject<WatchItem>, configFile: utils.convertString(process.env.ETS_CONFIG_FILE, '') || [ './tshelper', './tsHelper' ], }; // default watch dir export function getDefaultGeneratorConfig(opt?: TsHelperConfig) { const baseConfig: { [key: string]: Partial<WatchItem> } = {}; // extend baseConfig.extend = { directory: 'app/extend', generator: 'extend', }; // controller baseConfig.controller = { directory: 'app/controller', interface: declMapping.controller, generator: 'class', }; // middleware baseConfig.middleware = { directory: 'app/middleware', interface: declMapping.middleware, generator: 'object', }; // proxy baseConfig.proxy = { directory: 'app/proxy', interface: 'IProxy', generator: 'class', enabled: false, }; // model baseConfig.model = { directory: 'app/model', generator: 'function', interface: 'IModel', caseStyle: 'upper', enabled: !deepGet(opt?.eggInfo, 'config.customLoader.model'), }; // config baseConfig.config = { directory: 'config', generator: 'config', trigger: [ 'add', 'unlink', 'change' ], }; // plugin baseConfig.plugin = { directory: 'config', generator: 'plugin', trigger: [ 'add', 'unlink', 'change' ], }; // service baseConfig.service = { directory: 'app/service', interface: declMapping.service, generator: 'auto', }; // egg baseConfig.egg = { directory: 'app', generator: 'egg', watch: false, }; // custom loader baseConfig.customLoader = { generator: 'custom', trigger: [ 'add', 'unlink', 'change' ], }; return baseConfig as PlainObject; } export default class TsHelper extends EventEmitter { config: TsHelperConfig; watcherList: Watcher[] = []; private cacheDist: PlainObject = {}; private dtsFileList: string[] = []; // utils public utils = utils; constructor(options: TsHelperOption) { super(); // configure ets this.configure(options); // init watcher this.initWatcher(); } // build all watcher build() { // clean old files this.cleanFiles(); this.watcherList.forEach(watcher => watcher.execute()); return this; } // destroy destroy() { this.removeAllListeners(); this.watcherList.forEach(item => item.destroy()); this.watcherList.length = 0; } // log log(info: string, ignoreSilent?: boolean) { if (!ignoreSilent && this.config.silent) { return; } utils.log(info); } warn(info: string) { this.log(chalk.yellow(info), !isInUnitTest); } // create oneForAll file createOneForAll(dist?: string) { const config = this.config; const oneForAllDist = (typeof dist === 'string') ? dist : path.join(config.typings, './ets.d.ts'); const oneForAllDistDir = path.dirname(oneForAllDist); // create d.ts includes all types. const distContent = dtsComment + this.dtsFileList .map(file => { const importUrl = path .relative(oneForAllDistDir, file.replace(/\.d\.ts$/, '')) .replace(/\/|\\/g, '/'); return `import '${importUrl.startsWith('.') ? importUrl : `./${importUrl}`}';`; }) .join('\n'); this.emit('update', oneForAllDist); utils.writeFileSync(oneForAllDist, distContent); } // init watcher private initWatcher() { Object.keys(this.config.generatorConfig).forEach(key => { this.registerWatcher(key, this.config.generatorConfig[key], false); }); } // destroy watcher destroyWatcher(...refs: string[]) { this.watcherList = this.watcherList.filter(w => { if (refs.includes(w.ref)) { w.destroy(); return false; } return true; }); } // clean old files in startup cleanFiles() { const cwd = this.config.typings; glob.sync([ '**/*.d.ts', '!**/node_modules' ], { cwd }) .forEach(file => { const fileUrl = path.resolve(cwd, file); const content = fs.readFileSync(fileUrl, 'utf-8'); const isGeneratedByEts = content.match(dtsCommentRE); if (isGeneratedByEts) fs.unlinkSync(fileUrl); }); } // register watcher registerWatcher(name: string, watchConfig: WatchItem & { directory: string | string[]; }, removeDuplicate = true) { if (removeDuplicate) { this.destroyWatcher(name); } if (watchConfig.hasOwnProperty('enabled') && !watchConfig.enabled) { return; } const directories = Array.isArray(watchConfig.directory) ? watchConfig.directory : [ watchConfig.directory ]; // support array directory. return directories.map(dir => { const options = { name, ref: name, execAtInit: this.config.execAtInit, ...watchConfig, }; if (dir) { options.directory = dir; } if (!this.config.watch) { options.watch = false; } const watcher = new Watcher(this); watcher.on('update', this.generateTs.bind(this)); watcher.init(options); this.watcherList.push(watcher); return watcher; }); } private loadWatcherConfig(config: TsHelperConfig, options: TsHelperOption) { const configFile = options.configFile || config.configFile; const eggInfo = config.eggInfo; const getConfigFromPkg = pkg => (pkg.egg || {}).tsHelper; // read from enabled plugins if (eggInfo.plugins) { Object.keys(eggInfo.plugins) .forEach(k => { const pluginInfo = eggInfo.plugins![k]; if (pluginInfo.enable && pluginInfo.path) { this.mergeConfig(config, getConfigFromPkg(utils.getPkgInfo(pluginInfo.path))); } }); } // read from eggPaths if (eggInfo.eggPaths) { eggInfo.eggPaths.forEach(p => { this.mergeConfig(config, getConfigFromPkg(utils.getPkgInfo(p))); }); } // read from package.json this.mergeConfig(config, getConfigFromPkg(utils.getPkgInfo(config.cwd))); // read from local file( default to tshelper | tsHelper ) (Array.isArray(configFile) ? configFile : [ configFile ]).forEach(f => { this.mergeConfig(config, utils.requireFile(path.resolve(config.cwd, f))); }); // merge local config and options to config this.mergeConfig(config, options); // create extra config config.tsConfig = utils.loadTsConfig(path.resolve(config.cwd, './tsconfig.json')); } // configure // options > configFile > package.json private configure(options: TsHelperOption) { if (options.cwd) { options.cwd = path.resolve(defaultConfig.cwd, options.cwd); } // base config const config = { ...defaultConfig } as TsHelperConfig; config.id = crypto.randomBytes(16).toString('base64'); config.cwd = options.cwd || config.cwd; config.customLoader = config.customLoader || options.customLoader; // load egg info config.eggInfo = utils.getEggInfo({ cwd: config.cwd!, cacheIndex: config.id, customLoader: config.customLoader, }); config.framework = options.framework || defaultConfig.framework; config.frameworkVersion = options.frameworkVersion || defaultConfig.frameworkVersion; if (!config.frameworkVersion) { const frameworkPackageJSONFile = utils.resolveModule(`${config.framework}/package.json`, config.cwd); if (frameworkPackageJSONFile) { const frameworkPackageJSON = utils.readJson(frameworkPackageJSONFile); config.frameworkVersion = frameworkPackageJSON.version; } } config.generatorConfig = getDefaultGeneratorConfig(config); config.typings = path.resolve(config.cwd, config.typings); this.config = config; debug('config %o', this.config); // load watcher config this.loadWatcherConfig(this.config, options); // deprecated framework when env.ETS_FRAMEWORK exists if (this.config.framework && this.config.framework !== defaultConfig.framework && process.env.ETS_FRAMEWORK) { this.warn(`options.framework are deprecated, using default value(${defaultConfig.framework}) instead`); } } private generateTs(result: GeneratorCbResult<GeneratorAllResult>, file: string | undefined, startTime: number) { const updateTs = (result: GeneratorAllResult, file?: string) => { const config = this.config; const resultList = Array.isArray(result) ? result : [ result ]; for (const item of resultList) { // check cache if (this.isCached(item.dist, item.content)) { return; } if (item.content) { // create file const dtsContent = `${dtsComment}\nimport '${config.framework}';\n${item.content}`; utils.writeFileSync(item.dist, dtsContent); this.emit('update', item.dist, file); this.log(`create ${path.relative(this.config.cwd, item.dist)} (${Date.now() - startTime}ms)`); this.updateDistFiles(item.dist); } else { if (!fs.existsSync(item.dist)) { return; } // remove file fs.unlinkSync(item.dist); delete this.cacheDist[item.dist]; this.emit('remove', item.dist, file); this.log(`delete ${path.relative(this.config.cwd, item.dist)} (${Date.now() - startTime}ms)`); this.updateDistFiles(item.dist, true); } } }; if (typeof (result as any).then === 'function') { return (result as Promise<GeneratorAllResult>) .then(r => updateTs(r, file)) .catch(e => { this.log(e.message); }); } updateTs(result as GeneratorAllResult, file); } private updateDistFiles(fileUrl: string, isRemove?: boolean) { const index = this.dtsFileList.indexOf(fileUrl); if (index >= 0) { if (isRemove) { this.dtsFileList.splice(index, 1); } } else { this.dtsFileList.push(fileUrl); } } private isCached(fileUrl, content) { const cacheItem = this.cacheDist[fileUrl]; if (content && cacheItem === content) { // no need to create file content is not changed. return true; } this.cacheDist[fileUrl] = content; return false; } // support dot prop config private formatConfig(config) { const newConfig: any = {}; Object.keys(config).forEach(key => deepSet(newConfig, key, config[key])); return newConfig; } // merge ts helper options private mergeConfig(base: TsHelperConfig, ...args: Array<TsHelperOption | undefined>) { args.forEach(opt => { if (!opt) return; const config = this.formatConfig(opt); // compatitable for alias of generatorCofig if (config.watchDirs) config.generatorConfig = config.watchDirs; Object.keys(config).forEach(key => { if (key !== 'generatorConfig') { base[key] = config[key] === undefined ? base[key] : config[key]; return; } const generatorConfig = config.generatorConfig || {}; Object.keys(generatorConfig).forEach(k => { const item = generatorConfig[k]; if (typeof item === 'boolean') { if (base.generatorConfig[k]) base.generatorConfig[k].enabled = item; } else if (item) { // check private generator assert(!generator.isPrivateGenerator(item.generator), `${item.generator} is a private generator, can not configure in config file`); // compatible for deprecated fields [ [ 'path', 'directory' ], ].forEach(([ oldValue, newValue ]) => { if (item[oldValue]) { item[newValue] = item[oldValue]; } }); if (base.generatorConfig[k]) { Object.assign(base.generatorConfig[k], item); } else { base.generatorConfig[k] = item; } } }); }); }); } } export function createTsHelperInstance(options: TsHelperOption) { return new TsHelper(options); } export { TsHelper, WatchItem, BaseGenerator, generator };