diff --git a/.eslintrc b/.eslintrc index 1f6ab679..9bcdb468 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,4 +1,6 @@ { - "root": true, - "extends": "eslint-config-egg" + "extends": [ + "eslint-config-egg/typescript", + "eslint-config-egg/lib/rules/enforce-node-prefix" + ] } diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 02e278b8..00000000 --- a/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -# make sure git clone linebreak keep as \n on windows -# https://eslint.org/docs/rules/linebreak-style -*.js text eol=lf diff --git a/index.js b/index.js deleted file mode 100644 index 2d9cb181..00000000 --- a/index.js +++ /dev/null @@ -1,11 +0,0 @@ -const EggCore = require('./lib/egg'); -const EggLoader = require('./lib/loader/egg_loader'); -const BaseContextClass = require('./lib/utils/base_context_class'); -const utils = require('./lib/utils'); - -module.exports = { - EggCore, - EggLoader, - BaseContextClass, - utils, -}; diff --git a/lib/lifecycle.js b/lib/lifecycle.js deleted file mode 100644 index e44e15ee..00000000 --- a/lib/lifecycle.js +++ /dev/null @@ -1,276 +0,0 @@ -'use strict'; - -const is = require('is-type-of'); -const assert = require('assert'); -const getReady = require('get-ready'); -const { Ready } = require('ready-callback'); -const { EventEmitter } = require('events'); -const debug = require('debug')('egg-core:lifecycle'); -const utils = require('./utils'); - -const INIT = Symbol('Lifycycle#init'); -const INIT_READY = Symbol('Lifecycle#initReady'); -const DELEGATE_READY_EVENT = Symbol('Lifecycle#delegateReadyEvent'); -const REGISTER_READY_CALLBACK = Symbol('Lifecycle#registerReadyCallback'); -const CLOSE_SET = Symbol('Lifecycle#closeSet'); -const IS_CLOSED = Symbol('Lifecycle#isClosed'); -const BOOT_HOOKS = Symbol('Lifecycle#bootHooks'); -const BOOTS = Symbol('Lifecycle#boots'); - -class Lifecycle extends EventEmitter { - - /** - * @param {object} options - options - * @param {String} options.baseDir - the directory of application - * @param {EggCore} options.app - Application instance - * @param {Logger} options.logger - logger - */ - constructor(options) { - super(); - this.options = options; - this[BOOT_HOOKS] = []; - this[BOOTS] = []; - this[CLOSE_SET] = new Set(); - this[IS_CLOSED] = false; - this[INIT] = false; - getReady.mixin(this); - - this.timing.start('Application Start'); - // get app timeout from env or use default timeout 10 second - const eggReadyTimeoutEnv = Number.parseInt(process.env.EGG_READY_TIMEOUT_ENV || 10000); - assert( - Number.isInteger(eggReadyTimeoutEnv), - `process.env.EGG_READY_TIMEOUT_ENV ${process.env.EGG_READY_TIMEOUT_ENV} should be able to parseInt.`); - this.readyTimeout = eggReadyTimeoutEnv; - - this[INIT_READY](); - this - .on('ready_stat', data => { - this.logger.info('[egg:core:ready_stat] end ready task %s, remain %j', data.id, data.remain); - }) - .on('ready_timeout', id => { - this.logger.warn('[egg:core:ready_timeout] %s seconds later %s was still unable to finish.', this.readyTimeout / 1000, id); - }); - - this.ready(err => { - this.triggerDidReady(err); - this.timing.end('Application Start'); - }); - } - - get app() { - return this.options.app; - } - - get logger() { - return this.options.logger; - } - - get timing() { - return this.app.timing; - } - - legacyReadyCallback(name, opt) { - const timingKeyPrefix = 'readyCallback'; - const timing = this.timing; - const cb = this.loadReady.readyCallback(name, opt); - const timingkey = `${timingKeyPrefix} in ` + utils.getResolvedFilename(name, this.app.baseDir); - this.timing.start(timingkey); - return function legacyReadyCallback(...args) { - timing.end(timingkey); - cb(...args); - }; - } - - addBootHook(hook) { - assert(this[INIT] === false, 'do not add hook when lifecycle has been initialized'); - this[BOOT_HOOKS].push(hook); - } - - addFunctionAsBootHook(hook) { - assert(this[INIT] === false, 'do not add hook when lifecycle has been initialized'); - // app.js is exported as a function - // call this function in configDidLoad - this[BOOT_HOOKS].push(class Hook { - constructor(app) { - this.app = app; - } - configDidLoad() { - hook(this.app); - } - }); - } - - /** - * init boots and trigger config did config - */ - init() { - assert(this[INIT] === false, 'lifecycle have been init'); - this[INIT] = true; - this[BOOTS] = this[BOOT_HOOKS].map(t => new t(this.app)); - } - - registerBeforeStart(scope, name) { - this[REGISTER_READY_CALLBACK]({ - scope, - ready: this.loadReady, - timingKeyPrefix: 'Before Start', - scopeFullName: name, - }); - } - - registerBeforeClose(fn) { - assert(is.function(fn), 'argument should be function'); - assert(this[IS_CLOSED] === false, 'app has been closed'); - this[CLOSE_SET].add(fn); - } - - async close() { - // close in reverse order: first created, last closed - const closeFns = Array.from(this[CLOSE_SET]); - for (const fn of closeFns.reverse()) { - await utils.callFn(fn); - this[CLOSE_SET].delete(fn); - } - // Be called after other close callbacks - this.app.emit('close'); - this.removeAllListeners(); - this.app.removeAllListeners(); - this[IS_CLOSED] = true; - } - - triggerConfigWillLoad() { - for (const boot of this[BOOTS]) { - if (boot.configWillLoad) { - boot.configWillLoad(); - } - } - this.triggerConfigDidLoad(); - } - - triggerConfigDidLoad() { - for (const boot of this[BOOTS]) { - if (boot.configDidLoad) { - boot.configDidLoad(); - } - // function boot hook register after configDidLoad trigger - const beforeClose = boot.beforeClose && boot.beforeClose.bind(boot); - if (beforeClose) { - this.registerBeforeClose(beforeClose); - } - } - this.triggerDidLoad(); - } - - triggerDidLoad() { - debug('register didLoad'); - for (const boot of this[BOOTS]) { - const didLoad = boot.didLoad && boot.didLoad.bind(boot); - if (didLoad) { - this[REGISTER_READY_CALLBACK]({ - scope: didLoad, - ready: this.loadReady, - timingKeyPrefix: 'Did Load', - scopeFullName: boot.fullPath + ':didLoad', - }); - } - } - } - - triggerWillReady() { - debug('register willReady'); - this.bootReady.start(); - for (const boot of this[BOOTS]) { - const willReady = boot.willReady && boot.willReady.bind(boot); - if (willReady) { - this[REGISTER_READY_CALLBACK]({ - scope: willReady, - ready: this.bootReady, - timingKeyPrefix: 'Will Ready', - scopeFullName: boot.fullPath + ':willReady', - }); - } - } - } - - triggerDidReady(err) { - debug('trigger didReady'); - (async () => { - for (const boot of this[BOOTS]) { - if (boot.didReady) { - try { - await boot.didReady(err); - } catch (e) { - this.emit('error', e); - } - } - } - debug('trigger didReady done'); - })(); - } - - triggerServerDidReady() { - (async () => { - for (const boot of this[BOOTS]) { - try { - await utils.callFn(boot.serverDidReady, null, boot); - } catch (e) { - this.emit('error', e); - } - } - })(); - } - - [INIT_READY]() { - this.loadReady = new Ready({ timeout: this.readyTimeout }); - this[DELEGATE_READY_EVENT](this.loadReady); - this.loadReady.ready(err => { - debug('didLoad done'); - if (err) { - this.ready(err); - } else { - this.triggerWillReady(); - } - }); - - this.bootReady = new Ready({ timeout: this.readyTimeout, lazyStart: true }); - this[DELEGATE_READY_EVENT](this.bootReady); - this.bootReady.ready(err => { - this.ready(err || true); - }); - } - - [DELEGATE_READY_EVENT](ready) { - ready.once('error', err => ready.ready(err)); - ready.on('ready_timeout', id => this.emit('ready_timeout', id)); - ready.on('ready_stat', data => this.emit('ready_stat', data)); - ready.on('error', err => this.emit('error', err)); - } - - [REGISTER_READY_CALLBACK]({ scope, ready, timingKeyPrefix, scopeFullName }) { - if (!is.function(scope)) { - throw new Error('boot only support function'); - } - - // get filename from stack if scopeFullName is undefined - const name = scopeFullName || utils.getCalleeFromStack(true, 4); - const timingkey = `${timingKeyPrefix} in ` + utils.getResolvedFilename(name, this.app.baseDir); - - this.timing.start(timingkey); - - const done = ready.readyCallback(name); - - // ensure scope executes after load completed - process.nextTick(() => { - utils.callFn(scope).then(() => { - done(); - this.timing.end(timingkey); - }, err => { - done(err); - this.timing.end(timingkey); - }); - }); - } -} - -module.exports = Lifecycle; diff --git a/lib/utils/timing.js b/lib/utils/timing.js deleted file mode 100644 index bbf5c617..00000000 --- a/lib/utils/timing.js +++ /dev/null @@ -1,104 +0,0 @@ -'use strict'; - -const { EOL } = require('os'); -const assert = require('assert'); - -const MAP = Symbol('Timing#map'); -const LIST = Symbol('Timing#list'); - -class Timing { - constructor() { - this._enable = true; - this._start = null; - this[MAP] = new Map(); - this[LIST] = []; - - this.init(); - } - - init() { - // process start time - this.start('Process Start', Date.now() - Math.floor(process.uptime() * 1000)); - this.end('Process Start'); - - if (typeof process.scriptStartTime === 'number') { - // js script start execute time - this.start('Script Start', process.scriptStartTime); - this.end('Script Start'); - } - } - - start(name, start) { - if (!name || !this._enable) return; - - if (this[MAP].has(name)) this.end(name); - - start = start || Date.now(); - if (this._start === null) { - this._start = start; - } - const item = { - name, - start, - end: undefined, - duration: undefined, - pid: process.pid, - index: this[LIST].length, - }; - this[MAP].set(name, item); - this[LIST].push(item); - return item; - } - - end(name) { - if (!name || !this._enable) return; - assert(this[MAP].has(name), `should run timing.start('${name}') first`); - - const item = this[MAP].get(name); - item.end = Date.now(); - item.duration = item.end - item.start; - return item; - } - - enable() { - this._enable = true; - } - - disable() { - this._enable = false; - } - - clear() { - this[MAP].clear(); - this[LIST] = []; - } - - toJSON() { - return this[LIST]; - } - - itemToString(timelineEnd, item, times) { - const isEnd = typeof item.duration === 'number'; - const duration = isEnd ? item.duration : timelineEnd - item.start; - const offset = item.start - this._start; - const status = `${duration}ms${isEnd ? '' : ' NOT_END'}`; - const timespan = Math.floor((offset * times).toFixed(6)); - let timeline = Math.floor((duration * times).toFixed(6)); - timeline = timeline > 0 ? timeline : 1; // make sure there is at least one unit - const message = `#${item.index} ${item.name}`; - return ' '.repeat(timespan) + '▇'.repeat(timeline) + ` [${status}] - ${message}`; - } - - toString(prefix = 'egg start timeline:', width = 50) { - const timelineEnd = Date.now(); - const timelineDuration = timelineEnd - this._start; - let times = 1; - if (timelineDuration > width) { - times = width / timelineDuration; - } - // follow https://github.com/node-modules/time-profile/blob/master/lib/profiler.js#L88 - return prefix + EOL + this[LIST].map(item => this.itemToString(timelineEnd, item, times)).join(EOL); - } -} - -module.exports = Timing; diff --git a/package.json b/package.json index 3211909f..4073e3f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@eggjs/core", "version": "5.3.1", - "description": "A core Pluggable framework based on koa", + "description": "A core plugin framework based on koa", "main": "lib/index.js", "types": "lib/index.d.ts", "files": [ @@ -9,12 +9,13 @@ ], "scripts": { "lint": "eslint .", - "test": "npm run lint -- --fix && npm run test-local", - "test-local": "egg-bin test -p --ts false", - "test-single": "egg-bin test --ts false", - "cov": "egg-bin cov -p --ts false", - "ci": "npm run lint && npm run cov", - "contributor": "git-contributor" + "test": "npm run lint -- --fix && npm run test-local -- -p", + "test-local": "egg-bin test", + "ci": "egg-bin cov -p", + "contributor": "git-contributor", + "clean": "tsc -b --clean", + "tsc": "tsc", + "prepublishOnly": "npm run tsc" }, "repository": { "type": "git", @@ -33,8 +34,29 @@ "engines": { "node": ">= 16.13.0" }, + "dependencies": { + "@eggjs/koa": "^2.15.1", + "@eggjs/router": "^2.0.1", + "@types/depd": "^1.1.32", + "co": "^4.6.0", + "depd": "^2.0.0", + "egg-logger": "^3.3.1", + "egg-path-matching": "^1.0.1", + "extend2": "^1.0.0", + "gals": "^1.0.1", + "get-ready": "^3.0.0", + "globby": "^11.0.2", + "is-type-of": "^1.2.1", + "koa-convert": "^1.2.0", + "node-homedir": "^1.1.1", + "ready-callback": "^3.0.0", + "tsconfig-paths": "^4.1.1", + "utility": "^1.16.1" + }, "devDependencies": { "@eggjs/tsconfig": "^1.3.3", + "@types/mocha": "^10.0.1", + "@types/node": "^20.2.5", "await-event": "^2.1.0", "coffee": "^5.2.1", "egg-bin": "^6.4.1", @@ -51,27 +73,6 @@ "typescript": "^5.1.3", "urllib": "^3.10.0" }, - "dependencies": { - "@eggjs/koa": "^2.15.0", - "@eggjs/router": "^2.0.0", - "@types/depd": "^1.1.32", - "@types/koa": "^2.13.5", - "co": "^4.6.0", - "debug": "^4.1.1", - "depd": "^2.0.0", - "egg-logger": "^3.1.0", - "egg-path-matching": "^1.0.1", - "extend2": "^1.0.0", - "gals": "^1.0.1", - "get-ready": "^2.0.1", - "globby": "^11.0.2", - "is-type-of": "^1.2.1", - "koa-convert": "^1.2.0", - "node-homedir": "^1.1.1", - "ready-callback": "^3.0.0", - "tsconfig-paths": "^4.1.1", - "utility": "^1.16.1" - }, "publishConfig": { "access": "public" } diff --git a/lib/egg.js b/src/egg.ts similarity index 77% rename from lib/egg.js rename to src/egg.ts index f3ad6ccf..7f1c360f 100644 --- a/lib/egg.js +++ b/src/egg.ts @@ -1,23 +1,44 @@ -const assert = require('assert'); -const fs = require('fs'); -const KoaApplication = require('@eggjs/koa').default; -const EggConsoleLogger = require('egg-logger').EggConsoleLogger; -const debug = require('debug')('egg-core'); -const is = require('is-type-of'); -const co = require('co'); -const Router = require('@eggjs/router').EggRouter; -const { getAsyncLocalStorage } = require('gals'); -const BaseContextClass = require('./utils/base_context_class'); -const utils = require('./utils'); -const Timing = require('./utils/timing'); -const Lifecycle = require('./lifecycle'); +import assert from 'node:assert'; +import fs from 'node:fs'; +import { debuglog } from 'node:util'; +import is from 'is-type-of'; +import KoaApplication from '@eggjs/koa'; +import type { MiddlewareFunc } from '@eggjs/koa'; +import { EggConsoleLogger } from 'egg-logger'; +import { EggRouter as Router } from '@eggjs/router'; +import type { ReadyFunctionArg } from 'get-ready'; +import { getAsyncLocalStorage } from 'gals'; +import { BaseContextClass } from './utils/base_context_class'; +import utils from './utils'; +import { Timing } from './utils/timing'; +import type { Fun } from './utils'; +import { Lifecycle } from './lifecycle'; +import type { EggLoader } from './loader/egg_loader'; + +const debug = debuglog('@eggjs/core:egg'); const DEPRECATE = Symbol('EggCore#deprecate'); const ROUTER = Symbol('EggCore#router'); const EGG_LOADER = Symbol.for('egg#loader'); const CLOSE_PROMISE = Symbol('EggCore#closePromise'); -class EggCore extends KoaApplication { +export interface EggCoreOptions { + baseDir: string; + type: 'application' | 'agent'; + plugins?: any; + serverScope?: string; + env?: string; +} + +export class EggCore extends KoaApplication { + options: EggCoreOptions; + timing: Timing; + console: EggConsoleLogger; + BaseContextClass: typeof BaseContextClass; + Controller: typeof BaseContextClass; + Service: typeof BaseContextClass; + lifecycle: Lifecycle; + loader: EggLoader; /** * @class @@ -27,7 +48,7 @@ class EggCore extends KoaApplication { * @param {Object} [options.plugins] - custom plugins * @since 1.0.0 */ - constructor(options = {}) { + constructor(options: Partial = {}) { options.baseDir = options.baseDir || process.cwd(); options.type = options.type || 'application'; @@ -35,7 +56,6 @@ class EggCore extends KoaApplication { assert(fs.existsSync(options.baseDir), `Directory ${options.baseDir} not exists`); assert(fs.statSync(options.baseDir).isDirectory(), `Directory ${options.baseDir} is not a directory`); assert(options.type === 'application' || options.type === 'agent', 'options.type should be application or agent'); - // disable koa als and use egg logic super({ asyncLocalStorage: false }); // can access the AsyncLocalStorage instance in global @@ -51,8 +71,7 @@ class EggCore extends KoaApplication { * @private * @since 1.0.0 */ - this._options = this.options = options; - this.deprecate.property(this, '_options', 'app._options is deprecated, use app.options instead'); + this.options = options as EggCoreOptions; /** * logging for EggCore, avoid using console directly @@ -129,13 +148,11 @@ class EggCore extends KoaApplication { /** * override koa's app.use, support generator function - * @param {Function} fn - middleware - * @return {Application} app * @since 1.0.0 */ - use(fn) { + use(fn: MiddlewareFunc) { assert(is.function(fn), 'app.use() requires a function'); - debug('use %s', fn._name || fn.name || '-'); + debug('use %s', (fn as any)._name || fn.name || '-'); this.middleware.push(utils.middleware(fn)); return this; } @@ -167,6 +184,7 @@ class EggCore extends KoaApplication { get deprecate() { const caller = utils.getCalleeFromStack(); if (!this[DEPRECATE].has(caller)) { + // eslint-disable-next-line @typescript-eslint/no-var-requires const deprecate = require('depd')('egg'); // dynamic set _file to caller deprecate._file = caller; @@ -216,16 +234,14 @@ class EggCore extends KoaApplication { * @param {Function|GeneratorFunction|AsyncFunction} scope function will execute before app start * @param {string} [name] scope name, default is empty string */ - beforeStart(scope, name) { + beforeStart(scope: Fun, name?: string) { this.lifecycle.registerBeforeStart(scope, name || ''); } /** * register an callback function that will be invoked when application is ready. - * @see https://github.com/node-modules/ready + * @see https://github.com/node-modules/get-ready * @since 1.0.0 - * @param {boolean|Error|Function} [flagOrFunction] - - * @return {Promise|null} return promise when argument is undefined * @example * const app = new Application(...); * app.ready(err => { @@ -233,7 +249,7 @@ class EggCore extends KoaApplication { * console.log('done'); * }); */ - ready(flagOrFunction) { + ready(flagOrFunction: ReadyFunctionArg) { return this.lifecycle.ready(flagOrFunction); } @@ -255,7 +271,7 @@ class EggCore extends KoaApplication { * const done = app.readyCallback('mysql'); * mysql.ready(done); */ - readyCallback(name, opts) { + readyCallback(name: string, opts) { return this.lifecycle.legacyReadyCallback(name, opts); } @@ -305,7 +321,7 @@ class EggCore extends KoaApplication { // register router middleware this.beforeStart(() => { this.use(router.middleware()); - }); + }, 'use-router'); return router; } @@ -315,11 +331,11 @@ class EggCore extends KoaApplication { * @param {Object} params - more parameters * @return {String} url */ - url(name, params) { + url(name: string, params?: object) { return this.router.url(name, params); } - del(...args) { + del(...args: any[]) { this.router.delete(...args); return this; } @@ -327,58 +343,12 @@ class EggCore extends KoaApplication { get [EGG_LOADER]() { return require('./loader/egg_loader'); } - - /** - * Convert a generator function to a promisable one. - * - * Notice: for other kinds of functions, it directly returns you what it is. - * - * @param {Function} fn The inputted function. - * @return {AsyncFunction} An async promise-based function. - * @example - ```javascript - const fn = function* (arg) { - return arg; - }; - const wrapped = app.toAsyncFunction(fn); - wrapped(true).then((value) => console.log(value)); - ``` - */ - toAsyncFunction(fn) { - if (!is.generatorFunction(fn)) return fn; - fn = co.wrap(fn); - return async function(...args) { - return fn.apply(this, args); - }; - } - - /** - * Convert an object with generator functions to a Promisable one. - * @param {Mixed} obj The inputted object. - * @return {Promise} A Promisable result. - * @example - ```javascript - const fn = function* (arg) { - return arg; - }; - const arr = [ fn(1), fn(2) ]; - const promise = app.toPromise(arr); - promise.then(res => console.log(res)); - ``` - */ - toPromise(obj) { - return co(function* () { - return yield obj; - }); - } } // delegate all router method to application utils.methods.concat([ 'all', 'resources', 'register', 'redirect' ]).forEach(method => { - EggCore.prototype[method] = function(...args) { + EggCore.prototype[method] = function(...args: any[]) { this.router[method](...args); return this; }; }); - -module.exports = EggCore; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..8bfafaba --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +export { EggCore } from './egg'; +export { EggLoader } from './loader/egg_loader'; +export { BaseContextClass } from './utils/base_context_class'; +export * as utils from './utils'; diff --git a/src/lifecycle.ts b/src/lifecycle.ts new file mode 100644 index 00000000..660a71d3 --- /dev/null +++ b/src/lifecycle.ts @@ -0,0 +1,336 @@ +import assert from 'node:assert'; +import { EventEmitter } from 'node:events'; +import { debuglog } from 'node:util'; +import is from 'is-type-of'; +import ReadyObject from 'get-ready'; +import type { ReadyFunctionArg } from 'get-ready'; +import { Ready } from 'ready-callback'; +import { EggConsoleLogger } from 'egg-logger'; +import utils from './utils'; +import type { Fun } from './utils'; +import type { EggCore } from './egg'; + +const debug = debuglog('@eggjs/core:lifecycle'); + +export interface ILifecycleBoot { + // loader auto set 'fullPath' property on boot class + fullPath?: string; + /** + * Ready to call configDidLoad, + * Config, plugin files are referred, + * this is the last chance to modify the config. + */ + configWillLoad?(): void; + + /** + * Config, plugin files have loaded + */ + configDidLoad?(): void; + + /** + * All files have loaded, start plugin here + */ + didLoad?(): Promise; + + /** + * All plugins have started, can do some thing before app ready + */ + willReady?(): Promise; + + /** + * Worker is ready, can do some things, + * don't need to block the app boot + */ + didReady?(err?: Error): Promise; + + /** + * Server is listening + */ + serverDidReady?(): Promise; + + /** + * Do some thing before app close + */ + beforeClose?(): Promise; +} + +export type BootImplClass = new(...args: any[]) => T; + +export interface LifecycleOptions { + baseDir: string; + app: EggCore; + logger: EggConsoleLogger; +} + +export class Lifecycle extends EventEmitter { + #init: boolean; + #readyObject: ReadyObject; + #bootHooks: BootImplClass[]; + #boots: ILifecycleBoot[]; + #isClosed: boolean; + #closeFunctionSet: Set; + loadReady: Ready; + bootReady: Ready; + options: LifecycleOptions; + readyTimeout: number; + + constructor(options: Partial) { + super(); + options.logger = options.logger ?? new EggConsoleLogger(); + this.options = options as LifecycleOptions; + this.#readyObject = new ReadyObject(); + this.#bootHooks = []; + this.#boots = []; + this.#closeFunctionSet = new Set(); + this.#isClosed = false; + this.#init = false; + + this.timing.start('Application Start'); + // get app timeout from env or use default timeout 10 second + const eggReadyTimeoutEnv = parseInt(process.env.EGG_READY_TIMEOUT_ENV || '10000'); + assert( + Number.isInteger(eggReadyTimeoutEnv), + `process.env.EGG_READY_TIMEOUT_ENV ${process.env.EGG_READY_TIMEOUT_ENV} should be able to parseInt.`); + this.readyTimeout = eggReadyTimeoutEnv; + + this.#initReady(); + this + .on('ready_stat', data => { + this.logger.info('[egg:core:ready_stat] end ready task %s, remain %j', data.id, data.remain); + }) + .on('ready_timeout', id => { + this.logger.warn('[egg:core:ready_timeout] %s seconds later %s was still unable to finish.', this.readyTimeout / 1000, id); + }); + + this.ready(err => { + this.triggerDidReady(err); + this.timing.end('Application Start'); + }); + } + + ready(arg?: ReadyFunctionArg) { + return this.#readyObject.ready(arg); + } + + get app() { + return this.options.app; + } + + get logger() { + return this.options.logger; + } + + get timing() { + return this.app.timing; + } + + legacyReadyCallback(name: string, opt?: object) { + const timingKeyPrefix = 'readyCallback'; + const timing = this.timing; + const cb = this.loadReady.readyCallback(name, opt); + const timingKey = `${timingKeyPrefix} in ` + utils.getResolvedFilename(name, this.app.baseDir); + this.timing.start(timingKey); + return function legacyReadyCallback(...args: any[]) { + timing.end(timingKey); + cb(...args); + }; + } + + addBootHook(BootClass: BootImplClass) { + assert(this.#init === false, 'do not add hook when lifecycle has been initialized'); + this.#bootHooks.push(BootClass); + } + + addFunctionAsBootHook(hook: (app: T) => void) { + assert(this.#init === false, 'do not add hook when lifecycle has been initialized'); + // app.js is exported as a function + // call this function in configDidLoad + this.#bootHooks.push(class Boot implements ILifecycleBoot { + app: T; + constructor(app: T) { + this.app = app; + } + configDidLoad() { + hook(this.app); + } + }); + } + + /** + * init boots and trigger config did config + */ + init() { + assert(this.#init === false, 'lifecycle have been init'); + this.#init = true; + this.#boots = this.#bootHooks.map((Boot: BootImplClass) => new Boot(this.app)); + } + + registerBeforeStart(scope: Fun, name: string) { + this.#registerReadyCallback({ + scope, + ready: this.loadReady, + timingKeyPrefix: 'Before Start', + scopeFullName: name, + }); + } + + registerBeforeClose(fn: Fun) { + assert(is.function(fn), 'argument should be function'); + assert(this.#isClosed === false, 'app has been closed'); + this.#closeFunctionSet.add(fn); + } + + async close() { + // close in reverse order: first created, last closed + const closeFns = Array.from(this.#closeFunctionSet); + for (const fn of closeFns.reverse()) { + await utils.callFn(fn); + this.#closeFunctionSet.delete(fn); + } + // Be called after other close callbacks + this.app.emit('close'); + this.removeAllListeners(); + this.app.removeAllListeners(); + this.#isClosed = true; + } + + triggerConfigWillLoad() { + for (const boot of this.#boots) { + if (typeof boot.configWillLoad === 'function') { + boot.configWillLoad(); + } + } + this.triggerConfigDidLoad(); + } + + triggerConfigDidLoad() { + for (const boot of this.#boots) { + if (typeof boot.configDidLoad === 'function') { + boot.configDidLoad(); + } + // function boot hook register after configDidLoad trigger + if (typeof boot.beforeClose === 'function') { + const beforeClose = boot.beforeClose.bind(boot); + this.registerBeforeClose(beforeClose); + } + } + this.triggerDidLoad(); + } + + triggerDidLoad() { + debug('register didLoad'); + for (const boot of this.#boots) { + if (typeof boot.didLoad === 'function') { + const didLoad = boot.didLoad.bind(boot); + this.#registerReadyCallback({ + scope: didLoad, + ready: this.loadReady, + timingKeyPrefix: 'Did Load', + scopeFullName: boot.fullPath + ':didLoad', + }); + } + } + } + + triggerWillReady() { + debug('register willReady'); + this.bootReady.start(); + for (const boot of this.#boots) { + if (typeof boot.willReady === 'function') { + const willReady = boot.willReady.bind(boot); + this.#registerReadyCallback({ + scope: willReady, + ready: this.bootReady, + timingKeyPrefix: 'Will Ready', + scopeFullName: boot.fullPath + ':willReady', + }); + } + } + } + + triggerDidReady(err?: Error) { + debug('trigger didReady'); + (async () => { + for (const boot of this.#boots) { + if (typeof boot.didReady === 'function') { + try { + await boot.didReady(err); + } catch (e) { + this.emit('error', e); + } + } + } + debug('trigger didReady done'); + })(); + } + + triggerServerDidReady() { + (async () => { + for (const boot of this.#boots) { + if (typeof boot.serverDidReady !== 'function') continue; + try { + await utils.callFn(boot.serverDidReady, undefined, boot); + } catch (err) { + this.emit('error', err); + } + } + })(); + } + + #initReady() { + this.loadReady = new Ready({ timeout: this.readyTimeout }); + this.#delegateReadyEvent(this.loadReady); + this.loadReady.ready((err?: Error) => { + debug('didLoad done'); + if (err) { + this.ready(err); + } else { + this.triggerWillReady(); + } + }); + + this.bootReady = new Ready({ timeout: this.readyTimeout, lazyStart: true }); + this.#delegateReadyEvent(this.bootReady); + this.bootReady.ready((err?: Error) => { + this.ready(err || true); + }); + } + + #delegateReadyEvent(ready: Ready) { + ready.once('error', (err?: Error) => ready.ready(err)); + ready.on('ready_timeout', (id: any) => this.emit('ready_timeout', id)); + ready.on('ready_stat', (data: any) => this.emit('ready_stat', data)); + ready.on('error', (err?: Error) => this.emit('error', err)); + } + + #registerReadyCallback(args: { + scope: Fun; + ready: Ready; + timingKeyPrefix: string; + scopeFullName?: string; + }) { + const { scope, ready, timingKeyPrefix, scopeFullName } = args; + if (typeof scope !== 'function') { + throw new Error('boot only support function'); + } + + // get filename from stack if scopeFullName is undefined + const name = scopeFullName || utils.getCalleeFromStack(true, 4); + const timingKey = `${timingKeyPrefix} in ` + utils.getResolvedFilename(name, this.app.baseDir); + + this.timing.start(timingKey); + + const done = ready.readyCallback(name); + + // ensure scope executes after load completed + process.nextTick(() => { + utils.callFn(scope).then(() => { + done(); + this.timing.end(timingKey); + }, (err: Error) => { + done(err); + this.timing.end(timingKey); + }); + }); + } +} diff --git a/lib/loader/egg_loader.js b/src/loader/base_loader.ts similarity index 78% rename from lib/loader/egg_loader.js rename to src/loader/base_loader.ts index b19ab7b5..9cd679fc 100644 --- a/lib/loader/egg_loader.js +++ b/src/loader/base_loader.ts @@ -1,20 +1,79 @@ -'use strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert'; +import { debuglog } from 'node:util'; +import is from 'is-type-of'; +import homedir from 'node-homedir'; +import type { Logger } from 'egg-logger'; +import FileLoader from './file_loader'; +import ContextLoader from './context_loader'; +import utility from 'utility'; +import utils from '../utils'; +import { Timing } from '../utils/timing'; +import type { EggCore } from '../egg'; + +const debug = debuglog('@eggjs/core:loader:base'); + +export interface EggAppInfo { + /** package.json */ + pkg: Record; + /** the application name from package.json */ + name: string; + /** current directory of application */ + baseDir: string; + /** equals to serverEnv */ + env: string; + /** equals to serverScope */ + scope: string; + /** home directory of the OS */ + HOME: string; + /** baseDir when local and unittest, HOME when other environment */ + root: string; +} -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); -const is = require('is-type-of'); -const debug = require('debug')('egg-core'); -const homedir = require('node-homedir'); -const FileLoader = require('./file_loader'); -const ContextLoader = require('./context_loader'); -const utility = require('utility'); -const utils = require('../utils'); -const Timing = require('../utils/timing'); +export interface PluginInfo { + /** the plugin name, it can be used in `dep` */ + name: string; + /** whether enabled */ + enable: boolean; + /** the package name of plugin */ + package?: string; + /** the directory of the plugin package */ + path?: string; + /** the dependent plugins, you can use the plugin name */ + dependencies: string[]; + /** the optional dependent plugins. */ + optionalDependencies: string[]; + /** specify the serverEnv that only enable the plugin in it */ + env: string[]; + /** the file plugin config in. */ + from: string; +} -const REQUIRE_COUNT = Symbol('EggLoader#requireCount'); +export interface EggLoaderOptions { + /** server env */ + env: string; + /** Application instance */ + app: EggCore; + /** the directory of application */ + baseDir: string; + /** egg logger */ + logger: Logger; + /** server scope */ + serverScope?: string; + /** custom plugins */ + plugins?: Record; +} -class EggLoader { +export class BaseLoader { + #requiredCount: 0; + readonly options: EggLoaderOptions; + readonly timing: Timing; + readonly pkg: Record; + readonly eggPaths: string[]; + readonly serverEnv: string; + readonly serverScope: string; + readonly appInfo: EggAppInfo; /** * @class @@ -25,16 +84,13 @@ class EggLoader { * @param {Object} [options.plugins] - custom plugins * @since 1.0.0 */ - constructor(options) { + constructor(options: EggLoaderOptions) { this.options = options; assert(fs.existsSync(this.options.baseDir), `${this.options.baseDir} not exists`); assert(this.options.app, 'options.app is required'); assert(this.options.logger, 'options.logger is required'); - this.app = this.options.app; - this.lifecycle = this.app.lifecycle; this.timing = this.app.timing || new Timing(); - this[REQUIRE_COUNT] = 0; /** * @member {Object} EggLoader#pkg @@ -49,9 +105,10 @@ class EggLoader { // skip require tsconfig-paths if tsconfig.json not exists const tsConfigFile = path.join(this.options.baseDir, 'tsconfig.json'); if (fs.existsSync(tsConfigFile)) { + // eslint-disable-next-line @typescript-eslint/no-var-requires require('tsconfig-paths').register({ cwd: this.options.baseDir }); } else { - this.options.logger.info('[egg:loader] skip register "tsconfig-paths" because tsconfig.json not exists at %s', tsConfigFile); + this.logger.info('[egg:loader] skip register "tsconfig-paths" because tsconfig.json not exists at %s', tsConfigFile); } } @@ -106,6 +163,18 @@ class EggLoader { this.appInfo = this.getAppInfo(); } + get app() { + return this.options.app; + } + + get lifecycle() { + return this.app.lifecycle; + } + + get logger() { + return this.options.logger; + } + /** * Get {@link AppInfo#env} * @return {String} env @@ -113,7 +182,7 @@ class EggLoader { * @private * @since 1.0.0 */ - getServerEnv() { + protected getServerEnv(): string { let serverEnv = this.options.env; const envPath = path.join(this.options.baseDir, 'config/env'); @@ -121,7 +190,7 @@ class EggLoader { serverEnv = fs.readFileSync(envPath, 'utf8').trim(); } - if (!serverEnv) { + if (!serverEnv && process.env.EGG_SERVER_ENV) { serverEnv = process.env.EGG_SERVER_ENV; } @@ -145,7 +214,7 @@ class EggLoader { * @return {String} serverScope * @private */ - getServerScope() { + protected getServerScope(): string { return process.env.EGG_SERVER_SCOPE || ''; } @@ -179,7 +248,7 @@ class EggLoader { * @return {AppInfo} appInfo * @since 1.0.0 */ - getAppInfo() { + protected getAppInfo(): EggAppInfo { const env = this.serverEnv; const scope = this.serverScope; const home = this.getHomedir(); @@ -257,10 +326,11 @@ class EggLoader { * @private * @since 1.0.0 */ - getEggPaths() { + protected getEggPaths(): string[] { // avoid require recursively - const EggCore = require('../egg'); - const eggPaths = []; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const EggCore = require('../egg').EggCore; + const eggPaths: string[] = []; let proto = this.app; @@ -323,7 +393,7 @@ class EggLoader { * @private */ requireFile(filepath) { - const timingKey = `Require(${this[REQUIRE_COUNT]++}) ${utils.getResolvedFilename(filepath, this.options.baseDir)}`; + const timingKey = `Require(${this.#requiredCount++}) ${utils.getResolvedFilename(filepath, this.options.baseDir)}`; this.timing.start(timingKey); const ret = utils.loadFile(filepath); this.timing.end(timingKey); @@ -436,7 +506,7 @@ class EggLoader { return ContextLoader; } - getTypeFiles(filename) { + getTypeFiles(filename: string) { const files = [ `${filename}.default` ]; if (this.serverScope) files.push(`${filename}.${this.serverScope}`); if (this.serverEnv === 'default') return files; @@ -446,14 +516,15 @@ class EggLoader { return files; } - resolveModule(filepath) { - let fullPath; + resolveModule(filepath: string) { + let fullPath: string; try { fullPath = require.resolve(filepath); - } catch (e) { + } catch { return undefined; } + // ignore .ts on non ts loader if (process.env.EGG_TYPESCRIPT !== 'true' && fullPath.endsWith('.ts')) { return undefined; } @@ -462,25 +533,3 @@ class EggLoader { } } -/** - * Mixin methods to EggLoader - * // ES6 Multiple Inheritance - * https://medium.com/@leocavalcante/es6-multiple-inheritance-73a3c66d2b6b - */ -const loaders = [ - require('./mixin/plugin'), - require('./mixin/config'), - require('./mixin/extend'), - require('./mixin/custom'), - require('./mixin/service'), - require('./mixin/middleware'), - require('./mixin/controller'), - require('./mixin/router'), - require('./mixin/custom_loader'), -]; - -for (const loader of loaders) { - Object.assign(EggLoader.prototype, loader); -} - -module.exports = EggLoader; diff --git a/lib/loader/context_loader.js b/src/loader/context_loader.js similarity index 100% rename from lib/loader/context_loader.js rename to src/loader/context_loader.js diff --git a/src/loader/egg_loader.ts b/src/loader/egg_loader.ts new file mode 100644 index 00000000..8b488cad --- /dev/null +++ b/src/loader/egg_loader.ts @@ -0,0 +1,579 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert'; +import { debuglog } from 'node:util'; +import is from 'is-type-of'; +import homedir from 'node-homedir'; +import type { Logger } from 'egg-logger'; +import FileLoader from './file_loader'; +import ContextLoader from './context_loader'; +import utility from 'utility'; +import utils from '../utils'; +import { Timing } from '../utils/timing'; +import type { EggCore } from '../egg'; + +const debug = debuglog('@eggjs/core:egg_loader'); + +export interface EggAppInfo { + /** package.json */ + pkg: Record; + /** the application name from package.json */ + name: string; + /** current directory of application */ + baseDir: string; + /** equals to serverEnv */ + env: string; + /** equals to serverScope */ + scope: string; + /** home directory of the OS */ + HOME: string; + /** baseDir when local and unittest, HOME when other environment */ + root: string; +} + +export interface PluginInfo { + /** the plugin name, it can be used in `dep` */ + name: string; + /** the package name of plugin */ + package: string; + /** whether enabled */ + enable: boolean; + /** the directory of the plugin package */ + path: string; + /** the dependent plugins, you can use the plugin name */ + dependencies: string[]; + /** the optional dependent plugins. */ + optionalDependencies: string[]; + /** specify the serverEnv that only enable the plugin in it */ + env: string[]; + /** the file plugin config in. */ + from: string; +} + +export interface EggLoaderOptions { + /** server env */ + env: string; + /** Application instance */ + app: EggCore; + /** the directory of application */ + baseDir: string; + /** egg logger */ + logger: Logger; + /** server scope */ + serverScope?: string; + /** custom plugins */ + plugins?: Record; +} + +export class EggLoader { + #requiredCount: 0; + readonly options: EggLoaderOptions; + readonly timing: Timing; + readonly pkg: Record; + readonly eggPaths: string[]; + readonly serverEnv: string; + readonly serverScope: string; + readonly appInfo: EggAppInfo; + + /** + * @class + * @param {Object} options - options + * @param {String} options.baseDir - the directory of application + * @param {EggCore} options.app - Application instance + * @param {Logger} options.logger - logger + * @param {Object} [options.plugins] - custom plugins + * @since 1.0.0 + */ + constructor(options: EggLoaderOptions) { + this.options = options; + assert(fs.existsSync(this.options.baseDir), `${this.options.baseDir} not exists`); + assert(this.options.app, 'options.app is required'); + assert(this.options.logger, 'options.logger is required'); + + this.timing = this.app.timing || new Timing(); + + /** + * @member {Object} EggLoader#pkg + * @see {@link AppInfo#pkg} + * @since 1.0.0 + */ + this.pkg = utility.readJSONSync(path.join(this.options.baseDir, 'package.json')); + + // auto require('tsconfig-paths/register') on typescript app + // support env.EGG_TYPESCRIPT = true or { "egg": { "typescript": true } } on package.json + if (process.env.EGG_TYPESCRIPT === 'true' || (this.pkg.egg && this.pkg.egg.typescript)) { + // skip require tsconfig-paths if tsconfig.json not exists + const tsConfigFile = path.join(this.options.baseDir, 'tsconfig.json'); + if (fs.existsSync(tsConfigFile)) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('tsconfig-paths').register({ cwd: this.options.baseDir }); + } else { + this.logger.info('[egg:loader] skip register "tsconfig-paths" because tsconfig.json not exists at %s', tsConfigFile); + } + } + + /** + * All framework directories. + * + * You can extend Application of egg, the entry point is options.app, + * + * loader will find all directories from the prototype of Application, + * you should define `Symbol.for('egg#eggPath')` property. + * + * ``` + * // lib/example.js + * const egg = require('egg'); + * class ExampleApplication extends egg.Application { + * constructor(options) { + * super(options); + * } + * + * get [Symbol.for('egg#eggPath')]() { + * return path.join(__dirname, '..'); + * } + * } + * ``` + * @member {Array} EggLoader#eggPaths + * @see EggLoader#getEggPaths + * @since 1.0.0 + */ + this.eggPaths = this.getEggPaths(); + debug('Loaded eggPaths %j', this.eggPaths); + + /** + * @member {String} EggLoader#serverEnv + * @see AppInfo#env + * @since 1.0.0 + */ + this.serverEnv = this.getServerEnv(); + debug('Loaded serverEnv %j', this.serverEnv); + + /** + * @member {String} EggLoader#serverScope + * @see AppInfo#serverScope + */ + this.serverScope = options.serverScope !== undefined + ? options.serverScope + : this.getServerScope(); + + /** + * @member {AppInfo} EggLoader#appInfo + * @since 1.0.0 + */ + this.appInfo = this.getAppInfo(); + } + + get app() { + return this.options.app; + } + + get lifecycle() { + return this.app.lifecycle; + } + + get logger() { + return this.options.logger; + } + + /** + * Get {@link AppInfo#env} + * @return {String} env + * @see AppInfo#env + * @private + * @since 1.0.0 + */ + protected getServerEnv(): string { + let serverEnv = this.options.env; + + const envPath = path.join(this.options.baseDir, 'config/env'); + if (!serverEnv && fs.existsSync(envPath)) { + serverEnv = fs.readFileSync(envPath, 'utf8').trim(); + } + + if (!serverEnv && process.env.EGG_SERVER_ENV) { + serverEnv = process.env.EGG_SERVER_ENV; + } + + if (!serverEnv) { + if (process.env.NODE_ENV === 'test') { + serverEnv = 'unittest'; + } else if (process.env.NODE_ENV === 'production') { + serverEnv = 'prod'; + } else { + serverEnv = 'local'; + } + } else { + serverEnv = serverEnv.trim(); + } + + return serverEnv; + } + + /** + * Get {@link AppInfo#scope} + * @return {String} serverScope + * @private + */ + protected getServerScope(): string { + return process.env.EGG_SERVER_SCOPE || ''; + } + + /** + * Get {@link AppInfo#name} + * @return {String} appname + * @private + * @since 1.0.0 + */ + getAppname() { + if (this.pkg.name) { + debug('Loaded appname(%s) from package.json', this.pkg.name); + return this.pkg.name; + } + const pkg = path.join(this.options.baseDir, 'package.json'); + throw new Error(`name is required from ${pkg}`); + } + + /** + * Get home directory + * @return {String} home directory + * @since 3.4.0 + */ + getHomedir() { + // EGG_HOME for test + return process.env.EGG_HOME || homedir() || '/home/admin'; + } + + /** + * Get app info + * @return {AppInfo} appInfo + * @since 1.0.0 + */ + protected getAppInfo(): EggAppInfo { + const env = this.serverEnv; + const scope = this.serverScope; + const home = this.getHomedir(); + const baseDir = this.options.baseDir; + + /** + * Meta information of the application + * @class AppInfo + */ + return { + /** + * The name of the application, retrieve from the name property in `package.json`. + * @member {String} AppInfo#name + */ + name: this.getAppname(), + + /** + * The current directory, where the application code is. + * @member {String} AppInfo#baseDir + */ + baseDir, + + /** + * The environment of the application, **it's not NODE_ENV** + * + * 1. from `$baseDir/config/env` + * 2. from EGG_SERVER_ENV + * 3. from NODE_ENV + * + * env | description + * --- | --- + * test | system integration testing + * prod | production + * local | local on your own computer + * unittest | unit test + * + * @member {String} AppInfo#env + * @see https://eggjs.org/zh-cn/basics/env.html + */ + env, + + /** + * @member {String} AppInfo#scope + */ + scope, + + /** + * The use directory, same as `process.env.HOME` + * @member {String} AppInfo#HOME + */ + HOME: home, + + /** + * parsed from `package.json` + * @member {Object} AppInfo#pkg + */ + pkg: this.pkg, + + /** + * The directory whether is baseDir or HOME depend on env. + * it's good for test when you want to write some file to HOME, + * but don't want to write to the real directory, + * so use root to write file to baseDir instead of HOME when unittest. + * keep root directory in baseDir when local and unittest + * @member {String} AppInfo#root + */ + root: env === 'local' || env === 'unittest' ? baseDir : home, + }; + } + + /** + * Get {@link EggLoader#eggPaths} + * @return {Array} framework directories + * @see {@link EggLoader#eggPaths} + * @private + * @since 1.0.0 + */ + protected getEggPaths(): string[] { + // avoid require recursively + // eslint-disable-next-line @typescript-eslint/no-var-requires + const EggCore = require('../egg').EggCore; + const eggPaths: string[] = []; + + let proto = this.app; + + // Loop for the prototype chain + while (proto) { + proto = Object.getPrototypeOf(proto); + // stop the loop if + // - object extends Object + // - object extends EggCore + if (proto === Object.prototype || proto === EggCore.prototype) { + break; + } + + assert(proto.hasOwnProperty(Symbol.for('egg#eggPath')), 'Symbol.for(\'egg#eggPath\') is required on Application'); + const eggPath = proto[Symbol.for('egg#eggPath')]; + assert(eggPath && typeof eggPath === 'string', 'Symbol.for(\'egg#eggPath\') should be string'); + assert(fs.existsSync(eggPath), `${eggPath} not exists`); + const realpath = fs.realpathSync(eggPath); + if (!eggPaths.includes(realpath)) { + eggPaths.unshift(realpath); + } + } + + return eggPaths; + } + + // Low Level API + + /** + * Load single file, will invoke when export is function + * + * @param {String} filepath - fullpath + * @param {Array} inject - pass rest arguments into the function when invoke + * @return {Object} exports + * @example + * ```js + * app.loader.loadFile(path.join(app.options.baseDir, 'config/router.js')); + * ``` + * @since 1.0.0 + */ + loadFile(filepath, ...inject) { + filepath = filepath && this.resolveModule(filepath); + if (!filepath) { + return null; + } + + // function(arg1, args, ...) {} + if (inject.length === 0) inject = [ this.app ]; + + let ret = this.requireFile(filepath); + if (is.function(ret) && !is.class(ret)) { + ret = ret(...inject); + } + return ret; + } + + /** + * @param {String} filepath - fullpath + * @return {Object} exports + * @private + */ + requireFile(filepath) { + const timingKey = `Require(${this.#requiredCount++}) ${utils.getResolvedFilename(filepath, this.options.baseDir)}`; + this.timing.start(timingKey); + const ret = utils.loadFile(filepath); + this.timing.end(timingKey); + return ret; + } + + /** + * Get all loadUnit + * + * loadUnit is a directory that can be loaded by EggLoader, it has the same structure. + * loadUnit has a path and a type(app, framework, plugin). + * + * The order of the loadUnits: + * + * 1. plugin + * 2. framework + * 3. app + * + * @return {Array} loadUnits + * @since 1.0.0 + */ + getLoadUnits() { + if (this.dirs) { + return this.dirs; + } + + const dirs = this.dirs = []; + + if (this.orderPlugins) { + for (const plugin of this.orderPlugins) { + dirs.push({ + path: plugin.path, + type: 'plugin', + }); + } + } + + // framework or egg path + for (const eggPath of this.eggPaths) { + dirs.push({ + path: eggPath, + type: 'framework', + }); + } + + // application + dirs.push({ + path: this.options.baseDir, + type: 'app', + }); + + debug('Loaded dirs %j', dirs); + return dirs; + } + + /** + * Load files using {@link FileLoader}, inject to {@link Application} + * @param {String|Array} directory - see {@link FileLoader} + * @param {String} property - see {@link FileLoader} + * @param {Object} opt - see {@link FileLoader} + * @since 1.0.0 + */ + loadToApp(directory, property, opt) { + const target = this.app[property] = {}; + opt = Object.assign({}, { + directory, + target, + inject: this.app, + }, opt); + + const timingKey = `Load "${String(property)}" to Application`; + this.timing.start(timingKey); + new FileLoader(opt).load(); + this.timing.end(timingKey); + } + + /** + * Load files using {@link ContextLoader} + * @param {String|Array} directory - see {@link ContextLoader} + * @param {String} property - see {@link ContextLoader} + * @param {Object} opt - see {@link ContextLoader} + * @since 1.0.0 + */ + loadToContext(directory, property, opt) { + opt = Object.assign({}, { + directory, + property, + inject: this.app, + }, opt); + + const timingKey = `Load "${String(property)}" to Context`; + this.timing.start(timingKey); + new ContextLoader(opt).load(); + this.timing.end(timingKey); + } + + /** + * @member {FileLoader} EggLoader#FileLoader + * @since 1.0.0 + */ + get FileLoader() { + return FileLoader; + } + + /** + * @member {ContextLoader} EggLoader#ContextLoader + * @since 1.0.0 + */ + get ContextLoader() { + return ContextLoader; + } + + getTypeFiles(filename) { + const files = [ `${filename}.default` ]; + if (this.serverScope) files.push(`${filename}.${this.serverScope}`); + if (this.serverEnv === 'default') return files; + + files.push(`${filename}.${this.serverEnv}`); + if (this.serverScope) files.push(`${filename}.${this.serverScope}_${this.serverEnv}`); + return files; + } + + resolveModule(filepath) { + let fullPath; + try { + fullPath = require.resolve(filepath); + } catch (e) { + return undefined; + } + + if (process.env.EGG_TYPESCRIPT !== 'true' && fullPath.endsWith('.ts')) { + return undefined; + } + + return fullPath; + } +} + +/** + * Mixin methods to EggLoader + * // ES6 Multiple Inheritance + * https://medium.com/@leocavalcante/es6-multiple-inheritance-73a3c66d2b6b + */ +const loaders = [ + require('./mixin/plugin'), + require('./mixin/config'), + require('./mixin/extend'), + require('./mixin/custom'), + require('./mixin/service'), + require('./mixin/middleware'), + require('./mixin/controller'), + require('./mixin/router'), + require('./mixin/custom_loader'), +]; + +for (const loader of loaders) { + Object.assign(EggLoader.prototype, loader); +} + +import { PluginLoader } from './mixin/plugin'; +import ConfigLoader from './mixin/config'; + +// https://www.typescriptlang.org/docs/handbook/mixins.html#alternative-pattern +export interface EggLoader extends PluginLoader, ConfigLoader {} + +// https://www.typescriptlang.org/docs/handbook/mixins.html +function applyMixins(derivedCtor: any, constructors: any[]) { + constructors.forEach(baseCtor => { + Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => { + if (derivedCtor.prototype.hasOwnProperty(name)) { + return; + } + Object.defineProperty( + derivedCtor.prototype, + name, + Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || + Object.create(null), + ); + }); + }); +} + +applyMixins(EggLoader, [ PluginLoader, ConfigLoader ]); diff --git a/lib/loader/file_loader.js b/src/loader/file_loader.js similarity index 99% rename from lib/loader/file_loader.js rename to src/loader/file_loader.js index b59ed4ba..9d734448 100644 --- a/lib/loader/file_loader.js +++ b/src/loader/file_loader.js @@ -2,7 +2,7 @@ const assert = require('assert'); const fs = require('fs'); -const debug = require('debug')('egg-core:loader'); +const debug = require('node:util').debuglog('egg-core:loader'); const path = require('path'); const globby = require('globby'); const is = require('is-type-of'); diff --git a/lib/loader/mixin/config.js b/src/loader/mixin/config.js similarity index 98% rename from lib/loader/mixin/config.js rename to src/loader/mixin/config.js index d63f44db..49c3ad64 100644 --- a/lib/loader/mixin/config.js +++ b/src/loader/mixin/config.js @@ -1,6 +1,6 @@ 'use strict'; -const debug = require('debug')('egg-core:config'); +const debug = require('node:util').debuglog('egg-core:config'); const path = require('path'); const extend = require('extend2'); const assert = require('assert'); diff --git a/lib/loader/mixin/controller.js b/src/loader/mixin/controller.js similarity index 100% rename from lib/loader/mixin/controller.js rename to src/loader/mixin/controller.js diff --git a/lib/loader/mixin/custom.js b/src/loader/mixin/custom.js similarity index 100% rename from lib/loader/mixin/custom.js rename to src/loader/mixin/custom.js diff --git a/lib/loader/mixin/custom_loader.js b/src/loader/mixin/custom_loader.js similarity index 100% rename from lib/loader/mixin/custom_loader.js rename to src/loader/mixin/custom_loader.js diff --git a/lib/loader/mixin/extend.js b/src/loader/mixin/extend.js similarity index 98% rename from lib/loader/mixin/extend.js rename to src/loader/mixin/extend.js index 86211a41..962c47dc 100644 --- a/lib/loader/mixin/extend.js +++ b/src/loader/mixin/extend.js @@ -1,6 +1,6 @@ 'use strict'; -const debug = require('debug')('egg-core:extend'); +const debug = require('node:util').debuglog('egg-core:extend'); const deprecate = require('depd')('egg'); const path = require('path'); diff --git a/lib/loader/mixin/middleware.js b/src/loader/mixin/middleware.js similarity index 98% rename from lib/loader/mixin/middleware.js rename to src/loader/mixin/middleware.js index 361aeae5..46336a9c 100644 --- a/lib/loader/mixin/middleware.js +++ b/src/loader/mixin/middleware.js @@ -4,7 +4,7 @@ const join = require('path').join; const is = require('is-type-of'); const inspect = require('util').inspect; const assert = require('assert'); -const debug = require('debug')('egg-core:middleware'); +const debug = require('node:util').debuglog('egg-core:middleware'); const pathMatching = require('egg-path-matching'); const utils = require('../../utils'); diff --git a/lib/loader/mixin/plugin.js b/src/loader/mixin/plugin.ts similarity index 87% rename from lib/loader/mixin/plugin.js rename to src/loader/mixin/plugin.ts index 5171575d..ce7bf09b 100644 --- a/lib/loader/mixin/plugin.js +++ b/src/loader/mixin/plugin.ts @@ -1,14 +1,19 @@ -'use strict'; - -const assert = require('assert'); -const fs = require('fs'); -const path = require('path'); -const debug = require('debug')('egg-core:plugin'); -const sequencify = require('../../utils/sequencify'); -const loadFile = require('../../utils').loadFile; - - -module.exports = { +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { debuglog } from 'node:util'; +import sequencify from '../../utils/sequencify'; +import utils from '../../utils'; +import { BaseLoader, PluginInfo } from '../base_loader'; + +const debug = debuglog('@eggjs/core:loader:plugin'); + +export class PluginLoader extends BaseLoader { + protected lookupDirs: Set; + protected eggPlugins: Record; + protected appPlugins: Record; + protected customPlugins: Record; + protected allPlugins: Record; /** * Load config/plugin.js from {EggLoader#loadUnits} @@ -69,21 +74,22 @@ module.exports = { this._extendPlugins(this.allPlugins, this.appPlugins); this._extendPlugins(this.allPlugins, this.customPlugins); - const enabledPluginNames = []; // enabled plugins that configured explicitly + const enabledPluginNames: string[] = []; // enabled plugins that configured explicitly const plugins = {}; const env = this.serverEnv; for (const name in this.allPlugins) { const plugin = this.allPlugins[name]; // resolve the real plugin.path based on plugin or package - plugin.path = this.getPluginPath(plugin, this.options.baseDir); + plugin.path = this.getPluginPath(plugin); // read plugin information from ${plugin.path}/package.json this.mergePluginConfig(plugin); // disable the plugin that not match the serverEnv - if (env && plugin.env.length && !plugin.env.includes(env)) { - this.options.logger.info('Plugin %s is disabled by env unmatched, require env(%s) but got env is %s', name, plugin.env, env); + if (env && plugin.env.length > 0 && !plugin.env.includes(env)) { + this.logger.info('[@eggjs/core] Plugin %o is disabled by env unmatched, require env(%o) but got env is %o', + name, plugin.env, env); plugin.enable = false; continue; } @@ -111,14 +117,14 @@ module.exports = { this.plugins = enablePlugins; this.timing.end('Load Plugin'); - }, + } loadAppPlugins() { // loader plugins from application const appPlugins = this.readPluginConfigs(path.join(this.options.baseDir, 'config/plugin.default')); debug('Loaded app plugins: %j', Object.keys(appPlugins)); return appPlugins; - }, + } loadEggPlugins() { // loader plugins from framework @@ -126,7 +132,7 @@ module.exports = { const eggPlugins = this.readPluginConfigs(eggPluginConfigPaths); debug('Loaded egg plugins: %j', Object.keys(eggPlugins)); return eggPlugins; - }, + } loadCustomPlugins() { // loader plugins from process.env.EGG_PLUGINS @@ -152,12 +158,12 @@ module.exports = { } return customPlugins; - }, + } /* * Read plugin.js from multiple directory */ - readPluginConfigs(configPaths) { + readPluginConfigs(configPaths: string[] | string) { if (!Array.isArray(configPaths)) { configPaths = [ configPaths ]; } @@ -167,7 +173,7 @@ module.exports = { // plugin.${scope}.js // plugin.${env}.js // plugin.${scope}_${env}.js - const newConfigPaths = []; + const newConfigPaths: string[] = []; for (const filename of this.getTypeFiles('plugin')) { for (let configPath of configPaths) { configPath = path.join(path.dirname(configPath), filename); @@ -175,7 +181,7 @@ module.exports = { } } - const plugins = {}; + const plugins: Record = {}; for (const configPath of newConfigPaths) { let filepath = this.resolveModule(configPath); @@ -188,8 +194,7 @@ module.exports = { continue; } - const config = loadFile(filepath); - + const config = utils.loadFile(filepath) as Record; for (const name in config) { this.normalizePluginConfig(config, name, filepath); } @@ -198,9 +203,9 @@ module.exports = { } return plugins; - }, + } - normalizePluginConfig(plugins, name, configPath) { + normalizePluginConfig(plugins: Record, name: string, configPath: string) { const plugin = plugins[name]; // plugin_name: false @@ -216,7 +221,7 @@ module.exports = { return; } - if (!('enable' in plugin)) { + if (typeof plugin.enable !== 'boolean') { plugin.enable = true; } plugin.name = name; @@ -225,7 +230,7 @@ module.exports = { plugin.env = plugin.env || []; plugin.from = configPath; depCompatible(plugin); - }, + } // Read plugin information from package.json and merge // { @@ -268,7 +273,7 @@ module.exports = { plugin[key] = config[key]; } } - }, + } getOrderPlugins(allPlugins, enabledPluginNames, appPlugins) { // no plugins enabled @@ -341,10 +346,10 @@ module.exports = { } return result.sequence.map(name => allPlugins[name]); - }, + } getLookupDirs() { - const lookupDirs = new Set(); + const lookupDirs = new Set(); // try to locate the plugin in the following directories's node_modules // -> {APP_PATH} -> {EGG_PATH} -> $CWD @@ -360,10 +365,10 @@ module.exports = { lookupDirs.add(process.cwd()); return lookupDirs; - }, + } // Get the real plugin path - getPluginPath(plugin) { + getPluginPath(plugin: PluginInfo) { if (plugin.path) { return plugin.path; } @@ -373,7 +378,7 @@ module.exports = { } return this._resolvePluginPath(plugin); - }, + } _resolvePluginPath(plugin) { const name = plugin.package || plugin.name; @@ -390,9 +395,9 @@ module.exports = { } catch (_) { throw new Error(`Can not find plugin ${name} in "${[ ...this.lookupDirs ].join(', ')}"`); } - }, + } - _extendPlugins(target, plugins) { + _extendPlugins(target: Record, plugins: Record) { if (!plugins) { return; } @@ -400,10 +405,10 @@ module.exports = { const plugin = plugins[name]; let targetPlugin = target[name]; if (!targetPlugin) { - targetPlugin = target[name] = {}; + targetPlugin = target[name] = {} as PluginInfo; } if (targetPlugin.package && targetPlugin.package === plugin.package) { - this.options.logger.warn('plugin %s has been defined that is %j, but you define again in %s', + this.logger.warn('[@eggjs/core] plugin %s has been defined that is %j, but you define again in %s', name, targetPlugin, plugin.from); } if (plugin.path || plugin.package) { @@ -420,11 +425,10 @@ module.exports = { targetPlugin[prop] = plugin[prop]; } } - }, - -}; + } +} -function depCompatible(plugin) { +function depCompatible(plugin: PluginInfo & { dep?: string[] }) { if (plugin.dep && !(Array.isArray(plugin.dependencies) && plugin.dependencies.length)) { plugin.dependencies = plugin.dep; delete plugin.dep; diff --git a/lib/loader/mixin/router.js b/src/loader/mixin/router.js similarity index 100% rename from lib/loader/mixin/router.js rename to src/loader/mixin/router.js diff --git a/lib/loader/mixin/service.js b/src/loader/mixin/service.js similarity index 100% rename from lib/loader/mixin/service.js rename to src/loader/mixin/service.js diff --git a/index.d.ts b/src/types.ts similarity index 97% rename from index.d.ts rename to src/types.ts index 055fecf4..f6276cf7 100644 --- a/index.d.ts +++ b/src/types.ts @@ -1,8 +1,8 @@ -import KoaApplication from '@eggjs/koa'; -import depd = require('depd'); -import { Logger } from 'egg-logger'; +import type KoaApplication from '@eggjs/koa'; +import type depd = require('depd'); +import type { Logger } from 'egg-logger'; -type EggType = 'application' | 'agent'; +export type EggType = 'application' | 'agent'; interface PlainObject { [key: string]: T; @@ -205,8 +205,8 @@ export interface EggCore extends EggCoreBase { } export class EggCore { - /** - * @constructor + /** + * @class * @param {Object} options - options * @param {String} [options.baseDir=process.cwd()] - the directory of application * @param {String} [options.type=application|agent] - whether it's running in app worker or agent worker @@ -279,13 +279,13 @@ export interface FileLoaderOption { /** match the files when load, support glob, default to all js files */ match?: string | string[]; /** ignore the files when load, support glob */ - ignore?: string | string[]; + ignore?: string | string[]; /** custom file exports, receive two parameters, first is the inject object(if not js file, will be content buffer), second is an `options` object that contain `path` */ initializer?(obj: object, options: { path: string; pathName: string; }): any; /** determine whether invoke when exports is function */ call?: boolean; /** determine whether override the property when get the same name */ - override?: boolean; + override?: boolean; /** an object that be the argument when invoke the function */ inject?: object; /** a function that filter the exports which can be loaded */ @@ -356,7 +356,7 @@ export interface FileLoader { export interface ContextLoader { /** * Same as {@link FileLoader}, but it will attach file to `inject[fieldClass]`. The exports will be lazy loaded, such as `ctx.group.repository`. - * @extends FileLoader + * @augments FileLoader * @since 1.0.0 */ new (options: ContextLoaderOption): ContextLoaderBase; @@ -377,7 +377,7 @@ export class EggLoader< options: Options; /** - * @constructor + * @class * @param {Object} options - options * @param {String} options.baseDir - the directory of application * @param {EggCore} options.app - Application instance diff --git a/lib/utils/base_context_class.js b/src/utils/base_context_class.ts similarity index 66% rename from lib/utils/base_context_class.js rename to src/utils/base_context_class.ts index dcc07bbd..0bbde000 100644 --- a/lib/utils/base_context_class.js +++ b/src/utils/base_context_class.ts @@ -1,18 +1,25 @@ -'use strict'; +import type { Context as KoaContext } from '@eggjs/koa'; +import type { EggCore } from '../egg'; + +export type EggCoreContext = KoaContext & { + app: EggCore; +}; /** * BaseContextClass is a base class that can be extended, * it's instantiated in context level, * {@link Helper}, {@link Service} is extending it. */ -class BaseContextClass { +export class BaseContextClass { + ctx: EggCoreContext; + app: EggCore; + config: Record; + service: BaseContextClass; /** - * @class - * @param {Context} ctx - context instance * @since 1.0.0 */ - constructor(ctx) { + constructor(ctx: EggCoreContext) { /** * @member {Context} BaseContextClass#ctx * @since 1.0.0 @@ -35,5 +42,3 @@ class BaseContextClass { this.service = ctx.service; } } - -module.exports = BaseContextClass; diff --git a/lib/utils/index.js b/src/utils/index.ts similarity index 70% rename from lib/utils/index.js rename to src/utils/index.ts index 390a65f5..517aa0b0 100644 --- a/lib/utils/index.js +++ b/src/utils/index.ts @@ -1,11 +1,11 @@ -'use strict'; +import path from 'node:path'; +import fs from 'node:fs'; +import BuiltinModule from 'node:module'; +import convert from 'koa-convert'; +import is from 'is-type-of'; +import co from 'co'; -const convert = require('koa-convert'); -const is = require('is-type-of'); -const path = require('path'); -const fs = require('fs'); -const co = require('co'); -const BuiltinModule = require('module'); +export type Fun = (...args: any[]) => any; // Guard against poorly mocked module constructors. const Module = module.constructor.length > 1 @@ -13,42 +13,43 @@ const Module = module.constructor.length > 1 /* istanbul ignore next */ : BuiltinModule; -module.exports = { - extensions: Module._extensions, +export default { + extensions: (Module as any)._extensions, - loadFile(filepath) { + loadFile(filepath: string) { try { // if not js module, just return content buffer const extname = path.extname(filepath); - if (extname && !Module._extensions[extname]) { + if (extname && !(Module as any)._extensions[extname]) { return fs.readFileSync(filepath); } // require js module + // eslint-disable-next-line @typescript-eslint/no-var-requires const obj = require(filepath); if (!obj) return obj; // it's es module if (obj.__esModule) return 'default' in obj ? obj.default : obj; return obj; } catch (err) { - err.message = `[egg-core] load file: ${filepath}, error: ${err.message}`; + err.message = `[@eggjs/core] load file: ${filepath}, error: ${err.message}`; throw err; } }, methods: [ 'head', 'options', 'get', 'put', 'patch', 'post', 'delete' ], - async callFn(fn, args, ctx) { + async callFn(fn: Fun, args?: any[], ctx?: any) { args = args || []; - if (!is.function(fn)) return; + if (typeof fn !== 'function') return; if (is.generatorFunction(fn)) fn = co.wrap(fn); return ctx ? fn.call(ctx, ...args) : fn(...args); }, - middleware(fn) { + middleware(fn: any) { return is.generatorFunction(fn) ? convert(fn) : fn; }, - getCalleeFromStack(withLine, stackIndex) { + getCalleeFromStack(withLine?: boolean, stackIndex?: number) { stackIndex = stackIndex === undefined ? 2 : stackIndex; const limit = Error.stackTraceLimit; const prep = Error.prepareStackTrace; @@ -57,11 +58,10 @@ module.exports = { Error.stackTraceLimit = 5; // capture the stack - const obj = {}; + const obj: any = {}; Error.captureStackTrace(obj); let callSite = obj.stack[stackIndex]; - let fileName; - /* istanbul ignore else */ + let fileName = ''; if (callSite) { // egg-mock will create a proxy // https://github.com/eggjs/egg-mock/blob/master/lib/app.js#L174 @@ -77,13 +77,12 @@ module.exports = { Error.prepareStackTrace = prep; Error.stackTraceLimit = limit; - /* istanbul ignore if */ if (!callSite || !fileName) return ''; if (!withLine) return fileName; return `${fileName}:${callSite.getLineNumber()}:${callSite.getColumnNumber()}`; }, - getResolvedFilename(filepath, baseDir) { + getResolvedFilename(filepath: string, baseDir: string) { const reg = /[/\\]/g; return filepath.replace(baseDir + path.sep, '').replace(reg, '/'); }, @@ -95,6 +94,6 @@ module.exports = { * https://github.com/v8/v8/wiki/Stack-Trace-API */ -function prepareObjectStackTrace(obj, stack) { +function prepareObjectStackTrace(_obj, stack) { return stack; } diff --git a/lib/utils/sequencify.js b/src/utils/sequencify.ts similarity index 91% rename from lib/utils/sequencify.js rename to src/utils/sequencify.ts index 517997b8..d7358411 100644 --- a/lib/utils/sequencify.js +++ b/src/utils/sequencify.ts @@ -1,6 +1,6 @@ -'use strict'; +import { debuglog } from 'node:util'; -const debug = require('debug')('egg-core#sequencify'); +const debug = debuglog('@eggjs/core:utils:sequencify'); function sequence(tasks, names, results, missing, recursive, nest, optional, parent) { names.forEach(function(name) { @@ -37,7 +37,7 @@ function sequence(tasks, names, results, missing, recursive, nest, optional, par // tasks: object with keys as task names // names: array of task names -module.exports = function(tasks, names) { +export default function sequencify(tasks, names: string[]) { const results = { sequence: [], requires: {}, @@ -56,4 +56,4 @@ module.exports = function(tasks, names) { missingTasks: missing, recursiveDependencies: recursive, }; -}; +} diff --git a/src/utils/timing.ts b/src/utils/timing.ts new file mode 100644 index 00000000..c77a4042 --- /dev/null +++ b/src/utils/timing.ts @@ -0,0 +1,107 @@ +import { EOL } from 'node:os'; +import assert from 'node:assert'; + +interface TimingItem { + name: string; + start: number; + end?: number; + duration?: number; + pid: number; + index: number; +} + +export class Timing { + #enable: boolean; + #startTime: number | null; + #map: Map; + #list: TimingItem[]; + constructor() { + this.#enable = true; + this.#startTime = null; + this.#map = new Map(); + this.#list = []; + this.init(); + } + + init() { + // process start time + this.start('Process Start', Date.now() - Math.floor(process.uptime() * 1000)); + this.end('Process Start'); + + if ('scriptStartTime' in process && typeof process.scriptStartTime === 'number') { + // js script start execute time + this.start('Script Start', process.scriptStartTime); + this.end('Script Start'); + } + } + + start(name: string, start?: number) { + if (!name || !this.#enable) return; + + if (this.#map.has(name)) this.end(name); + + start = start || Date.now(); + if (this.#startTime === null) { + this.#startTime = start; + } + const item: TimingItem = { + name, + start, + pid: process.pid, + index: this.#list.length, + }; + this.#map.set(name, item); + this.#list.push(item); + return item; + } + + end(name: string) { + if (!name || !this.#enable) return; + assert(this.#map.has(name), `should run timing.start('${name}') first`); + + const item = this.#map.get(name)!; + item.end = Date.now(); + item.duration = item.end - item.start; + return item; + } + + enable() { + this.#enable = true; + } + + disable() { + this.#enable = false; + } + + clear() { + this.#map.clear(); + this.#list = []; + } + + toJSON() { + return this.#list; + } + + itemToString(timelineEnd: number, item: TimingItem, times: number) { + const isEnd = typeof item.duration === 'number'; + const duration = isEnd ? item.duration! : timelineEnd - item.start; + const offset = item.start - this.#startTime!; + const status = `${duration}ms${isEnd ? '' : ' NOT_END'}`; + const timespan = Math.floor(Number((offset * times).toFixed(6))); + let timeline = Math.floor(Number((duration * times).toFixed(6))); + timeline = timeline > 0 ? timeline : 1; // make sure there is at least one unit + const message = `#${item.index} ${item.name}`; + return ' '.repeat(timespan) + '▇'.repeat(timeline) + ` [${status}] - ${message}`; + } + + toString(prefix = 'egg start timeline:', width = 50) { + const timelineEnd = Date.now(); + const timelineDuration = timelineEnd - this.#startTime!; + let times = 1; + if (timelineDuration > width) { + times = width / timelineDuration; + } + // follow https://github.com/node-modules/time-profile/blob/master/lib/profiler.js#L88 + return prefix + EOL + this.#list.map(item => this.itemToString(timelineEnd, item, times)).join(EOL); + } +} diff --git a/test/asyncLocalStorage.test.ts b/test/asyncLocalStorage.test.ts new file mode 100644 index 00000000..31e6dc37 --- /dev/null +++ b/test/asyncLocalStorage.test.ts @@ -0,0 +1,35 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; +import request from 'supertest'; +import { getAsyncLocalStorage, kGALS } from 'gals'; +import { Application } from './fixtures/egg'; + +describe('test/asyncLocalStorage.test.ts', () => { + let app: Application; + before(() => { + app = new Application({ + baseDir: path.join(__dirname, 'fixtures/session-cache-app'), + type: 'application', + }); + app.loader.loadAll(); + }); + + it('should start app with asyncLocalStorage = true by default', async () => { + assert.equal(app.currentContext, undefined); + const res = await request(app.callback()) + .get('/'); + assert.equal(res.status, 200); + // console.log(res.body); + assert.equal(res.body.sessionId, 'mock-session-id-123'); + assert(res.body.traceId); + assert.equal(app.currentContext, undefined); + }); + + it('should access als on global', async () => { + assert(global[Symbol.for('gals#asyncLocalStorage')]); + assert(global[kGALS]); + assert(global[Symbol.for('gals#asyncLocalStorage')] instanceof AsyncLocalStorage); + assert.equal(app.ctxStorage, global[Symbol.for('gals#asyncLocalStorage')]); + assert.equal(app.ctxStorage, getAsyncLocalStorage()); + }); +}); diff --git a/test/egg.test.js b/test/egg.test.ts similarity index 98% rename from test/egg.test.js rename to test/egg.test.ts index 01e32436..dcc33cbb 100644 --- a/test/egg.test.js +++ b/test/egg.test.ts @@ -1,21 +1,21 @@ -const mm = require('mm'); -const is = require('is-type-of'); -const util = require('util'); -const path = require('path'); -const assert = require('assert'); -const spy = require('spy'); -const request = require('supertest'); -const coffee = require('coffee'); -const utils = require('./utils'); -const EggCore = require('..').EggCore; -const awaitEvent = require('await-event'); -const fs = require('fs/promises'); - -describe('test/egg.test.js', () => { +import util from 'node:util'; +import path from 'node:path'; +import { strict as assert } from 'node:assert'; +import fs from 'node:fs/promises'; +import mm from 'mm'; +import is from 'is-type-of'; +import spy from 'spy'; +import request from 'supertest'; +import coffee from 'coffee'; +import utils from './utils'; +import awaitEvent from 'await-event'; +import { EggCore } from '../src/index'; + +describe('test/egg.test.ts', () => { afterEach(mm.restore); describe('create EggCore', () => { - let app; + let app: EggCore; after(() => app && app.close()); it('should set options and _options', () => { diff --git a/test/fixtures/timing/preload.js b/test/fixtures/timing/preload.js index 3c610744..ed2a7266 100644 --- a/test/fixtures/timing/preload.js +++ b/test/fixtures/timing/preload.js @@ -1,3 +1 @@ -'use strict'; - process.scriptStartTime = Date.now(); diff --git a/test/index.test.js b/test/index.test.ts similarity index 53% rename from test/index.test.js rename to test/index.test.ts index 6a9a143b..50416af9 100644 --- a/test/index.test.js +++ b/test/index.test.ts @@ -1,8 +1,9 @@ -const assert = require('assert'); -const EggCore = require('..'); +import { strict as assert } from 'node:assert'; +import * as EggCore from '../src/index'; -describe('test/index.test.js', () => { +describe('test/index.test.ts', () => { it('should expose properties', () => { + console.log(EggCore); assert(EggCore.EggCore); assert(EggCore.EggLoader); assert(EggCore.BaseContextClass); diff --git a/test/lifecycle.test.js b/test/lifecycle.test.ts similarity index 59% rename from test/lifecycle.test.js rename to test/lifecycle.test.ts index 05b13618..f1a6dff5 100644 --- a/test/lifecycle.test.js +++ b/test/lifecycle.test.ts @@ -1,9 +1,9 @@ -const assert = require('assert'); -const Lifecycle = require('../lib/lifecycle.js'); -const EggCore = require('..').EggCore; +import { strict as assert } from 'node:assert'; +import Lifecycle from '../src/lifecycle'; +import EggCore from '../src/egg'; -describe('test/lifecycle.js', () => { - it('should forbid adding hook atfter initialization', () => { +describe('test/lifecycle.test.ts', () => { + it('should forbid adding hook after initialization', () => { const lifecycle = new Lifecycle({ baseDir: '.', app: new EggCore(), @@ -13,18 +13,19 @@ describe('test/lifecycle.js', () => { assert.throws(() => { lifecycle.addBootHook( class Hook { - constructor(app) { + app: EggCore; + constructor(app: EggCore) { this.app = app; } configDidLoad() { console.log('test'); } - } + }, ); }, /do not add hook when lifecycle has been initialized/); assert.throws(() => { - lifecycle.addBootHook(() => { + lifecycle.addFunctionAsBootHook(() => { console.log('test'); }); }, /do not add hook when lifecycle has been initialized/); diff --git a/test/loader/file_loader.test.js b/test/loader/file_loader.test.js index 475d327c..1091220d 100644 --- a/test/loader/file_loader.test.js +++ b/test/loader/file_loader.test.js @@ -53,7 +53,7 @@ describe('test/loader/file_loader.test.js', () => { target: app.services, }).load(); }, - /can't overwrite property 'foo'/ + /can't overwrite property 'foo'/, ); }); diff --git a/test/utils/index.test.js b/test/utils/index.test.ts similarity index 80% rename from test/utils/index.test.js rename to test/utils/index.test.ts index 368d0108..a67d3d39 100644 --- a/test/utils/index.test.js +++ b/test/utils/index.test.ts @@ -1,10 +1,10 @@ -const mm = require('mm'); -const path = require('path'); -const assert = require('assert'); -const { sleep } = require('../utils'); -const utils = require('../../lib/utils'); +import path from 'node:path'; +import { strict as assert } from 'node:assert'; +import mm from 'mm'; +import { sleep } from '../utils'; +import utils from '../../src/utils'; -describe('test/utils/index.test.js', () => { +describe('test/utils/index.test.ts', () => { afterEach(mm.restore); describe('callFn', () => { @@ -15,7 +15,7 @@ describe('test/utils/index.test.js', () => { it('should call function', async () => { function fn() { return 1; } const result = await utils.callFn(fn); - assert(result === 1); + assert.equal(result, 1); }); it('should call generator function', async () => { @@ -24,7 +24,7 @@ describe('test/utils/index.test.js', () => { return 1; } const result = await utils.callFn(fn); - assert(result === 1); + assert.equal(result, 1); }); it('should call return promise function', async () => { @@ -32,7 +32,7 @@ describe('test/utils/index.test.js', () => { return sleep(10).then(() => (1)); } const result = await utils.callFn(fn); - assert(result === 1); + assert.equal(result, 1); }); it('should call async function', async () => { @@ -41,7 +41,7 @@ describe('test/utils/index.test.js', () => { return 1; } const result = await utils.callFn(fn); - assert(result === 1); + assert.equal(result, 1); }); it('should call with args', async () => { @@ -58,17 +58,17 @@ describe('test/utils/index.test.js', () => { const baseDir = path.join(__dirname, '../fixtures/loadfile'); it('should load object', () => { const result = utils.loadFile(path.join(baseDir, 'object.js')); - assert(result.a === 1); + assert.equal(result.a, 1); }); it('should load null', () => { const result = utils.loadFile(path.join(baseDir, 'null.js')); - assert(result === null); + assert.equal(result, null); }); it('should load null', () => { const result = utils.loadFile(path.join(baseDir, 'zero.js')); - assert(result === 0); + assert.equal(result, 0); }); it('should load es module', () => { @@ -83,7 +83,7 @@ describe('test/utils/index.test.js', () => { it('should load es module with default = null', () => { const result = utils.loadFile(path.join(baseDir, 'es-module-default-null.js')); - assert(result === null); + assert.equal(result, null); }); it('should load no js file', () => { @@ -91,7 +91,7 @@ describe('test/utils/index.test.js', () => { if (process.platform === 'win32') { result = result.replace(/\r\n/g, '\n'); } - assert(result === '---\nmap:\n a: 1\n b: 2'); + assert.equal(result, '---\nmap:\n a: 1\n b: 2'); }); }); }); diff --git a/test/utils/router.test.js b/test/utils/router.test.ts similarity index 98% rename from test/utils/router.test.js rename to test/utils/router.test.ts index 028b6f81..310e51ba 100644 --- a/test/utils/router.test.js +++ b/test/utils/router.test.ts @@ -1,8 +1,8 @@ -const assert = require('assert'); -const request = require('supertest'); -const utils = require('../utils'); +import { strict as assert } from 'node:assert'; +import request from 'supertest'; +import utils from '../utils'; -describe('test/utils/router.test.js', () => { +describe('test/utils/router.test.ts', () => { let app; before(() => { app = utils.createApp('router-app'); diff --git a/test/utils/timing.test.js b/test/utils/timing.test.ts similarity index 65% rename from test/utils/timing.test.js rename to test/utils/timing.test.ts index fa13c33b..6fdf5ca4 100644 --- a/test/utils/timing.test.js +++ b/test/utils/timing.test.ts @@ -1,9 +1,7 @@ -'use strict'; +import { strict as assert } from 'node:assert'; +import Timing from '../../src/utils/timing'; -const assert = require('assert'); -const Timing = require('../../lib/utils/timing'); - -describe('test/utils/timing.test.js', () => { +describe('test/utils/timing.test.ts', () => { it('should trace', () => { const timing = new Timing(); @@ -13,14 +11,14 @@ describe('test/utils/timing.test.js', () => { timing.end('b'); const json = timing.toJSON(); - assert(json.length === 3); + assert.equal(json.length, 3); - assert(json[1].name === 'a'); - assert(json[1].end - json[1].start === json[1].duration); - assert(json[1].pid === process.pid); - assert(json[2].name === 'b'); - assert(json[2].end - json[2].start === json[2].duration); - assert(json[2].pid === process.pid); + assert.equal(json[1].name, 'a'); + assert.equal(json[1].end - json[1].start, json[1].duration); + assert.equal(json[1].pid, process.pid); + assert.equal(json[2].name, 'b'); + assert.equal(json[2].end - json[2].start, json[2].duration); + assert.equal(json[2].pid, process.pid); timing.start('c'); console.log(timing.toString()); @@ -31,10 +29,10 @@ describe('test/utils/timing.test.js', () => { timing.start('a'); const json = timing.toJSON(); - assert(json[1].name === 'a'); + assert.equal(json[1].name, 'a'); assert(json[1].start); - assert(json[1].end === undefined); - assert(json[1].duration === undefined); + assert.equal(json[1].end, undefined); + assert.equal(json[1].duration, undefined); }); it('should ignore start when name is empty', () => { @@ -42,22 +40,22 @@ describe('test/utils/timing.test.js', () => { timing.start(); const json = timing.toJSON(); - assert(json.length === 1); + assert.equal(json.length, 1); }); it('should throw when name exists', () => { const timing = new Timing(); timing.start('a'); - assert(timing.toJSON().length === 2); + assert.equal(timing.toJSON().length, 2); timing.start('a'); - assert(timing.toJSON().length === 3); + assert.equal(timing.toJSON().length, 3); }); it('should ignore end when name dont exist', () => { const timing = new Timing(); timing.end(); - assert(timing.toJSON().length === 1); + assert.equal(timing.toJSON().length, 1); }); it('should enable/disable', () => { @@ -77,9 +75,9 @@ describe('test/utils/timing.test.js', () => { const json = timing.toJSON(); - assert(json[1].name === 'a'); - assert(json[2].name === 'c'); - assert(json.length === 3); + assert.equal(json[1].name, 'a'); + assert.equal(json[2].name, 'c'); + assert.equal(json.length, 3); }); it('should clear', () => { @@ -88,7 +86,7 @@ describe('test/utils/timing.test.js', () => { timing.end('a'); const json = timing.toJSON(); - assert(json[1].name === 'a'); + assert.equal(json[1].name, 'a'); timing.clear(); @@ -97,8 +95,8 @@ describe('test/utils/timing.test.js', () => { const json2 = timing.toJSON(); - assert(json2[0].name === 'b'); - assert(json2.length === 1); + assert.equal(json2[0].name, 'b'); + assert.equal(json2.length, 1); }); it('should throw when end and name dont exists', () => {