'use strict';
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');
const REQUIRE_COUNT = Symbol('EggLoader#requireCount');
class EggLoader {
/**
* @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) {
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
* @see {@link AppInfo#pkg}
* @since 1.0.0
*/
this.pkg = utility.readJSONSync(path.join(this.options.baseDir, 'package.json'));
/**
* 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 {AppInfo} EggLoader#appInfo
* @since 1.0.0
*/
this.appInfo = this.getAppInfo();
/**
* @member {String} EggLoader#serverScope
* @see AppInfo#serverScope
*/
this.serverScope = options.serverScope !== undefined
? options.serverScope
: this.getServerScope();
}
/**
* Get {@link AppInfo#env}
* @return {String} env
* @see AppInfo#env
* @private
* @since 1.0.0
*/
getServerEnv() {
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) {
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
*/
getServerScope() {
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
*/
getAppInfo() {
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
*/
getEggPaths() {
// avoid require recursively
const EggCore = require('../egg');
const eggPaths = [];
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} arguments - 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[REQUIRE_COUNT]++}) ${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);
}
module.exports = EggLoader;