lib/models/per-bundle-addon-cache/index.js
'use strict';
const fs = require('fs');
const path = require('path');
const isLazyEngine = require('../../utilities/is-lazy-engine');
const { getAddonProxy } = require('./addon-proxy');
const logger = require('heimdalljs-logger')('ember-cli:per-bundle-addon-cache');
const { TARGET_INSTANCE } = require('./target-instance');
function defaultAllowCachingPerBundle({ addonEntryPointModule }) {
return (
addonEntryPointModule.allowCachingPerBundle ||
(addonEntryPointModule.prototype && addonEntryPointModule.prototype.allowCachingPerBundle)
);
}
/**
* Resolves the perBundleAddonCacheUtil; this prefers the custom provided version by
* the consuming application, and defaults to an internal implementation here.
*
* @method resolvePerBundleAddonCacheUtil
* @param {Project} project
* @return {{allowCachingPerBundle: Function}}
*/
function resolvePerBundleAddonCacheUtil(project) {
const relativePathToUtil =
project.pkg && project.pkg['ember-addon'] && project.pkg['ember-addon'].perBundleAddonCacheUtil;
if (typeof relativePathToUtil === 'string') {
const absolutePathToUtil = path.resolve(project.root, relativePathToUtil);
if (!fs.existsSync(absolutePathToUtil)) {
throw new Error(
`[ember-cli] the provided \`${relativePathToUtil}\` for \`ember-addon.perBundleAddonCacheUtil\` does not exist`
);
}
return require(absolutePathToUtil);
}
return {
allowCachingPerBundle: defaultAllowCachingPerBundle,
};
}
/**
* For large applications with many addons (and many instances of each, resulting in
* potentially many millions of addon instances during a build), the build can become
* very, very slow (tens of minutes) partially due to the sheer number of addon instances.
* The PerBundleAddonCache deals with this slowness by doing 3 things:
*
* (1) Making only a single copy of each of certain addons and their dependent addons
* (2) Replacing any other instances of those addons with Proxy copies to the single instance
* (3) Having the Proxies return an empty array for their dependent addons, rather
* than proxying to the contents of the single addon instance. This gives up the
* ability of the Proxies to traverse downward into their child addons,
* something that many addons do not do anyway, for the huge reduction in duplications
* of those child addons. For applications that enable `ember-engines` dedupe logic,
* that logic is stateful, and having the Proxies allow access to the child addons array
* just breaks everything, because that logic will try multiple times to remove items
* it thinks are duplicated, messing up the single copy of the child addon array.
* See the explanation of the dedupe logic in
* {@link https://github.com/ember-engines/ember-engines/blob/master/packages/ember-engines/lib/utils/deeply-non-duplicated-addon.js}
*
* What follows are the more technical details of how the PerBundleAddonCache implements
* the above 3 behaviors.
*
* This class supports per-bundle-host (bundle host = project or lazy engine)
* caching of addon instances. During addon initialization we cannot add a
* cache to each bundle host object AFTER it is instantiated because running the
* addon constructor ultimately causes Addon class `setupRegistry` code to
* run which instantiates child addons, which need the cache to already be
* in place for the parent bundle host.
* We handle this by providing a global cache that exists independent of the
* bundle host objects. That is this object.
*
* There are a number of "behaviors" being implemented by this object and
* its contents. They are:
* (1) Any addon that is a lazy engine has only a single real instance per
* project - all other references to the lazy engine are to be proxies. These
* lazy engines are compared by name, not by packageInfo.realPath.
* (2) Any addon that is not a lazy engine, there is only a single real instance
* of the addon per "bundle host" (i.e. lazy engine or project).
* (3) An optimization - any addon that is in a lazy engine but that is also
* in bundled by its LCA host - the single instance is the one bundled by this
* host. All other instances (in any lazy engine) are proxies.
*
* NOTE: the optimization is only enabled if the environment variable that controls
* `ember-engines` transitive deduplication (process.env.EMBER_ENGINES_ADDON_DEDUPE)
* is set to a truthy value. For more info, see:
* https://github.com/ember-engines/ember-engines/blob/master/packages/ember-engines/lib/engine-addon.js#L396
*
* @public
* @class PerBundleAddonCache
*/
class PerBundleAddonCache {
constructor(project) {
this.project = project;
// The cache of bundle-host package infos and their individual addon caches.
// The cache is keyed by package info (representing a bundle host (project or
// lazy engine)) and an addon instance cache to bundle with that bundle host.
this.bundleHostCache = new Map();
// Indicate if ember-engines transitive dedupe is enabled.
this.engineAddonTransitiveDedupeEnabled = !!process.env.EMBER_ENGINES_ADDON_DEDUPE;
this._perBundleAddonCacheUtil = resolvePerBundleAddonCacheUtil(this.project);
// For stats purposes, counts on the # addons and proxies created. Addons we
// can compare against the bundleHostCache addon caches. Proxies, not so much,
// but we'll count them here.
this.numAddonInstances = 0;
this.numProxies = 0;
}
/**
* The default implementation here is to indicate if the original addon entry point has
* the `allowCachingPerBundle` flag set either on itself or on its prototype.
*
* If a consuming application specifies a relative path to a custom utility via the
* `ember-addon.perBundleAddonCacheUtil` configuration, we prefer the custom implementation
* provided by the consumer.
*
* @method allowCachingPerBundle
* @param {Object|Function} addonEntryPointModule
* @return {Boolean} true if the given constructor function or class supports caching per bundle, false otherwise
*/
allowCachingPerBundle(addonEntryPointModule) {
return this._perBundleAddonCacheUtil.allowCachingPerBundle({ addonEntryPointModule });
}
/**
* Creates a cache entry for the bundleHostCache. Because we want to use the same sort of proxy
* for both bundle hosts and for 'regular' addon instances (though their cache entries have
* slightly different structures) we'll use the Symbol from getAddonProxy.
*
* @method createBundleHostCacheEntry
* @param {PackageInfo} bundleHostPkgInfo bundle host's pkgInfo.realPath
* @return {Object} an object in the form of a bundle-host cache entry
*/
createBundleHostCacheEntry(bundleHostPkgInfo) {
return { [TARGET_INSTANCE]: null, realPath: bundleHostPkgInfo.realPath, addonInstanceCache: new Map() };
}
/**
* Create a cache entry object for a given (non-bundle-host) addon to put into
* an addon cache.
*
* @method createAddonCacheEntry
* @param {Addon} addonInstance the addon instance to cache
* @param {String} addonRealPath the addon's pkgInfo.realPath
* @return {Object} an object in the form of an addon-cache entry
*/
createAddonCacheEntry(addonInstance, addonRealPath) {
return { [TARGET_INSTANCE]: addonInstance, realPath: addonRealPath };
}
/**
* Given a parent object of a potential addon (another addon or the project),
* go up the 'parent' chain to find the potential addon's bundle host object
* (i.e. lazy engine or project.) Because Projects are always bundle hosts,
* this should always pass, but we'll throw if somehow it doesn't work.
*
* @method findBundleHost
* @param {Project|Addon} addonParent the direct parent object of a (potential or real) addon.
* @param {PackageInfo} addonPkgInfo the PackageInfo for an addon being instantiated. This is only
* used for information if an error is going to be thrown.
* @return {Object} the object in the 'parent' chain that is a bundle host.
* @throws {Error} if there is not bundle host
*/
findBundleHost(addonParent, addonPkgInfo) {
let curr = addonParent;
while (curr) {
if (curr === this.project) {
return curr;
}
if (isLazyEngine(curr)) {
// if we're building a lazy engine in isolation, prefer that the bundle host is
// the project, not the lazy engine addon instance
if (curr.parent === this.project && curr._packageInfo === this.project._packageInfo) {
return this.project;
}
return curr;
}
curr = curr.parent;
}
// the following should not be able to happen given that Projects are always
// bundle hosts, but just in case, throw an error if we didn't find one.
throw new Error(`Addon at path\n ${addonPkgInfo.realPath}\n has 'allowCachingPerBundle' but has no bundleHost`);
}
/**
* An optimization we support from lazy engines is the following:
*
* If an addon instance is supposed to be bundled with a particular lazy engine, and
* same addon is also to be bundled by a common LCA host, prefer the one bundled by the
* host (since it's ultimately going to be deduped later by `ember-engines`).
*
* NOTE: this only applies if this.engineAddonTransitiveDedupeEnabled is truthy. If it is not,
* the bundle host always "owns" the addon instance.
*
* If deduping is enabled and the LCA host also depends on the same addon,
* the lazy-engine instances of the addon will all be proxies to the one in
* the LCA host. This function indicates whether the bundle host passed in
* (either the project or a lazy engine) is really the bundle host to "own" the
* new addon.
*
* @method bundleHostOwnsInstance
* @param (Object} bundleHost the project or lazy engine that is trying to "own"
* the new addon instance specified by addonPkgInfo
* @param {PackageInfo} addonPkgInfo the PackageInfo of the potential new addon instance
* @return {Boolean} true if the bundle host is to "own" the instance, false otherwise.
*/
bundleHostOwnsInstance(bundleHost, addonPkgInfo) {
if (isLazyEngine(bundleHost)) {
return (
!this.engineAddonTransitiveDedupeEnabled ||
!this.project.hostInfoCache
.getHostAddonInfo(bundleHost._packageInfo)
.hostAndAncestorBundledPackageInfos.has(addonPkgInfo)
);
}
return true;
}
findBundleOwner(bundleHost, addonPkgInfo) {
if (bundleHost === this.project._packageInfo) {
return bundleHost;
}
let { hostPackageInfo, hostAndAncestorBundledPackageInfos } =
this.project.hostInfoCache.getHostAddonInfo(bundleHost);
if (!hostAndAncestorBundledPackageInfos.has(addonPkgInfo)) {
return bundleHost;
}
return this.findBundleOwner(hostPackageInfo, addonPkgInfo);
}
/**
* Called from PackageInfo.getAddonInstance(), return an instance of the requested
* addon or a Proxy, based on the type of addon and its bundle host.
*
* @method getAddonInstance
* @param {Addon|Project} parent the parent Addon or Project this addon instance is
* a child of.
* @param {*} addonPkgInfo the PackageInfo for the addon being created.
* @return {Addon|Proxy} An addon instance (for the first copy of the addon) or a Proxy.
* An addon that is a lazy engine will only ever have a single copy in the cache.
* An addon that is not will have 1 copy per bundle host (Project or lazy engine),
* except if it is an addon that's also owned by a given LCA host and transitive
* dedupe is enabled (`engineAddonTransitiveDedupeEnabled`), in which case it will
* only have a single copy in the project's addon cache.
*/
getAddonInstance(parent, addonPkgInfo) {
// If the new addon is itself a bundle host (i.e. lazy engine), there is only one
// instance of the bundle host, and it's in the entries of the bundleHostCache, outside
// of the 'regular' addon caches. Because 'setupBundleHostCache' ran during construction,
// we know that an entry is in the cache with this engine name.
if (addonPkgInfo.isForBundleHost()) {
let cacheEntry = this._getBundleHostCacheEntry(addonPkgInfo);
if (cacheEntry[TARGET_INSTANCE]) {
logger.debug(`About to construct BR PROXY to cache entry for addon at: ${addonPkgInfo.realPath}`);
this.numProxies++;
return getAddonProxy(cacheEntry, parent);
} else {
// create an instance, put it in the pre-existing cache entry, then
// return it (as the first instance of the lazy engine.)
logger.debug(`About to fill in BR EXISTING cache entry for addon at: ${addonPkgInfo.realPath}`);
this.numAddonInstances++;
let addon = addonPkgInfo.constructAddonInstance(parent, this.project);
cacheEntry[TARGET_INSTANCE] = addon; // cache BEFORE initializing child addons
addonPkgInfo.initChildAddons(addon);
return addon;
}
}
// We know now we're asking for a 'regular' (non-bundle-host) addon instance.
let bundleHost = this.findBundleHost(parent, addonPkgInfo);
// if the bundle host "owns" the new addon instance
// * Do we already have an instance of the addon cached?
// * If so, make a proxy for it.
// * If not, make a new instance of the addon and cache it in the
// bundle host's addon cache.
// If not, it means the bundle host is a lazy engine but the LCA host also uses
// the addon and deduping is enabled
// * If the LCA host already has a cached entry, return a proxy to that
// * If it does not, create a 'blank' cache entry and return a proxy to that.
// When the addon is encountered later when processing the LCA host's addons,
// fill in the instance.
if (this.bundleHostOwnsInstance(bundleHost, addonPkgInfo)) {
let bundleHostCacheEntry = this._getBundleHostCacheEntry(bundleHost._packageInfo);
let addonInstanceCache = bundleHostCacheEntry.addonInstanceCache;
let addonCacheEntry = addonInstanceCache.get(addonPkgInfo.realPath);
let addonInstance;
if (addonCacheEntry) {
if (addonCacheEntry[TARGET_INSTANCE]) {
logger.debug(`About to construct REGULAR ADDON PROXY for addon at: ${addonPkgInfo.realPath}`);
this.numProxies++;
return getAddonProxy(addonCacheEntry, parent);
} else {
// the cache entry was created 'empty' by an earlier call, indicating
// an addon that is used in a lazy engine but also used by its LCA host,
// and we're now creating the instance for the LCA host.
// Fill in the entry and return the new instance.
logger.debug(`About to fill in REGULAR ADDON EXISTING cache entry for addon at: ${addonPkgInfo.realPath}`);
this.numAddonInstances++;
addonInstance = addonPkgInfo.constructAddonInstance(parent, this.project);
addonCacheEntry[TARGET_INSTANCE] = addonInstance; // cache BEFORE initializing child addons
addonPkgInfo.initChildAddons(addonInstance);
return addonInstance;
}
}
// There is no entry for this addon in the bundleHost's addon cache. Create a new
// instance, cache it in the addon cache, and return it.
logger.debug(`About to construct REGULAR ADDON NEW cache entry for addon at: ${addonPkgInfo.realPath}`);
this.numAddonInstances++;
addonInstance = addonPkgInfo.constructAddonInstance(parent, this.project);
addonCacheEntry = this.createAddonCacheEntry(addonInstance, addonPkgInfo.realPath);
addonInstanceCache.set(addonPkgInfo.realPath, addonCacheEntry); // cache BEFORE initializing child addons
addonPkgInfo.initChildAddons(addonInstance);
return addonInstance;
} else {
// The bundleHost is not the project but the some ancestor bundles the addon and
// deduping is enabled, so the cache entry needs to go in the bundle owner's cache.
// Get/create an empty cache entry and return a proxy to it. The bundle owner will
// set the instance later (see above).
let bundleHostCacheEntry = this._getBundleHostCacheEntry(
this.findBundleOwner(bundleHost._packageInfo, addonPkgInfo)
);
let addonCacheEntry = bundleHostCacheEntry.addonInstanceCache.get(addonPkgInfo.realPath);
if (!addonCacheEntry) {
logger.debug(`About to construct REGULAR ADDON EMPTY cache entry for addon at: ${addonPkgInfo.realPath}`);
addonCacheEntry = this.createAddonCacheEntry(null, addonPkgInfo.realPath);
bundleHostCacheEntry.addonInstanceCache.set(addonPkgInfo.realPath, addonCacheEntry);
}
logger.debug(`About to construct REGULAR ADDON PROXY for EMPTY addon at: ${addonPkgInfo.realPath}`);
this.numProxies++;
return getAddonProxy(addonCacheEntry, parent);
}
}
getPathsToAddonsOptedIn() {
const addonSet = new Set();
for (const [, { addonInstanceCache }] of this.bundleHostCache) {
Array.from(addonInstanceCache.keys()).forEach((realPath) => {
addonSet.add(realPath);
});
}
return Array.from(addonSet);
}
_getBundleHostCacheEntry(pkgInfo) {
let cacheEntry = this.bundleHostCache.get(pkgInfo);
if (!cacheEntry) {
cacheEntry = this.createBundleHostCacheEntry(pkgInfo);
this.bundleHostCache.set(pkgInfo, cacheEntry);
}
return cacheEntry;
}
// Support for per-bundle addon caching is GLOBAL opt OUT (unless you explicitly set
// EMBER_CLI_ADDON_INSTANCE_CACHING to false, it will be enabled.) If you opt out, that
// overrides setting `allowCachingPerBundle` for any particular addon type to true.
// To help make testing easier, we'll expose the setting as a function so it can be
// called multiple times and evaluate each time.
static isEnabled() {
return process.env.EMBER_CLI_ADDON_INSTANCE_CACHING !== 'false';
}
}
module.exports = PerBundleAddonCache;