@kubb/core
Version:
Core functionality for Kubb's plugin-based code generation system, providing the foundation for transforming OpenAPI specifications.
714 lines (600 loc) • 20 kB
text/typescript
import { FileManager } from './FileManager.ts'
import { isPromiseRejectedResult } from './PromiseManager.ts'
import { PromiseManager } from './PromiseManager.ts'
import { ValidationPluginError } from './errors.ts'
import { pluginCore } from './plugin.ts'
import { transformReservedWord } from './transformers/transformReservedWord.ts'
import { EventEmitter } from './utils/EventEmitter.ts'
import { setUniqueName } from './utils/uniqueName.ts'
import type { KubbFile } from './fs/index.ts'
import type { Logger } from './logger.ts'
import type { PluginCore } from './plugin.ts'
import { trim } from './transformers/trim.ts'
import type {
Config,
GetPluginFactoryOptions,
Plugin,
PluginFactoryOptions,
PluginLifecycle,
PluginLifecycleHooks,
PluginParameter,
PluginWithLifeCycle,
ResolveNameParams,
ResolvePathParams,
UserPlugin,
UserPluginWithLifeCycle,
} from './types.ts'
type RequiredPluginLifecycle = Required<PluginLifecycle>
type Strategy = 'hookFirst' | 'hookForPlugin' | 'hookParallel' | 'hookSeq'
type Executer<H extends PluginLifecycleHooks = PluginLifecycleHooks> = {
message: string
strategy: Strategy
hookName: H
plugin: Plugin
parameters?: unknown[] | undefined
output?: unknown
}
type ParseResult<H extends PluginLifecycleHooks> = RequiredPluginLifecycle[H]
type SafeParseResult<H extends PluginLifecycleHooks, Result = ReturnType<ParseResult<H>>> = {
result: Result
plugin: Plugin
}
// inspired by: https://github.com/rollup/rollup/blob/master/src/utils/PluginDriver.ts#
type Options = {
logger: Logger
/**
* @default Number.POSITIVE_INFINITY
*/
concurrency?: number
}
type Events = {
executing: [executer: Executer]
executed: [executer: Executer]
error: [error: Error]
}
type GetFileProps<TOptions = object> = {
name: string
mode?: KubbFile.Mode
extname: KubbFile.Extname
pluginKey: Plugin['key']
options?: TOptions
}
export class PluginManager {
readonly plugins = new Set<Plugin<GetPluginFactoryOptions<any>>>()
readonly fileManager: FileManager
readonly events: EventEmitter<Events> = new EventEmitter()
readonly config: Config
readonly executed: Array<Executer> = []
readonly logger: Logger
readonly options: Options
readonly #core: Plugin<PluginCore>
readonly #usedPluginNames: Record<string, number> = {}
readonly #promiseManager: PromiseManager
constructor(config: Config, options: Options) {
this.config = config
this.options = options
this.logger = options.logger
this.fileManager = new FileManager()
this.#promiseManager = new PromiseManager({
nullCheck: (state: SafeParseResult<'resolveName'> | null) => !!state?.result,
})
const core = pluginCore({
config,
logger: this.logger,
pluginManager: this,
fileManager: this.fileManager,
resolvePath: this.resolvePath.bind(this),
resolveName: this.resolveName.bind(this),
getPlugins: this.#getSortedPlugins.bind(this),
})
// call core.context.call with empty context so we can transform `context()` to `context: {}`
this.#core = this.#parse(core as unknown as UserPlugin, this as any, core.context.call(null as any)) as Plugin<PluginCore>
;[this.#core, ...(config.plugins || [])].forEach((plugin) => {
const parsedPlugin = this.#parse(plugin as UserPlugin, this, this.#core.context)
this.plugins.add(parsedPlugin)
})
return this
}
getFile<TOptions = object>({ name, mode, extname, pluginKey, options }: GetFileProps<TOptions>): KubbFile.File<{ pluginKey: Plugin['key'] }> {
const baseName = `${name}${extname}` as const
const path = this.resolvePath({ baseName, mode, pluginKey, options })
if (!path) {
throw new Error(`Filepath should be defined for resolvedName "${name}" and pluginKey [${JSON.stringify(pluginKey)}]`)
}
return {
path,
baseName,
meta: {
pluginKey,
},
sources: [],
}
}
resolvePath = <TOptions = object>(params: ResolvePathParams<TOptions>): KubbFile.OptionalPath => {
if (params.pluginKey) {
const paths = this.hookForPluginSync({
pluginKey: params.pluginKey,
hookName: 'resolvePath',
parameters: [params.baseName, params.mode, params.options as object],
message: `Resolving path '${params.baseName}'`,
})
if (paths && paths?.length > 1) {
this.logger.emit('debug', {
date: new Date(),
logs: [
`Cannot return a path where the 'pluginKey' ${
params.pluginKey ? JSON.stringify(params.pluginKey) : '"'
} is not unique enough\n\nPaths: ${JSON.stringify(paths, undefined, 2)}\n\nFalling back on the first item.\n`,
],
})
}
return paths?.at(0)
}
return this.hookFirstSync({
hookName: 'resolvePath',
parameters: [params.baseName, params.mode, params.options as object],
message: `Resolving path '${params.baseName}'`,
}).result
}
//TODO refactor by using the order of plugins and the cache of the fileManager instead of guessing and recreating the name/path
resolveName = (params: ResolveNameParams): string => {
if (params.pluginKey) {
const names = this.hookForPluginSync({
pluginKey: params.pluginKey,
hookName: 'resolveName',
parameters: [trim(params.name), params.type],
message: `Resolving name '${params.name}' and type '${params.type}'`,
})
if (names && names?.length > 1) {
this.logger.emit('debug', {
date: new Date(),
logs: [
`Cannot return a name where the 'pluginKey' ${
params.pluginKey ? JSON.stringify(params.pluginKey) : '"'
} is not unique enough\n\nNames: ${JSON.stringify(names, undefined, 2)}\n\nFalling back on the first item.\n`,
],
})
}
return transformReservedWord(names?.at(0) || params.name)
}
const name = this.hookFirstSync({
hookName: 'resolveName',
parameters: [trim(params.name), params.type],
message: `Resolving name '${params.name}' and type '${params.type}'`,
}).result
return transformReservedWord(name)
}
/**
* Instead of calling `pluginManager.events.on` you can use `pluginManager.on`. This one also has better types.
*/
on<TEventName extends keyof Events & string>(eventName: TEventName, handler: (...eventArg: Events[TEventName]) => void): void {
this.events.on(eventName, handler as any)
}
/**
* Run a specific hookName for plugin x.
*/
async hookForPlugin<H extends PluginLifecycleHooks>({
pluginKey,
hookName,
parameters,
message,
}: {
pluginKey: Plugin['key']
hookName: H
parameters: PluginParameter<H>
message: string
}): Promise<Array<ReturnType<ParseResult<H>> | null>> {
const plugins = this.getPluginsByKey(hookName, pluginKey)
this.logger.emit('progress_start', { id: hookName, size: plugins.length, message: 'Running plugins...' })
const items: Array<ReturnType<ParseResult<H>>> = []
for (const plugin of plugins) {
const result = await this.#execute<H>({
strategy: 'hookFirst',
hookName,
parameters,
plugin,
message,
})
if (result !== undefined && result !== null) {
items.push(result)
}
}
this.logger.emit('progress_stop', { id: hookName })
return items
}
/**
* Run a specific hookName for plugin x.
*/
hookForPluginSync<H extends PluginLifecycleHooks>({
pluginKey,
hookName,
parameters,
message,
}: {
pluginKey: Plugin['key']
hookName: H
parameters: PluginParameter<H>
message: string
}): Array<ReturnType<ParseResult<H>>> | null {
const plugins = this.getPluginsByKey(hookName, pluginKey)
const result = plugins
.map((plugin) => {
return this.#executeSync<H>({
strategy: 'hookFirst',
hookName,
parameters,
plugin,
message,
})
})
.filter(Boolean)
return result
}
/**
* First non-null result stops and will return it's value.
*/
async hookFirst<H extends PluginLifecycleHooks>({
hookName,
parameters,
skipped,
message,
}: {
hookName: H
parameters: PluginParameter<H>
skipped?: ReadonlySet<Plugin> | null
message: string
}): Promise<SafeParseResult<H>> {
const plugins = this.#getSortedPlugins(hookName).filter((plugin) => {
return skipped ? skipped.has(plugin) : true
})
this.logger.emit('progress_start', { id: hookName, size: plugins.length })
const promises = plugins.map((plugin) => {
return async () => {
const value = await this.#execute<H>({
strategy: 'hookFirst',
hookName,
parameters,
plugin,
message,
})
return Promise.resolve({
plugin,
result: value,
} as SafeParseResult<H>)
}
})
const result = await this.#promiseManager.run('first', promises)
this.logger.emit('progress_stop', { id: hookName })
return result
}
/**
* First non-null result stops and will return it's value.
*/
hookFirstSync<H extends PluginLifecycleHooks>({
hookName,
parameters,
skipped,
message,
}: {
hookName: H
parameters: PluginParameter<H>
skipped?: ReadonlySet<Plugin> | null
message: string
}): SafeParseResult<H> {
let parseResult: SafeParseResult<H> = null as unknown as SafeParseResult<H>
const plugins = this.#getSortedPlugins(hookName).filter((plugin) => {
return skipped ? skipped.has(plugin) : true
})
for (const plugin of plugins) {
parseResult = {
result: this.#executeSync<H>({
strategy: 'hookFirst',
hookName,
parameters,
plugin,
message,
}),
plugin,
} as SafeParseResult<H>
if (parseResult?.result != null) {
break
}
}
return parseResult
}
/**
* Run all plugins in parallel(order will be based on `this.plugin` and if `pre` or `post` is set).
*/
async hookParallel<H extends PluginLifecycleHooks, TOuput = void>({
hookName,
parameters,
message,
}: {
hookName: H
parameters?: Parameters<RequiredPluginLifecycle[H]> | undefined
message: string
}): Promise<Awaited<TOuput>[]> {
const plugins = this.#getSortedPlugins(hookName)
this.logger.emit('progress_start', { id: hookName, size: plugins.length })
const promises = plugins.map((plugin) => {
return () =>
this.#execute({
strategy: 'hookParallel',
hookName,
parameters,
plugin,
message,
}) as Promise<TOuput>
})
const results = await this.#promiseManager.run('parallel', promises, { concurrency: this.options.concurrency })
results.forEach((result, index) => {
if (isPromiseRejectedResult<Error>(result)) {
const plugin = this.#getSortedPlugins(hookName)[index]
this.#catcher<H>(result.reason, plugin, hookName)
}
})
this.logger.emit('progress_stop', { id: hookName })
return results.filter((result) => result.status === 'fulfilled').map((result) => (result as PromiseFulfilledResult<Awaited<TOuput>>).value)
}
/**
* Chains plugins
*/
async hookSeq<H extends PluginLifecycleHooks>({
hookName,
parameters,
message,
}: {
hookName: H
parameters?: PluginParameter<H>
message: string
}): Promise<void> {
const plugins = this.#getSortedPlugins(hookName)
this.logger.emit('progress_start', { id: hookName, size: plugins.length })
const promises = plugins.map((plugin) => {
return () =>
this.#execute({
strategy: 'hookSeq',
hookName,
parameters,
plugin,
message,
})
})
await this.#promiseManager.run('seq', promises)
this.logger.emit('progress_stop', { id: hookName })
}
#getSortedPlugins(hookName?: keyof PluginLifecycle): Plugin[] {
const plugins = [...this.plugins].filter((plugin) => plugin.name !== 'core')
if (hookName) {
return plugins.filter((plugin) => hookName in plugin)
}
// TODO add test case for sorting with pre/post
return plugins
.map((plugin) => {
if (plugin.pre) {
const isValid = plugin.pre.every((pluginName) => plugins.find((pluginToFind) => pluginToFind.name === pluginName))
if (!isValid) {
throw new ValidationPluginError(`This plugin has a pre set that is not valid(${JSON.stringify(plugin.pre, undefined, 2)})`)
}
}
return plugin
})
.sort((a, b) => {
if (b.pre?.includes(a.name)) {
return 1
}
if (b.post?.includes(a.name)) {
return -1
}
return 0
})
}
getPluginByKey(pluginKey: Plugin['key']): Plugin | undefined {
const plugins = [...this.plugins]
const [searchPluginName] = pluginKey
return plugins.find((item) => {
const [name] = item.key
return name === searchPluginName
})
}
getPluginsByKey(hookName: keyof PluginWithLifeCycle, pluginKey: Plugin['key']): Plugin[] {
const plugins = [...this.plugins]
const [searchPluginName, searchIdentifier] = pluginKey
const pluginByPluginName = plugins
.filter((plugin) => hookName in plugin)
.filter((item) => {
const [name, identifier] = item.key
const identifierCheck = identifier?.toString() === searchIdentifier?.toString()
const nameCheck = name === searchPluginName
if (searchIdentifier) {
return identifierCheck && nameCheck
}
return nameCheck
})
if (!pluginByPluginName?.length) {
// fallback on the core plugin when there is no match
const corePlugin = plugins.find((plugin) => plugin.name === 'core' && hookName in plugin)
if (corePlugin) {
this.logger.emit('debug', {
date: new Date(),
logs: [`No hook '${hookName}' for pluginKey '${JSON.stringify(pluginKey)}' found, falling back on the '@kubb/core' plugin`],
})
} else {
this.logger.emit('debug', {
date: new Date(),
logs: [`No hook '${hookName}' for pluginKey '${JSON.stringify(pluginKey)}' found, no fallback found in the '@kubb/core' plugin`],
})
}
return corePlugin ? [corePlugin] : []
}
return pluginByPluginName
}
#addExecutedToCallStack(executer: Executer | undefined) {
if (executer) {
this.events.emit('executed', executer)
this.executed.push(executer)
this.logger.emit('progressed', { id: executer.hookName, message: `${executer.plugin.name}: ${executer.message}` })
}
}
/**
* Run an async plugin hook and return the result.
* @param hookName Name of the plugin hook. Must be either in `PluginHooks` or `OutputPluginValueHooks`.
* @param args Arguments passed to the plugin hook.
* @param plugin The actual pluginObject to run.
*/
// Implementation signature
#execute<H extends PluginLifecycleHooks>({
strategy,
hookName,
parameters,
plugin,
message,
}: {
strategy: Strategy
hookName: H
parameters: unknown[] | undefined
plugin: PluginWithLifeCycle
message: string
}): Promise<ReturnType<ParseResult<H>> | null> | null {
const hook = plugin[hookName]
let output: unknown
if (!hook) {
return null
}
this.events.emit('executing', { strategy, hookName, parameters, plugin, message })
const task = (async () => {
try {
if (typeof hook === 'function') {
const result = await Promise.resolve((hook as Function).apply({ ...this.#core.context, plugin }, parameters))
output = result
this.#addExecutedToCallStack({
parameters,
output,
strategy,
hookName,
plugin,
message,
})
return result
}
output = hook
this.#addExecutedToCallStack({
parameters,
output,
strategy,
hookName,
plugin,
message,
})
return hook
} catch (e) {
this.#catcher<H>(e as Error, plugin, hookName)
return null
}
})()
return task
}
/**
* Run a sync plugin hook and return the result.
* @param hookName Name of the plugin hook. Must be in `PluginHooks`.
* @param args Arguments passed to the plugin hook.
* @param plugin The acutal plugin
* @param replaceContext When passed, the plugin context can be overridden.
*/
#executeSync<H extends PluginLifecycleHooks>({
strategy,
hookName,
parameters,
plugin,
message,
}: {
strategy: Strategy
hookName: H
parameters: PluginParameter<H>
plugin: PluginWithLifeCycle
message: string
}): ReturnType<ParseResult<H>> | null {
const hook = plugin[hookName]
let output: unknown
if (!hook) {
return null
}
this.events.emit('executing', { strategy, hookName, parameters, plugin, message })
try {
if (typeof hook === 'function') {
const fn = (hook as Function).apply({ ...this.#core.context, plugin }, parameters) as ReturnType<ParseResult<H>>
output = fn
this.#addExecutedToCallStack({
parameters,
output,
strategy,
hookName,
plugin,
message,
})
return fn
}
output = hook
this.#addExecutedToCallStack({
parameters,
output,
strategy,
hookName,
plugin,
message,
})
return hook
} catch (e) {
this.#catcher<H>(e as Error, plugin, hookName)
return null
}
}
#catcher<H extends PluginLifecycleHooks>(cause: Error, plugin?: Plugin, hookName?: H) {
const text = `${cause.message} (plugin: ${plugin?.name || 'unknown'}, hook: ${hookName || 'unknown'})`
this.logger.emit('error', text, cause)
this.events.emit('error', cause)
}
#parse<TPlugin extends UserPluginWithLifeCycle>(
plugin: TPlugin,
pluginManager: PluginManager,
context: PluginCore['context'] | undefined,
): Plugin<GetPluginFactoryOptions<TPlugin>> {
const usedPluginNames = pluginManager.#usedPluginNames
setUniqueName(plugin.name, usedPluginNames)
const key = [plugin.name, usedPluginNames[plugin.name]].filter(Boolean) as [typeof plugin.name, string]
if (plugin.context && typeof plugin.context === 'function') {
return {
...plugin,
key,
context: (plugin.context as Function).call(context) as typeof plugin.context,
} as unknown as Plugin<GetPluginFactoryOptions<TPlugin>>
}
return {
...plugin,
key,
} as unknown as Plugin<GetPluginFactoryOptions<TPlugin>>
}
static getDependedPlugins<
T1 extends PluginFactoryOptions,
T2 extends PluginFactoryOptions = never,
T3 extends PluginFactoryOptions = never,
TOutput = T3 extends never ? (T2 extends never ? [T1: Plugin<T1>] : [T1: Plugin<T1>, T2: Plugin<T2>]) : [T1: Plugin<T1>, T2: Plugin<T2>, T3: Plugin<T3>],
>(plugins: Array<Plugin>, dependedPluginNames: string | string[]): TOutput {
let pluginNames: string[] = []
if (typeof dependedPluginNames === 'string') {
pluginNames = [dependedPluginNames]
} else {
pluginNames = dependedPluginNames
}
return pluginNames.map((pluginName) => {
const plugin = plugins.find((plugin) => plugin.name === pluginName)
if (!plugin) {
throw new ValidationPluginError(`This plugin depends on the ${pluginName} plugin.`)
}
return plugin
}) as TOutput
}
static get hooks() {
return ['buildStart', 'resolvePath', 'resolveName', 'buildEnd'] as const
}
}