UNPKG

@roots/bud-extensions

Version:

bud.js core module

433 lines (432 loc) • 14.5 kB
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 };