lib/models/package-info-cache/package-info.js
'use strict';
const path = require('path');
const ErrorList = require('./error-list');
const Errors = require('./errors');
const AddonInfo = require('../addon-info');
const isAddon = require('../../utilities/is-addon');
const isEngine = require('../../utilities/is-engine');
const isLazyEngine = require('../../utilities/is-lazy-engine');
const logger = require('heimdalljs-logger')('ember-cli:package-info-cache:package-info');
const PerBundleAddonCache = require('../per-bundle-addon-cache');
function lexicographically(a, b) {
const aIsString = typeof a.name === 'string';
const bIsString = typeof b.name === 'string';
if (aIsString && bIsString) {
return a.name.localeCompare(b.name);
} else if (aIsString) {
return -1;
} else if (bIsString) {
return 1;
} else {
return 0;
}
}
function pushUnique(array, entry) {
const index = array.indexOf(entry);
if (index > -1) {
// the entry already exists in the array, but since the presedence between
// addons is "last right wins". We first remove the duplicate entry, and
// append it to the end of the array.
array.splice(index, 1);
}
// At this point, the entry is not in the array. So we must append it.
array.push(entry);
// All this ensures:
// pushUnique([a1,a2,a3], a1)
// results in:
//
// [a2, a3, a1]
//
// which results in the most "least surprising" addon ordering.
}
/**
* Class that stores information about a single package (directory tree with
* a package.json and other data, like and Addon or Project.) It is one of the
* two types of entries in a PackageInfoCache. It is only created by the
* PackageInfoCache.
*
* @public
* @class PackageInfo
*/
class PackageInfo {
constructor(pkgObj, realPath, cache, isRoot = false) {
this.pkg = pkgObj;
this.pkg['ember-addon'] = this.pkg['ember-addon'] || {};
this.realPath = realPath;
this.cache = cache;
this.errors = new ErrorList();
// other fields that will be set as needed. For JIT we'll define
// them here.
this.addonMainPath = undefined; // addons only
this.inRepoAddons = undefined; // (list of PackageInfo - both)
this.internalAddons = undefined; // (list of PackageInfo - project only)
this.cliInfo = undefined; // (PackageInfo - project only)
this.dependencyPackages = undefined; // (obj keyed by dependency name: PackageInfo)
// NOTE: ALL dependencies, not just addons
this.devDependencyPackages = undefined; // (obj keyed by devDependency name: PackageInfo)
// NOTE: these are ALL dependencies, not just addons
this.nodeModules = undefined; // (NodeModulesList, set only if pkg contains node_modules)
// flag indicating that the packageInfo is considered valid. This will
// be true as long as we have a valid directory and our package.json file
// is okay and, if we're an ember addon, that we have a valid 'main' file.
// Missing dependencies will not be considered an error, since they may
// not actually be used.
this.valid = true;
this.mayHaveAddons = isRoot || this.isForAddon(); // mayHaveAddons used in index.js
this._hasDumpedInvalidAddonPackages = false;
}
// Make various fields of the pkg object available.
get name() {
return this.pkg.name;
}
/**
* Given error data, add an ErrorEntry to the ErrorList for this object.
* This is used by the _readPackage and _readNodeModulesList methods. It
* should not be called otherwise.
*
* @protected
* @method addError
* @param {String} errorType one of the Errors.ERROR_* constants.
* @param {Object} errorData any error data relevant to the type of error
* being created. See showErrors().
*/
addError(errorType, errorData) {
this.errors.addError(errorType, errorData);
}
/**
* Indicate if there are any errors in the ErrorList for this package. Note that this does
* NOT indicate if there are any errors in the objects referred to by this package (e.g.,
* internal addons or dependencies).
*
* @public
* @method hasErrors
* @param {boolean} true if there are errors in the ErrorList, else false.
*/
hasErrors() {
return this.errors.hasErrors();
}
/**
* Add a reference to an in-repo addon PackageInfo object.
*
* @protected
* @method addInRepoAddon
* @param {PackageInfo} inRepoAddonPkg the PackageInfo for the in-repo addon
* @return null
*/
addInRepoAddon(inRepoAddonPkg) {
if (!this.inRepoAddons) {
this.inRepoAddons = [];
}
this.inRepoAddons.push(inRepoAddonPkg);
}
/**
* Add a reference to an internal addon PackageInfo object.
* "internal" addons (note: not in-repo addons) only exist in
* Projects, not other packages. Since the cache is loaded from
* 'loadProject', this can be done appropriately.
*
* @protected
* @method addInternalAddon
* @param {PackageInfo} internalAddonPkg the PackageInfo for the internal addon
* @return null
*/
addInternalAddon(internalAddonPkg) {
if (!this.internalAddons) {
this.internalAddons = [];
}
this.internalAddons.push(internalAddonPkg);
}
/**
* For each dependency in the given list, find the corresponding
* PackageInfo object in the cache (going up the file tree if
* necessary, as in the node resolution algorithm). Return a map
* of the dependencyName to PackageInfo object. Caller can then
* store it wherever they like.
*
* Note: this is not to be called until all packages that can be have
* been added to the cache.
*
* Note: this is for ALL dependencies, not just addons. To get just
* addons, filter the result by calling pkgInfo.isForAddon().
*
* Note: this is only intended for use from PackageInfoCache._resolveDependencies.
* It is not to be called directly by anything else.
*
* @protected
* @method addDependencies
* @param {PackageInfo} dependencies value of 'dependencies' or 'devDependencies'
* attributes of a package.json.
* @return {Object} a JavaScript object keyed on dependency name/path with
* values the corresponding PackageInfo object from the cache.
*/
addDependencies(dependencies) {
if (!dependencies) {
return null;
}
let dependencyNames = Object.keys(dependencies);
if (dependencyNames.length === 0) {
return null;
}
let packages = Object.create(null);
let missingDependencies = [];
dependencyNames.forEach((dependencyName) => {
logger.info('%s: Trying to find dependency %o', this.pkg.name, dependencyName);
let dependencyPackage;
// much of the time the package will have dependencies in
// a node_modules inside it, so check there first because it's
// quicker since we have the reference. Only check externally
// if we don't find it there.
if (this.nodeModules) {
dependencyPackage = this.nodeModules.findPackage(dependencyName);
}
if (!dependencyPackage) {
dependencyPackage = this.cache._findPackage(dependencyName, path.dirname(this.realPath));
}
if (dependencyPackage) {
packages[dependencyName] = dependencyPackage;
} else {
missingDependencies.push(dependencyName);
}
});
if (missingDependencies.length > 0) {
this.addError(Errors.ERROR_DEPENDENCIES_MISSING, missingDependencies);
}
return packages;
}
/**
* Indicate if this packageInfo is for a project. Should be called only after the project
* has been loaded (see {@link PackageInfoCache#loadProject} for details).
*
* @method isForProject
* @return {Boolean} true if this packageInfo is for a Project, false otherwise.
*/
isForProject() {
return !!this.project && this.project.isEmberCLIProject && this.project.isEmberCLIProject();
}
/**
* Indicate if this packageInfo is for an Addon.
*
* @method isForAddon
* @return {Boolean} true if this packageInfo is for an Addon, false otherwise.
*/
isForAddon() {
return isAddon(this.pkg.keywords);
}
/**
* Indicate if this packageInfo represents an engine.
*
* @method isForEngine
* @return {Boolean} true if this pkgInfo is configured as an engine & false otherwise
*/
isForEngine() {
return isEngine(this.pkg.keywords);
}
/**
* Indicate if this packageInfo represents a lazy engine.
*
* @method isForLazyEngine
* @return {Boolean} true if this pkgInfo is configured as an engine and the
* module this represents has lazyLoading enabled, false otherwise.
*/
isForLazyEngine() {
return this.isForEngine() && isLazyEngine(this._getAddonEntryPoint());
}
/**
* For use with the PerBundleAddonCache, is this packageInfo representing a
* bundle host (for now, a Project or a lazy engine).
*
* @method isForBundleHost
* @return {Boolean} true if this pkgInfo is for a bundle host, false otherwise.
*/
isForBundleHost() {
return this.isForProject() || this.isForLazyEngine();
}
/**
* Add to a list of child addon PackageInfos for this packageInfo.
*
* @method addPackages
* @param {Array} addonPackageList the list of child addon PackageInfos being constructed from various
* sources in this packageInfo.
* @param {Array | Object} packageInfoList a list or map of PackageInfos being considered
* (e.g., pkgInfo.dependencyPackages) for inclusion in the addonPackageList.
* @param {Function} excludeFn an optional function. If passed in, each child PackageInfo
* will be tested against the function and only included in the package map if the function
* returns a truthy value.
*/
addPackages(addonPackageList, packageInfoList, excludeFn) {
if (!packageInfoList) {
return;
}
let result = [];
if (Array.isArray(packageInfoList)) {
if (excludeFn) {
packageInfoList = packageInfoList.filter((pkgInfo) => !excludeFn(pkgInfo));
}
packageInfoList.forEach((pkgInfo) => result.push(pkgInfo));
} else {
// We're going to assume we have a map of name to packageInfo
Object.keys(packageInfoList).forEach((name) => {
let pkgInfo = packageInfoList[name];
if (!excludeFn || !excludeFn(pkgInfo)) {
result.push(pkgInfo);
}
});
}
result.sort(lexicographically).forEach((pkgInfo) => pushUnique(addonPackageList, pkgInfo));
return addonPackageList;
}
/**
* Discover the child addons for this packageInfo, which corresponds to an Addon.
*
* @method discoverAddonAddons
*/
discoverAddonAddons() {
let addonPackageList = [];
this.addPackages(
addonPackageList,
this.dependencyPackages,
(pkgInfo) => !pkgInfo.isForAddon() || pkgInfo.name === 'ember-cli'
);
this.addPackages(addonPackageList, this.inRepoAddons);
return addonPackageList;
}
/**
* Discover the child addons for this packageInfo, which corresponds to a Project.
*
* @method discoverProjectAddons
*/
discoverProjectAddons() {
let project = this.project;
let addonPackageList = [];
this.addPackages(addonPackageList, project.isEmberCLIAddon() ? [this] : null);
this.addPackages(addonPackageList, this.cliInfo ? this.cliInfo.inRepoAddons : null);
this.addPackages(addonPackageList, this.internalAddons);
this.addPackages(addonPackageList, this.devDependencyPackages, (pkgInfo) => !pkgInfo.isForAddon());
this.addPackages(addonPackageList, this.dependencyPackages, (pkgInfo) => !pkgInfo.isForAddon());
this.addPackages(addonPackageList, this.inRepoAddons);
return addonPackageList;
}
/**
* Given a list of PackageInfo objects, generate the 'addonPackages' object (keyed by
* name, value = AddonInfo instance, for all packages marked 'valid'). This is stored in
* both Addon and Project instances.
*
* @method generateAddonPackages
* @param {Array} addonPackageList the list of child addon PackageInfos to work from
* @param {Function} excludeFn an optional function. If passed in, each child PackageInfo
* will be tested against the function and only included in the package map if the function
* returns a truthy value.
*/
generateAddonPackages(addonPackageList, excludeFn) {
let validPackages = this.getValidPackages(addonPackageList);
let packageMap = Object.create(null);
validPackages.forEach((pkgInfo) => {
let addonInfo = new AddonInfo(pkgInfo.name, pkgInfo.realPath, pkgInfo.pkg);
if (!excludeFn || !excludeFn(addonInfo)) {
packageMap[pkgInfo.name] = addonInfo;
}
});
return packageMap;
}
getValidPackages(addonPackageList) {
return addonPackageList.filter((pkgInfo) => pkgInfo.valid);
}
getInvalidPackages(addonPackageList) {
return addonPackageList.filter((pkgInfo) => !pkgInfo.valid);
}
dumpInvalidAddonPackages(addonPackageList) {
if (this._hasDumpedInvalidAddonPackages) {
return;
}
this._hasDumpedInvalidAddonPackages = true;
let invalidPackages = this.getInvalidPackages(addonPackageList);
if (invalidPackages.length > 0) {
let typeName = this.project ? 'project' : 'addon';
let msg = `The 'package.json' file for the ${typeName} at ${this.realPath}`;
let relativePath;
if (invalidPackages.length === 1) {
relativePath = path.relative(this.realPath, invalidPackages[0].realPath);
msg = `${msg}\n specifies an invalid, malformed or missing addon at relative path '${relativePath}'`;
} else {
msg = `${msg}\n specifies invalid, malformed or missing addons at relative paths`;
invalidPackages.forEach((packageInfo) => {
let relativePath = path.relative(this.realPath, packageInfo.realPath);
msg = `${msg}\n '${relativePath}'`;
});
}
throw msg;
}
}
/**
* This is only supposed to be called by the addon instantiation code.
* Also, the assumption here is that this PackageInfo really is for an
* Addon, so we don't need to check each time.
*
* @private
* @method getAddonConstructor
* @return {AddonConstructor} an instance of a constructor function for the Addon class
* whose package information is stored in this object.
*/
getAddonConstructor() {
if (this.addonConstructor) {
return this.addonConstructor;
}
let module = this._getAddonEntryPoint();
let mainDir = path.dirname(this.addonMainPath);
let ctor;
if (typeof module === 'function') {
ctor = module;
ctor.prototype.root = ctor.prototype.root || mainDir;
ctor.prototype.packageRoot = ctor.prototype.packageRoot || this.realPath;
ctor.prototype.pkg = ctor.prototype.pkg || this.pkg;
} else {
const Addon = require('../addon'); // done here because of circular dependency
ctor = Addon.extend(Object.assign({ root: mainDir, packageRoot: this.realPath, pkg: this.pkg }, module));
}
ctor._meta_ = {
modulePath: this.addonMainPath,
};
return (this.addonConstructor = ctor);
}
/**
* Construct an addon instance.
*
* NOTE: this does NOT call constructors for the child addons. That is left to
* the caller to do, so they can insert any other logic they want.
*
* @private
* @method constructAddonInstance
* @param {Project|Addon} parent the parent that directly contains this addon
* @param {Project} project the project that is/contains this addon
*/
constructAddonInstance(parent, project) {
let start = Date.now();
let AddonConstructor = this.getAddonConstructor();
let addonInstance;
try {
addonInstance = new AddonConstructor(parent, project);
} catch (e) {
if (parent && parent.ui) {
parent.ui.writeError(e);
}
const SilentError = require('silent-error');
throw new SilentError(`An error occurred in the constructor for ${this.name} at ${this.realPath}`);
}
AddonConstructor._meta_.initializeIn = Date.now() - start;
addonInstance.constructor = AddonConstructor;
return addonInstance;
}
/**
* Create an instance of the addon represented by this packageInfo or (if we
* are supporting per-bundle caching and this is an allow-caching-per-bundle addon)
* check if we should be creating a proxy instead.
*
* NOTE: we assume that the value of 'allowCachingPerBundle' does not change between
* calls to the constructor! A given addon is either allowing or not allowing caching
* for an entire run.
*
* @method getAddonInstance
* @param {} parent the addon/project that is to be the direct parent of the
* addon instance created here
* @param {*} project the project that is to contain this addon instance
* @return {Object} the constructed instance of the addon
*/
getAddonInstance(parent, project) {
let addonEntryPointModule = this._getAddonEntryPoint();
let addonInstance;
if (
PerBundleAddonCache.isEnabled() &&
project &&
project.perBundleAddonCache.allowCachingPerBundle(addonEntryPointModule)
) {
addonInstance = project.perBundleAddonCache.getAddonInstance(parent, this);
} else {
addonInstance = this.constructAddonInstance(parent, project);
this.initChildAddons(addonInstance);
}
return addonInstance;
}
/**
* Initialize the child addons array of a newly-created addon instance. Normally when
* an addon derives from Addon, child addons will be created during 'setupRegistry' and
* this code is essentially unnecessary. But if an addon is created with custom constructors
* that don't call 'setupRegistry', any child addons may not yet be initialized.
*
* @method initChildAddons
* @param {Addon} addonInstance
*/
initChildAddons(addonInstance) {
if (addonInstance.initializeAddons) {
addonInstance.initializeAddons();
} else {
addonInstance.addons = [];
}
}
/**
* Gets the addon entry point
*
* @method _getAddonEntryPoint
* @return {Object|Function} The exported addon entry point
* @private
*/
_getAddonEntryPoint() {
if (!this.addonMainPath) {
throw new Error(`${this.pkg.name} at ${this.realPath} is missing its addon main file`);
}
// Load the addon.
// TODO: Future work - allow a time budget for loading each addon and warn
// or error for those that take too long.
return require(this.addonMainPath);
}
}
module.exports = PackageInfo;
module.exports.lexicographically = lexicographically;
module.exports.pushUnique = pushUnique;