@roots/bud-extensions
Version:
bud.js core module
521 lines (452 loc) • 13.6 kB
text/typescript
import type {Bud, Modules} from '@roots/bud-framework'
import type {ApplyPlugin} from '@roots/bud-framework/extension'
import type {
Extensions as BudExtensions,
LifecycleMethods,
} from '@roots/bud-framework/services/extensions'
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 implements BudExtensions {
/**
* Resolved options
*/
public options: Container<{
allowlist: Array<string>
denylist: Array<string>
discover: boolean
}>
/**
* Registered extensions
*/
public repository: Modules
/**
* 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
*/
public unresolvable: Set<string>
/**
*
* @param bud Class constructor
*/
public constructor(bud: () => Bud) {
super(bud)
this.options = new Container({
allowlist: [],
denylist: [],
discover: true,
})
this.repository = {} as Modules
this.unresolvable = new Set()
}
/**
* Add a {@link Extension} to the extensions repository
*/
public async add(extension: any): Promise<void> {
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}
*/
public async configBefore?(bud: 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: string) =>
await this.import(signifier, true),
),
)
await this.runAll(`register`)
await this.runAll(`boot`)
}
/**
* {@link BudExtensions.buildBefore}
*/
public override async buildAfter?(bud: Bud) {
await this.runAll(`buildAfter`)
}
/**
* {@link BudExtensions.buildBefore}
*/
public override async buildBefore?(bud: Bud) {
await this.runAll(`buildBefore`)
}
/**
* {@link BudExtensions.compilerDone}
*/
public override async compilerDone?(bud, stats) {
await this.runAll(`compilerDone`)
}
/**
* {@link BudExtensions.configAfter}
*/
public override async configAfter?(bud: Bud) {
await this.runAll(`configAfter`)
}
/**
* Get extension
*/
public get<K extends `${keyof Modules & string}`>(key: K): Modules[K] {
return this.repository[key]
}
/**
* Has extension
*/
public has(key: string): key is `${keyof Modules & string}` {
return this.repository[key] ? true : false
}
/**
* Import an extension
*/
public async import(
signifier: string,
required: boolean = true,
): Promise<Extension | null> {
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) as Extension
}
const extension: 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}
*/
public async instantiate(
source:
| {apply: (...args: any[]) => any}
| Extension
| (new (...args: any[]) => Extension),
): Promise<Extension> {
if (source instanceof Extension) return source
if (isConstructor(source)) {
const instance = new source(this.app)
instance.label =
instance.label ??
instance.constructor.name ??
(randomUUID() as keyof Modules & string)
return instance
}
if (typeof source === `function`) {
const instance = source(this.app)
if (!instance.label)
instance.label =
instance.constructor.name ??
(randomUUID() as keyof Modules & string)
return instance
}
if (typeof source.apply === `function`) {
return source as Extension
}
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() as keyof Modules & string
return instance
}
return new source(this.app)
}
/**
* {@link BudExtensions.isAllowed}
*/
public isAllowed(signifier: string): boolean {
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
*/
public async make(): Promise<ApplyPlugin[]> {
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: Array<[string, ApplyPlugin]>): Array<ApplyPlugin> =>
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
*/
public remove<K extends `${keyof Modules & string}`>(key: K): this {
delete this.repository[key]
return this
}
/**
* Run an extension lifecycle method
*
* @remarks
* - `register`
* - `boot`
* - `buildBefore`
* - `make`
*/
public async run(
extension: Modules[`${keyof Modules & string}`],
methodName: LifecycleMethods,
): Promise<this> {
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
*/
public async runAll(methodName: LifecycleMethods): Promise<any> {
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.
*/
public async runDependencies<K extends `${keyof Modules & string}`>(
extension: K | Modules[K],
methodName: LifecycleMethods,
): Promise<void> {
let instance: Modules[K]
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: string) => !this.unresolvable.has(signifier))
.reduce(async (promised, signifier: any) => {
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: string) => !this.unresolvable.has(signifier))
.reduce(async (promised, signifier: any) => {
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
*/
public set(value: Extension): this {
const key = (value.label ?? randomUUID()) as any
Object.assign(this.repository, {[key]: value})
this.logger.info(`Set extension:`, key, `=>`, value)
return this
}
}
export {Extensions as default}