@roots/bud-extensions
Version:
bud.js core module
433 lines (432 loc) • 14.5 kB
JavaScript
import { __decorate } from "tslib";
import { randomUUID } from 'node:crypto';
import { handleManifestSchemaWarning } from '@roots/bud-extensions/helpers/handleManifestSchemaWarning';
import { isConstructor } from '@roots/bud-extensions/helpers/isConstructor';
import { Extension } from '@roots/bud-framework/extension';
import { Service } from '@roots/bud-framework/service';
import { bind } from '@roots/bud-support/decorators/bind';
import { BudError } from '@roots/bud-support/errors';
import isFunction from '@roots/bud-support/isFunction';
import isUndefined from '@roots/bud-support/isUndefined';
import Container from '@roots/container';
/**
* Extensions Service
*/
class Extensions extends Service {
/**
* Resolved options
*/
options;
/**
* Registered extensions
*/
repository;
/**
* Modules on which an import attempt was made and failed
*
* @remarks
* This doesn't mean an error, per se. This should only
* be used in the context of trying to import `optionalDependencies`
* of a given extension module.
*
* @public
*/
unresolvable;
/**
*
* @param bud Class constructor
*/
constructor(bud) {
super(bud);
this.options = new Container({
allowlist: [],
denylist: [],
discover: true,
});
this.repository = {};
this.unresolvable = new Set();
}
/**
* Add a {@link Extension} to the extensions repository
*/
async add(extension) {
const arrayed = Array.isArray(extension) ? extension : [extension];
await arrayed.reduce(async (promised, item) => {
await promised;
const source = typeof item === `string`
? await this.app.module
.import(item.startsWith(`./`) ? this.app.path(item) : item, import.meta.url)
.then(pkg => pkg.default ?? pkg)
: item;
const extension = await this.instantiate(source);
this.set(extension);
this.logger.log(`Added`, extension.label);
await this.run(extension, `register`);
await this.run(extension, `boot`);
}, Promise.resolve());
}
/**
* {@link BudExtensions.bootstrap}
*/
async configBefore(bud) {
handleManifestSchemaWarning.bind(this)(bud);
const { extensions, manifest } = bud.context;
if (manifest?.bud?.extensions) {
const { allowlist, denylist } = manifest.bud.extensions;
const discover = manifest.bud.extensions.discover ??
manifest.bud.extensions.discovery;
if (!isUndefined(discover))
this.options.set(`discover`, discover);
if (!isUndefined(allowlist))
this.options.merge(`allowlist`, allowlist);
if (!isUndefined(denylist))
this.options.merge(`denylist`, denylist);
}
if (manifest?.bud?.[this.app.label]?.extensions) {
const { allowlist, denylist } = manifest.bud[this.app.label].extensions;
const discover = manifest.bud[this.app.label].extensions.discover ??
manifest.bud[this.app.label].extensions.discovery;
if (!isUndefined(discover))
this.options.set(`discover`, discover);
if (!isUndefined(allowlist))
this.options.set(`allowlist`, (allowed = []) => [
...allowed,
...allowlist,
]);
if (!isUndefined(denylist))
this.options.set(`denylist`, (denied = []) => [
...denied,
...allowlist,
]);
}
if (!isUndefined(extensions?.builtIn) &&
Array.isArray(extensions.builtIn))
await Promise.all(extensions.builtIn
.filter(Boolean)
.map(async (signifier) => await this.import(signifier, true)));
if (!isUndefined(bud.context.discover)) {
this.options.set(`discover`, bud.context.discover);
}
if (this.options.is(`discover`, true) &&
this.options.isEmpty(`allowlist`) &&
!isUndefined(extensions?.discovered) &&
Array.isArray(extensions.discovered))
await Promise.all(extensions.discovered
.filter(Boolean)
.filter(this.isAllowed)
.map(async (signifier) => await this.import(signifier, true)));
else if (this.options.isNotEmpty(`allowlist`))
await Promise.all(this.options
.get(`allowlist`)
.filter(Boolean)
.filter(this.isAllowed)
.map(async (signifier) => await this.import(signifier, true)));
await this.runAll(`register`);
await this.runAll(`boot`);
}
/**
* {@link BudExtensions.buildBefore}
*/
async buildAfter(bud) {
await this.runAll(`buildAfter`);
}
/**
* {@link BudExtensions.buildBefore}
*/
async buildBefore(bud) {
await this.runAll(`buildBefore`);
}
/**
* {@link BudExtensions.compilerDone}
*/
async compilerDone(bud, stats) {
await this.runAll(`compilerDone`);
}
/**
* {@link BudExtensions.configAfter}
*/
async configAfter(bud) {
await this.runAll(`configAfter`);
}
/**
* Get extension
*/
get(key) {
return this.repository[key];
}
/**
* Has extension
*/
has(key) {
return this.repository[key] ? true : false;
}
/**
* Import an extension
*/
async import(signifier, required = true) {
if (required && this.unresolvable.has(signifier))
throw new Error(`Extension ${signifier} is not importable`);
if (signifier.startsWith(`.`)) {
const originalSignifier = signifier;
signifier = this.app.path(signifier);
this.logger.info(`Interpolated project relative path:`, signifier, `=>`, originalSignifier);
}
if (this.has(signifier)) {
this.logger.info(signifier, `already imported`);
return this.get(signifier);
}
const extension = await this.app.module
.import(signifier, import.meta.url, { reject: false })
.catch(async (error) => {
this.unresolvable.add(signifier);
if (required)
await this.app.module.removeCachedResolutions();
});
if (!extension && required) {
throw new BudError(`Extension ${signifier} not found but required`);
}
if (!extension) {
this.logger.info(`Extension ${signifier} not found`);
return null;
}
const instance = await this.instantiate(extension);
if (instance.dependsOn)
await Promise.all(Array.from(instance.dependsOn)
.filter(dependency => !this.has(dependency))
.map(async (dependency) => await this.import(dependency, true)));
if (this.options.is(`discover`, true) && instance.dependsOnOptional)
await Promise.all(Array.from(instance.dependsOnOptional)
.filter(this.isAllowed)
.filter(dep => !this.unresolvable.has(dep))
.filter(dep => !this.has(dep))
.map(async (dep) => {
await this.import(dep, false);
if (!this.has(dep))
this.unresolvable.add(dep);
}));
instance && this.set(instance);
return instance;
}
/**
* {@link BudExtensions.instantiate}
*/
async instantiate(source) {
if (source instanceof Extension)
return source;
if (isConstructor(source)) {
const instance = new source(this.app);
instance.label =
instance.label ??
instance.constructor.name ??
randomUUID();
return instance;
}
if (typeof source === `function`) {
const instance = source(this.app);
if (!instance.label)
instance.label =
instance.constructor.name ??
randomUUID();
return instance;
}
if (typeof source.apply === `function`) {
return source;
}
if (!isConstructor(source)) {
const instance = new Extension(this.app);
Object.entries(source).forEach(([k, v]) => {
if (k === `options`) {
instance.setOptions(v);
return;
}
instance[k] = v;
});
if (!instance.label)
instance.label = randomUUID();
return instance;
}
return new source(this.app);
}
/**
* {@link BudExtensions.isAllowed}
*/
isAllowed(signifier) {
return ((this.options.isEmpty(`denylist`) ||
!this.options.get(`denylist`).includes(signifier)) &&
(this.options.isEmpty(`allowlist`) ||
this.options.get(`allowlist`).includes(signifier)));
}
/**
* Returns an array of plugin instances which have been registered to the
* container and are set to be used in the compilation
*
* @returns An array of plugin instances
*/
async make() {
const results = await Promise.all(Object.entries(this.repository ?? {}).map(async ([label, extension]) => {
if (!extension)
return null;
if (extension instanceof Extension) {
return [label, await extension.execute(`make`)];
}
if (`make` in extension)
return [
label,
await extension.make(this.app, extension.options),
];
if (`apply` in extension) {
return [label, extension];
}
})).then((results) => results
.filter(Boolean)
.filter(([_label, result]) => result)
.map(([label, result]) => {
this.logger
.log(`Defined:`, `compiler plugin:`, label)
.info(result);
return result;
}));
this.logger.log(`Using`, results.length, `compiler plugins`);
return results;
}
/**
* Remove extension
*/
remove(key) {
delete this.repository[key];
return this;
}
/**
* Run an extension lifecycle method
*
* @remarks
* - `register`
* - `boot`
* - `buildBefore`
* - `make`
*/
async run(extension, methodName) {
try {
await this.runDependencies(extension, methodName);
if (`execute` in extension && isFunction(extension.execute))
await extension.execute(methodName);
return this;
}
catch (error) {
throw error;
}
}
/**
* Execute a extension lifecycle method on all registered extensions
*/
async runAll(methodName) {
return await Object.values(this.repository).reduce(async (promised, extension) => {
await promised;
await this.run(extension, methodName);
}, Promise.resolve());
}
/**
* Run a lifecycle method for an extension's dependencies
*
* @remarks
* Called from {@link Extension.run}. Ensures a method is run for an
* extension's dependencies before it is run for the extension itself.
*/
async runDependencies(extension, methodName) {
let instance;
if (typeof extension === `string` && this.has(extension)) {
instance = this.get(extension);
}
else {
instance = extension;
}
if (`dependsOn` in instance) {
await Array.from(instance.dependsOn)
.filter(this.isAllowed)
.filter((signifier) => !this.unresolvable.has(signifier))
.reduce(async (promised, signifier) => {
await promised;
if (!this.has(signifier))
await this.import(signifier, true);
await this.run(this.get(signifier), methodName);
}, Promise.resolve());
}
if (!this.options.isTrue(`discover`) ||
!(`dependsOnOptional` in instance))
return;
await Array.from(instance.dependsOnOptional)
.filter(this.isAllowed)
.filter((signifier) => !this.unresolvable.has(signifier))
.reduce(async (promised, signifier) => {
await promised;
if (!this.has(signifier))
await this.import(signifier, false);
if (!this.has(signifier)) {
this.unresolvable.add(signifier);
return;
}
await this.run(this.get(signifier), methodName);
}, Promise.resolve());
}
/**
* Set extension
*/
set(value) {
const key = (value.label ?? randomUUID());
Object.assign(this.repository, { [key]: value });
this.logger.info(`Set extension:`, key, `=>`, value);
return this;
}
}
__decorate([
bind
], Extensions.prototype, "add", null);
__decorate([
bind
], Extensions.prototype, "configBefore", null);
__decorate([
bind
], Extensions.prototype, "buildAfter", null);
__decorate([
bind
], Extensions.prototype, "buildBefore", null);
__decorate([
bind
], Extensions.prototype, "compilerDone", null);
__decorate([
bind
], Extensions.prototype, "configAfter", null);
__decorate([
bind
], Extensions.prototype, "get", null);
__decorate([
bind
], Extensions.prototype, "has", null);
__decorate([
bind
], Extensions.prototype, "import", null);
__decorate([
bind
], Extensions.prototype, "instantiate", null);
__decorate([
bind
], Extensions.prototype, "isAllowed", null);
__decorate([
bind
], Extensions.prototype, "make", null);
__decorate([
bind
], Extensions.prototype, "remove", null);
__decorate([
bind
], Extensions.prototype, "run", null);
__decorate([
bind
], Extensions.prototype, "runAll", null);
__decorate([
bind
], Extensions.prototype, "runDependencies", null);
__decorate([
bind
], Extensions.prototype, "set", null);
export { Extensions as default };