@kubb/core 5.0.0-alpha.9 → 5.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +24 -21
  2. package/dist/PluginDriver-BXibeQk-.cjs +1036 -0
  3. package/dist/PluginDriver-BXibeQk-.cjs.map +1 -0
  4. package/dist/PluginDriver-DV3p2Hky.js +945 -0
  5. package/dist/PluginDriver-DV3p2Hky.js.map +1 -0
  6. package/dist/index.cjs +752 -1641
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.ts +271 -225
  9. package/dist/index.js +736 -1609
  10. package/dist/index.js.map +1 -1
  11. package/dist/mocks.cjs +145 -0
  12. package/dist/mocks.cjs.map +1 -0
  13. package/dist/mocks.d.ts +80 -0
  14. package/dist/mocks.js +140 -0
  15. package/dist/mocks.js.map +1 -0
  16. package/dist/types-CC09VtBt.d.ts +2148 -0
  17. package/package.json +51 -57
  18. package/src/FileManager.ts +115 -0
  19. package/src/FileProcessor.ts +86 -0
  20. package/src/Kubb.ts +207 -131
  21. package/src/PluginDriver.ts +325 -564
  22. package/src/constants.ts +20 -47
  23. package/src/createAdapter.ts +13 -6
  24. package/src/createKubb.ts +574 -0
  25. package/src/createRenderer.ts +57 -0
  26. package/src/createStorage.ts +13 -1
  27. package/src/defineGenerator.ts +77 -124
  28. package/src/defineLogger.ts +4 -2
  29. package/src/defineMiddleware.ts +62 -0
  30. package/src/defineParser.ts +44 -0
  31. package/src/definePlugin.ts +83 -0
  32. package/src/defineResolver.ts +418 -28
  33. package/src/devtools.ts +14 -14
  34. package/src/index.ts +13 -15
  35. package/src/mocks.ts +178 -0
  36. package/src/renderNode.ts +35 -0
  37. package/src/storages/fsStorage.ts +41 -11
  38. package/src/storages/memoryStorage.ts +4 -2
  39. package/src/types.ts +1031 -283
  40. package/src/utils/diagnostics.ts +4 -1
  41. package/src/utils/isInputPath.ts +10 -0
  42. package/src/utils/packageJSON.ts +50 -12
  43. package/dist/PluginDriver-BkFepPdm.d.ts +0 -1054
  44. package/dist/chunk-ByKO4r7w.cjs +0 -38
  45. package/dist/hooks.cjs +0 -103
  46. package/dist/hooks.cjs.map +0 -1
  47. package/dist/hooks.d.ts +0 -77
  48. package/dist/hooks.js +0 -98
  49. package/dist/hooks.js.map +0 -1
  50. package/src/build.ts +0 -418
  51. package/src/config.ts +0 -56
  52. package/src/createPlugin.ts +0 -28
  53. package/src/hooks/index.ts +0 -4
  54. package/src/hooks/useKubb.ts +0 -143
  55. package/src/hooks/useMode.ts +0 -11
  56. package/src/hooks/usePlugin.ts +0 -11
  57. package/src/hooks/usePluginDriver.ts +0 -11
  58. package/src/utils/FunctionParams.ts +0 -155
  59. package/src/utils/TreeNode.ts +0 -215
  60. package/src/utils/executeStrategies.ts +0 -81
  61. package/src/utils/formatters.ts +0 -56
  62. package/src/utils/getBarrelFiles.ts +0 -141
  63. package/src/utils/getConfigs.ts +0 -12
  64. package/src/utils/linters.ts +0 -25
@@ -1,663 +1,424 @@
1
- import { basename, extname, resolve } from 'node:path'
2
- import { performance } from 'node:perf_hooks'
1
+ import { extname, resolve } from 'node:path'
3
2
  import type { AsyncEventEmitter } from '@internals/utils'
4
- import { isPromiseRejectedResult, setUniqueName, transformReservedWord, ValidationPluginError } from '@internals/utils'
5
- import type { RootNode } from '@kubb/ast/types'
6
- import type { Fabric as FabricType, KubbFile } from '@kubb/fabric-core/types'
7
- import { CORE_PLUGIN_NAME, DEFAULT_STUDIO_URL } from './constants.ts'
3
+ import type { FileNode, InputNode, OperationNode, SchemaNode } from '@kubb/ast'
4
+ import { createFile } from '@kubb/ast'
5
+ import { DEFAULT_STUDIO_URL } from './constants.ts'
6
+ import type { Generator } from './defineGenerator.ts'
7
+ import type { Plugin } from './definePlugin.ts'
8
+ import { defineResolver } from './defineResolver.ts'
8
9
  import { openInStudio as openInStudioFn } from './devtools.ts'
10
+ import { FileManager } from './FileManager.ts'
11
+ import { applyHookResult } from './renderNode.ts'
9
12
 
10
13
  import type {
11
14
  Adapter,
12
15
  Config,
13
16
  DevtoolsOptions,
14
- KubbEvents,
15
- Plugin,
16
- PluginContext,
17
+ GeneratorContext,
18
+ KubbHooks,
19
+ KubbPluginSetupContext,
20
+ NormalizedPlugin,
17
21
  PluginFactoryOptions,
18
- PluginLifecycle,
19
- PluginLifecycleHooks,
20
- PluginParameter,
21
- PluginWithLifeCycle,
22
- ResolveNameParams,
23
- ResolvePathParams,
24
- UserPlugin,
22
+ Resolver,
25
23
  } from './types.ts'
26
- import { hookFirst, hookParallel, hookSeq } from './utils/executeStrategies.ts'
27
-
28
- type RequiredPluginLifecycle = Required<PluginLifecycle>
29
-
30
- export type Strategy = 'hookFirst' | 'hookForPlugin' | 'hookParallel' | 'hookSeq'
31
-
32
- type ParseResult<H extends PluginLifecycleHooks> = RequiredPluginLifecycle[H]
33
-
34
- type SafeParseResult<H extends PluginLifecycleHooks, Result = ReturnType<ParseResult<H>>> = {
35
- result: Result
36
- plugin: Plugin
37
- }
38
24
 
39
25
  // inspired by: https://github.com/rollup/rollup/blob/master/src/utils/PluginDriver.ts#
40
26
 
41
27
  type Options = {
42
- fabric: FabricType
43
- events: AsyncEventEmitter<KubbEvents>
44
- /**
45
- * @default Number.POSITIVE_INFINITY
46
- */
47
- concurrency?: number
28
+ hooks: AsyncEventEmitter<KubbHooks>
48
29
  }
49
30
 
50
- export type GetFileOptions<TOptions = object> = {
51
- name: string
52
- mode?: KubbFile.Mode
53
- extname: KubbFile.Extname
54
- pluginName: string
55
- options?: TOptions
31
+ function enforceOrder(enforce: 'pre' | 'post' | undefined): number {
32
+ return enforce === 'pre' ? -1 : enforce === 'post' ? 1 : 0
56
33
  }
57
34
 
58
- export function getMode(fileOrFolder: string | undefined | null): KubbFile.Mode {
59
- if (!fileOrFolder) {
60
- return 'split'
61
- }
62
- return extname(fileOrFolder) ? 'single' : 'split'
63
- }
64
-
65
- const hookFirstNullCheck = (state: unknown) => !!(state as SafeParseResult<'resolveName'> | null)?.result
66
-
67
35
  export class PluginDriver {
68
36
  readonly config: Config
69
37
  readonly options: Options
70
38
 
71
39
  /**
72
- * The universal `@kubb/ast` `RootNode` produced by the adapter, set by
40
+ * Returns `'single'` when `fileOrFolder` has a file extension, `'split'` otherwise.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * PluginDriver.getMode('src/gen/types.ts') // 'single'
45
+ * PluginDriver.getMode('src/gen/types') // 'split'
46
+ * ```
47
+ */
48
+ static getMode(fileOrFolder: string | undefined | null): 'single' | 'split' {
49
+ if (!fileOrFolder) {
50
+ return 'split'
51
+ }
52
+ return extname(fileOrFolder) ? 'single' : 'split'
53
+ }
54
+
55
+ /**
56
+ * The universal `@kubb/ast` `InputNode` produced by the adapter, set by
73
57
  * the build pipeline after the adapter's `parse()` resolves.
74
58
  */
75
- rootNode: RootNode | undefined = undefined
59
+ inputNode: InputNode | undefined = undefined
76
60
  adapter: Adapter | undefined = undefined
77
61
  #studioIsOpen = false
78
62
 
79
- readonly #plugins = new Set<Plugin>()
80
- readonly #usedPluginNames: Record<string, number> = {}
63
+ /**
64
+ * Central file store for all generated files.
65
+ * Plugins should use `this.addFile()` / `this.upsertFile()` (via their context) to
66
+ * add files; this property gives direct read/write access when needed.
67
+ */
68
+ readonly fileManager = new FileManager()
69
+
70
+ readonly plugins = new Map<string, NormalizedPlugin>()
71
+
72
+ /**
73
+ * Tracks which plugins have generators registered via `addGenerator()` (event-based path).
74
+ * Used by the build loop to decide whether to emit generator events for a given plugin.
75
+ */
76
+ readonly #pluginsWithEventGenerators = new Set<string>()
77
+ readonly #resolvers = new Map<string, Resolver>()
78
+ readonly #defaultResolvers = new Map<string, Resolver>()
79
+ readonly #hookListeners = new Map<keyof KubbHooks, Set<(...args: never[]) => void | Promise<void>>>()
81
80
 
82
81
  constructor(config: Config, options: Options) {
83
82
  this.config = config
84
83
  this.options = options
85
- ;[...(config.plugins || [])].forEach((plugin) => {
86
- const parsedPlugin = this.#parse(plugin as UserPlugin)
87
-
88
- this.#plugins.add(parsedPlugin)
89
- })
90
- }
91
-
92
- get events() {
93
- return this.options.events
94
- }
95
-
96
- getContext<TOptions extends PluginFactoryOptions>(plugin: Plugin<TOptions>): PluginContext<TOptions> & Record<string, unknown> {
97
- const plugins = [...this.#plugins]
98
- const driver = this
99
-
100
- const baseContext = {
101
- fabric: this.options.fabric,
102
- config: this.config,
103
- plugin,
104
- events: this.options.events,
105
- driver: this,
106
- mode: getMode(resolve(this.config.root, this.config.output.path)),
107
- addFile: async (...files: Array<KubbFile.File>) => {
108
- await this.options.fabric.addFile(...files)
109
- },
110
- upsertFile: async (...files: Array<KubbFile.File>) => {
111
- await this.options.fabric.upsertFile(...files)
112
- },
113
- get rootNode(): RootNode | undefined {
114
- return driver.rootNode
115
- },
116
- get adapter(): Adapter | undefined {
117
- return driver.adapter
118
- },
119
- openInStudio(options?: DevtoolsOptions) {
120
- if (!driver.config.devtools || driver.#studioIsOpen) {
121
- return
122
- }
123
-
124
- if (typeof driver.config.devtools !== 'object') {
125
- throw new Error('Devtools must be an object')
126
- }
127
-
128
- if (!driver.rootNode || !driver.adapter) {
129
- throw new Error('adapter is not defined, make sure you have set the parser in kubb.config.ts')
84
+ config.plugins
85
+ .map((rawPlugin) => this.#normalizePlugin(rawPlugin as Plugin))
86
+ .filter((plugin) => {
87
+ if (typeof plugin.apply === 'function') {
88
+ return plugin.apply(config)
130
89
  }
131
-
132
- driver.#studioIsOpen = true
133
-
134
- const studioUrl = driver.config.devtools?.studioUrl ?? DEFAULT_STUDIO_URL
135
-
136
- return openInStudioFn(driver.rootNode, studioUrl, options)
137
- },
138
- } as unknown as PluginContext<TOptions>
139
-
140
- const mergedExtras: Record<string, unknown> = {}
141
- for (const p of plugins) {
142
- if (typeof p.inject === 'function') {
143
- const result = (p.inject as (this: PluginContext, context: PluginContext) => unknown).call(
144
- baseContext as unknown as PluginContext,
145
- baseContext as unknown as PluginContext,
146
- )
147
- if (result !== null && typeof result === 'object') {
148
- Object.assign(mergedExtras, result)
149
- }
150
- }
151
- }
152
-
153
- return {
154
- ...baseContext,
155
- ...mergedExtras,
156
- }
90
+ return true
91
+ })
92
+ .sort((a, b) => {
93
+ if (b.dependencies?.includes(a.name)) return -1
94
+ if (a.dependencies?.includes(b.name)) return 1
95
+ // enforce: 'pre' plugins run first, 'post' plugins run last
96
+ return enforceOrder(a.enforce) - enforceOrder(b.enforce)
97
+ })
98
+ .forEach((plugin) => {
99
+ this.plugins.set(plugin.name, plugin)
100
+ })
157
101
  }
158
102
 
159
- get plugins(): Array<Plugin> {
160
- return this.#getSortedPlugins()
103
+ get hooks() {
104
+ return this.options.hooks
161
105
  }
162
106
 
163
- getFile<TOptions = object>({ name, mode, extname, pluginName, options }: GetFileOptions<TOptions>): KubbFile.File<{ pluginName: string }> {
164
- const resolvedName = mode ? (mode === 'single' ? '' : this.resolveName({ name, pluginName, type: 'file' })) : name
165
-
166
- const path = this.resolvePath({
167
- baseName: `${resolvedName}${extname}` as const,
168
- mode,
169
- pluginName,
170
- options,
171
- })
172
-
173
- if (!path) {
174
- throw new Error(`Filepath should be defined for resolvedName "${resolvedName}" and pluginName "${pluginName}"`)
175
- }
176
-
177
- return {
178
- path,
179
- baseName: basename(path) as KubbFile.File['baseName'],
180
- meta: {
181
- pluginName,
182
- },
183
- sources: [],
184
- imports: [],
185
- exports: [],
186
- }
107
+ /**
108
+ * Creates an `NormalizedPlugin` from a hook-style plugin and registers
109
+ * its lifecycle handlers on the `AsyncEventEmitter`.
110
+ */
111
+ #normalizePlugin(hookPlugin: Plugin): NormalizedPlugin {
112
+ const normalizedPlugin = {
113
+ name: hookPlugin.name,
114
+ dependencies: hookPlugin.dependencies,
115
+ enforce: hookPlugin.enforce,
116
+ options: { output: { path: '.' }, exclude: [], override: [] },
117
+ } as unknown as NormalizedPlugin
118
+
119
+ this.registerPluginHooks(hookPlugin, normalizedPlugin)
120
+ return normalizedPlugin
187
121
  }
188
122
 
189
- resolvePath = <TOptions = object>(params: ResolvePathParams<TOptions>): KubbFile.Path => {
190
- const root = resolve(this.config.root, this.config.output.path)
191
- const defaultPath = resolve(root, params.baseName)
192
-
193
- if (params.pluginName) {
194
- const paths = this.hookForPluginSync({
195
- pluginName: params.pluginName,
196
- hookName: 'resolvePath',
197
- parameters: [params.baseName, params.mode, params.options as object],
198
- })
123
+ /**
124
+ * Registers a hook-style plugin's lifecycle handlers on the shared `AsyncEventEmitter`.
125
+ *
126
+ * For `kubb:plugin:setup`, the registered listener wraps the globally emitted context with a
127
+ * plugin-specific one so that `addGenerator`, `setResolver`, `setTransformer`, and
128
+ * `setRenderer` all target the correct `normalizedPlugin` entry in the plugins map.
129
+ *
130
+ * All other hooks are iterated and registered directly as pass-through listeners.
131
+ * Any event key present in the global `KubbHooks` interface can be subscribed to.
132
+ *
133
+ * External tooling can subscribe to any of these events via `hooks.on(...)` to observe
134
+ * the plugin lifecycle without modifying plugin behavior.
135
+ *
136
+ * @internal
137
+ */
138
+ registerPluginHooks(hookPlugin: Plugin, normalizedPlugin: NormalizedPlugin): void {
139
+ const { hooks } = hookPlugin
140
+
141
+ // kubb:plugin:setup gets special treatment: the globally emitted context is wrapped with
142
+ // plugin-specific implementations so that addGenerator / setResolver / etc. target
143
+ // this plugin's normalizedPlugin entry rather than being no-ops.
144
+ if (hooks['kubb:plugin:setup']) {
145
+ const setupHandler = (globalCtx: KubbPluginSetupContext) => {
146
+ const pluginCtx: KubbPluginSetupContext = {
147
+ ...globalCtx,
148
+ options: hookPlugin.options ?? {},
149
+ addGenerator: (gen) => {
150
+ this.registerGenerator(normalizedPlugin.name, gen)
151
+ },
152
+ setResolver: (resolver) => {
153
+ this.setPluginResolver(normalizedPlugin.name, resolver)
154
+ },
155
+ setTransformer: (visitor) => {
156
+ normalizedPlugin.transformer = visitor
157
+ },
158
+ setRenderer: (renderer) => {
159
+ normalizedPlugin.renderer = renderer
160
+ },
161
+ setOptions: (opts) => {
162
+ normalizedPlugin.options = { ...normalizedPlugin.options, ...opts }
163
+ },
164
+ injectFile: (userFileNode) => {
165
+ this.fileManager.add(createFile(userFileNode))
166
+ },
167
+ }
168
+ return hooks['kubb:plugin:setup']!(pluginCtx)
169
+ }
199
170
 
200
- return paths?.at(0) || defaultPath
171
+ this.hooks.on('kubb:plugin:setup', setupHandler)
172
+ this.#trackHookListener('kubb:plugin:setup', setupHandler as (...args: never[]) => void | Promise<void>)
201
173
  }
202
174
 
203
- const firstResult = this.hookFirstSync({
204
- hookName: 'resolvePath',
205
- parameters: [params.baseName, params.mode, params.options as object],
206
- })
207
-
208
- return firstResult?.result || defaultPath
209
- }
210
- //TODO refactor by using the order of plugins and the cache of the fileManager instead of guessing and recreating the name/path
211
- resolveName = (params: ResolveNameParams): string => {
212
- if (params.pluginName) {
213
- const names = this.hookForPluginSync({
214
- pluginName: params.pluginName,
215
- hookName: 'resolveName',
216
- parameters: [params.name.trim(), params.type],
217
- })
218
-
219
- const uniqueNames = new Set(names)
175
+ // All other hooks are registered as direct pass-through listeners on the shared emitter.
176
+ for (const [event, handler] of Object.entries(hooks) as Array<[keyof KubbHooks, ((...args: never[]) => void | Promise<void>) | undefined]>) {
177
+ if (event === 'kubb:plugin:setup' || !handler) continue
220
178
 
221
- return transformReservedWord([...uniqueNames].at(0) || params.name)
179
+ this.hooks.on(event, handler as never)
180
+ this.#trackHookListener(event, handler as (...args: never[]) => void | Promise<void>)
222
181
  }
223
-
224
- const name = this.hookFirstSync({
225
- hookName: 'resolveName',
226
- parameters: [params.name.trim(), params.type],
227
- })?.result
228
-
229
- return transformReservedWord(name ?? params.name)
230
182
  }
231
183
 
232
184
  /**
233
- * Run a specific hookName for plugin x.
185
+ * Emits the `kubb:plugin:setup` event so that all registered hook-style plugin listeners
186
+ * can configure generators, resolvers, transformers and renderers before `buildStart` runs.
187
+ *
188
+ * Call this once from `safeBuild` before the plugin execution loop begins.
234
189
  */
235
- async hookForPlugin<H extends PluginLifecycleHooks>({
236
- pluginName,
237
- hookName,
238
- parameters,
239
- }: {
240
- pluginName: string
241
- hookName: H
242
- parameters: PluginParameter<H>
243
- }): Promise<Array<ReturnType<ParseResult<H>> | null>> {
244
- const plugins = this.getPluginsByName(hookName, pluginName)
245
-
246
- this.events.emit('plugins:hook:progress:start', {
247
- hookName,
248
- plugins,
190
+ async emitSetupHooks(): Promise<void> {
191
+ const noop = () => {}
192
+ await this.hooks.emit('kubb:plugin:setup', {
193
+ config: this.config,
194
+ options: {},
195
+ addGenerator: noop,
196
+ setResolver: noop,
197
+ setTransformer: noop,
198
+ setRenderer: noop,
199
+ setOptions: noop,
200
+ injectFile: noop,
201
+ updateConfig: noop,
249
202
  })
250
-
251
- const items: Array<ReturnType<ParseResult<H>>> = []
252
-
253
- for (const plugin of plugins) {
254
- const result = await this.#execute<H>({
255
- strategy: 'hookFirst',
256
- hookName,
257
- parameters,
258
- plugin,
259
- })
260
-
261
- if (result !== undefined && result !== null) {
262
- items.push(result)
263
- }
264
- }
265
-
266
- this.events.emit('plugins:hook:progress:end', { hookName })
267
-
268
- return items
269
- }
270
- /**
271
- * Run a specific hookName for plugin x.
272
- */
273
-
274
- hookForPluginSync<H extends PluginLifecycleHooks>({
275
- pluginName,
276
- hookName,
277
- parameters,
278
- }: {
279
- pluginName: string
280
- hookName: H
281
- parameters: PluginParameter<H>
282
- }): Array<ReturnType<ParseResult<H>>> | null {
283
- const plugins = this.getPluginsByName(hookName, pluginName)
284
-
285
- const result = plugins
286
- .map((plugin) => {
287
- return this.#executeSync<H>({
288
- strategy: 'hookFirst',
289
- hookName,
290
- parameters,
291
- plugin,
292
- })
293
- })
294
- .filter((x): x is NonNullable<typeof x> => x !== null)
295
-
296
- return result
297
203
  }
298
204
 
299
205
  /**
300
- * Returns the first non-null result.
206
+ * Registers a generator for the given plugin on the shared event emitter.
207
+ *
208
+ * The generator's `schema`, `operation`, and `operations` methods are registered as
209
+ * listeners on `kubb:generate:schema`, `kubb:generate:operation`, and `kubb:generate:operations`
210
+ * respectively. Each listener is scoped to the owning plugin via a `ctx.plugin.name` check
211
+ * so that generators from different plugins do not cross-fire.
212
+ *
213
+ * The renderer resolution chain is: `generator.renderer → plugin.renderer → config.renderer`.
214
+ * Set `generator.renderer = null` to explicitly opt out of rendering even when the plugin
215
+ * declares a renderer.
216
+ *
217
+ * Call this method inside `addGenerator()` (in `kubb:plugin:setup`) to wire up a generator.
301
218
  */
302
- async hookFirst<H extends PluginLifecycleHooks>({
303
- hookName,
304
- parameters,
305
- skipped,
306
- }: {
307
- hookName: H
308
- parameters: PluginParameter<H>
309
- skipped?: ReadonlySet<Plugin> | null
310
- }): Promise<SafeParseResult<H>> {
311
- const plugins = this.#getSortedPlugins(hookName).filter((plugin) => {
312
- return skipped ? !skipped.has(plugin) : true
313
- })
219
+ registerGenerator(pluginName: string, gen: Generator): void {
220
+ const resolveRenderer = () => {
221
+ const plugin = this.plugins.get(pluginName)
222
+ return gen.renderer === null ? undefined : (gen.renderer ?? plugin?.renderer ?? this.config.renderer)
223
+ }
314
224
 
315
- this.events.emit('plugins:hook:progress:start', { hookName, plugins })
316
-
317
- const promises = plugins.map((plugin) => {
318
- return async () => {
319
- const value = await this.#execute<H>({
320
- strategy: 'hookFirst',
321
- hookName,
322
- parameters,
323
- plugin,
324
- })
325
-
326
- return Promise.resolve({
327
- plugin,
328
- result: value,
329
- } as SafeParseResult<H>)
225
+ if (gen.schema) {
226
+ const schemaHandler = async (node: SchemaNode, ctx: GeneratorContext) => {
227
+ if (ctx.plugin.name !== pluginName) return
228
+ const result = await gen.schema!(node, ctx)
229
+ await applyHookResult(result, this, resolveRenderer())
330
230
  }
331
- })
332
-
333
- const result = await hookFirst(promises, hookFirstNullCheck)
334
231
 
335
- this.events.emit('plugins:hook:progress:end', { hookName })
232
+ this.hooks.on('kubb:generate:schema', schemaHandler)
233
+ this.#trackHookListener('kubb:generate:schema', schemaHandler as (...args: never[]) => void | Promise<void>)
234
+ }
336
235
 
337
- return result
338
- }
236
+ if (gen.operation) {
237
+ const operationHandler = async (node: OperationNode, ctx: GeneratorContext) => {
238
+ if (ctx.plugin.name !== pluginName) return
239
+ const result = await gen.operation!(node, ctx)
240
+ await applyHookResult(result, this, resolveRenderer())
241
+ }
339
242
 
340
- /**
341
- * Returns the first non-null result.
342
- */
343
- hookFirstSync<H extends PluginLifecycleHooks>({
344
- hookName,
345
- parameters,
346
- skipped,
347
- }: {
348
- hookName: H
349
- parameters: PluginParameter<H>
350
- skipped?: ReadonlySet<Plugin> | null
351
- }): SafeParseResult<H> | null {
352
- let parseResult: SafeParseResult<H> | null = null
353
- const plugins = this.#getSortedPlugins(hookName).filter((plugin) => {
354
- return skipped ? !skipped.has(plugin) : true
355
- })
243
+ this.hooks.on('kubb:generate:operation', operationHandler)
244
+ this.#trackHookListener('kubb:generate:operation', operationHandler as (...args: never[]) => void | Promise<void>)
245
+ }
356
246
 
357
- for (const plugin of plugins) {
358
- parseResult = {
359
- result: this.#executeSync<H>({
360
- strategy: 'hookFirst',
361
- hookName,
362
- parameters,
363
- plugin,
364
- }),
365
- plugin,
366
- } as SafeParseResult<H>
367
-
368
- if (parseResult?.result != null) {
369
- break
247
+ if (gen.operations) {
248
+ const operationsHandler = async (nodes: Array<OperationNode>, ctx: GeneratorContext) => {
249
+ if (ctx.plugin.name !== pluginName) return
250
+ const result = await gen.operations!(nodes, ctx)
251
+ await applyHookResult(result, this, resolveRenderer())
370
252
  }
253
+
254
+ this.hooks.on('kubb:generate:operations', operationsHandler)
255
+ this.#trackHookListener('kubb:generate:operations', operationsHandler as (...args: never[]) => void | Promise<void>)
371
256
  }
372
257
 
373
- return parseResult
258
+ this.#pluginsWithEventGenerators.add(pluginName)
374
259
  }
375
260
 
376
261
  /**
377
- * Runs all plugins in parallel based on `this.plugin` order and `pre`/`post` settings.
262
+ * Returns `true` when at least one generator was registered for the given plugin
263
+ * via `addGenerator()` in `kubb:plugin:setup` (event-based path).
264
+ *
265
+ * Used by the build loop to decide whether to walk the AST and emit generator events
266
+ * for a plugin that has no static `plugin.generators`.
378
267
  */
379
- async hookParallel<H extends PluginLifecycleHooks, TOutput = void>({
380
- hookName,
381
- parameters,
382
- }: {
383
- hookName: H
384
- parameters?: Parameters<RequiredPluginLifecycle[H]> | undefined
385
- }): Promise<Awaited<TOutput>[]> {
386
- const plugins = this.#getSortedPlugins(hookName)
387
- this.events.emit('plugins:hook:progress:start', { hookName, plugins })
388
-
389
- const pluginStartTimes = new Map<Plugin, number>()
390
-
391
- const promises = plugins.map((plugin) => {
392
- return () => {
393
- pluginStartTimes.set(plugin, performance.now())
394
- return this.#execute({
395
- strategy: 'hookParallel',
396
- hookName,
397
- parameters,
398
- plugin,
399
- }) as Promise<TOutput>
400
- }
401
- })
402
-
403
- const results = await hookParallel(promises, this.options.concurrency)
404
-
405
- results.forEach((result, index) => {
406
- if (isPromiseRejectedResult<Error>(result)) {
407
- const plugin = this.#getSortedPlugins(hookName)[index]
408
-
409
- if (plugin) {
410
- const startTime = pluginStartTimes.get(plugin) ?? performance.now()
411
- this.events.emit('error', result.reason, {
412
- plugin,
413
- hookName,
414
- strategy: 'hookParallel',
415
- duration: Math.round(performance.now() - startTime),
416
- parameters,
417
- })
418
- }
419
- }
420
- })
421
-
422
- this.events.emit('plugins:hook:progress:end', { hookName })
423
-
424
- return results.reduce((acc, result) => {
425
- if (result.status === 'fulfilled') {
426
- acc.push(result.value)
427
- }
428
- return acc
429
- }, [] as Awaited<TOutput>[])
268
+ hasRegisteredGenerators(pluginName: string): boolean {
269
+ return this.#pluginsWithEventGenerators.has(pluginName)
430
270
  }
431
271
 
432
272
  /**
433
- * Chains plugins
273
+ * Unregisters all plugin lifecycle listeners from the shared event emitter.
274
+ * Called at the end of a build to prevent listener leaks across repeated builds.
275
+ *
276
+ * @internal
434
277
  */
435
- async hookSeq<H extends PluginLifecycleHooks>({ hookName, parameters }: { hookName: H; parameters?: PluginParameter<H> }): Promise<void> {
436
- const plugins = this.#getSortedPlugins(hookName)
437
- this.events.emit('plugins:hook:progress:start', { hookName, plugins })
438
-
439
- const promises = plugins.map((plugin) => {
440
- return () =>
441
- this.#execute({
442
- strategy: 'hookSeq',
443
- hookName,
444
- parameters,
445
- plugin,
446
- })
447
- })
448
-
449
- await hookSeq(promises)
450
-
451
- this.events.emit('plugins:hook:progress:end', { hookName })
278
+ dispose(): void {
279
+ for (const [event, handlers] of this.#hookListeners) {
280
+ for (const handler of handlers) {
281
+ this.hooks.off(event, handler as never)
282
+ }
283
+ }
284
+ this.#hookListeners.clear()
285
+ this.#pluginsWithEventGenerators.clear()
452
286
  }
453
287
 
454
- #getSortedPlugins(hookName?: keyof PluginLifecycle): Array<Plugin> {
455
- const plugins = [...this.#plugins]
456
-
457
- if (hookName) {
458
- return plugins.filter((plugin) => hookName in plugin)
288
+ #trackHookListener(event: keyof KubbHooks, handler: (...args: never[]) => void | Promise<void>): void {
289
+ let handlers = this.#hookListeners.get(event)
290
+ if (!handlers) {
291
+ handlers = new Set()
292
+ this.#hookListeners.set(event, handlers)
459
293
  }
460
- // TODO add test case for sorting with pre/post
461
-
462
- return plugins
463
- .map((plugin) => {
464
- if (plugin.pre) {
465
- let missingPlugins = plugin.pre.filter((pluginName) => !plugins.find((pluginToFind) => pluginToFind.name === pluginName))
466
-
467
- // when adapter is set, we can ignore the depends on plugin-oas, in v5 this will not be needed anymore
468
- if (missingPlugins.includes('plugin-oas') && this.adapter) {
469
- missingPlugins = missingPlugins.filter((pluginName) => pluginName !== 'plugin-oas')
470
- }
471
-
472
- if (missingPlugins.length > 0) {
473
- throw new ValidationPluginError(`The plugin '${plugin.name}' has a pre set that references missing plugins for '${missingPlugins.join(', ')}'`)
474
- }
475
- }
476
-
477
- return plugin
478
- })
479
- .sort((a, b) => {
480
- if (b.pre?.includes(a.name)) {
481
- return 1
482
- }
483
- if (b.post?.includes(a.name)) {
484
- return -1
485
- }
486
- return 0
487
- })
294
+ handlers.add(handler)
488
295
  }
489
296
 
490
- getPluginByName(pluginName: string): Plugin | undefined {
491
- const plugins = [...this.#plugins]
297
+ #createDefaultResolver(pluginName: string): Resolver {
298
+ const existingResolver = this.#defaultResolvers.get(pluginName)
299
+ if (existingResolver) {
300
+ return existingResolver
301
+ }
492
302
 
493
- return plugins.find((item) => item.name === pluginName)
303
+ const resolver = defineResolver<PluginFactoryOptions>((_ctx) => ({
304
+ name: 'default',
305
+ pluginName,
306
+ }))
307
+ this.#defaultResolvers.set(pluginName, resolver)
308
+ return resolver
494
309
  }
495
310
 
496
- getPluginsByName(hookName: keyof PluginWithLifeCycle, pluginName: string): Plugin[] {
497
- const plugins = [...this.plugins]
498
-
499
- const pluginByPluginName = plugins.filter((plugin) => hookName in plugin).filter((item) => item.name === pluginName)
500
-
501
- if (!pluginByPluginName?.length) {
502
- // fallback on the core plugin when there is no match
503
-
504
- const corePlugin = plugins.find((plugin) => plugin.name === CORE_PLUGIN_NAME && hookName in plugin)
505
-
506
- return corePlugin ? [corePlugin] : []
311
+ /**
312
+ * Merges `partial` with the plugin's default resolver and stores the result.
313
+ * Also mirrors it onto `plugin.resolver` so callers using `getPlugin(name).resolver`
314
+ * get the up-to-date resolver without going through `getResolver()`.
315
+ */
316
+ setPluginResolver(pluginName: string, partial: Partial<Resolver>): void {
317
+ const defaultResolver = this.#createDefaultResolver(pluginName)
318
+ const merged = { ...defaultResolver, ...partial }
319
+ this.#resolvers.set(pluginName, merged)
320
+ const plugin = this.plugins.get(pluginName)
321
+ if (plugin) {
322
+ plugin.resolver = merged
507
323
  }
508
-
509
- return pluginByPluginName
510
324
  }
511
325
 
512
326
  /**
513
- * Run an async plugin hook and return the result.
514
- * @param hookName Name of the plugin hook. Must be either in `PluginHooks` or `OutputPluginValueHooks`.
515
- * @param args Arguments passed to the plugin hook.
516
- * @param plugin The actual pluginObject to run.
327
+ * Returns the resolver for the given plugin.
328
+ *
329
+ * Resolution order: dynamic resolver set via `setPluginResolver` → static resolver on the
330
+ * plugin lazily created default resolver (identity name, no path transforms).
517
331
  */
518
- #emitProcessingEnd<H extends PluginLifecycleHooks>({
519
- startTime,
520
- output,
521
- strategy,
522
- hookName,
523
- plugin,
524
- parameters,
525
- }: {
526
- startTime: number
527
- output: unknown
528
- strategy: Strategy
529
- hookName: H
530
- plugin: PluginWithLifeCycle
531
- parameters: unknown[] | undefined
532
- }): void {
533
- this.events.emit('plugins:hook:processing:end', {
534
- duration: Math.round(performance.now() - startTime),
535
- parameters,
536
- output,
537
- strategy,
538
- hookName,
539
- plugin,
540
- })
332
+ getResolver<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Kubb.PluginRegistry[TName]['resolver']
333
+ getResolver<TResolver extends Resolver = Resolver>(pluginName: string): TResolver
334
+ getResolver(pluginName: string): Resolver {
335
+ return this.#resolvers.get(pluginName) ?? this.plugins.get(pluginName)?.resolver ?? this.#createDefaultResolver(pluginName)
541
336
  }
542
337
 
543
- // Implementation signature
544
- #execute<H extends PluginLifecycleHooks>({
545
- strategy,
546
- hookName,
547
- parameters,
548
- plugin,
549
- }: {
550
- strategy: Strategy
551
- hookName: H
552
- parameters: unknown[] | undefined
553
- plugin: PluginWithLifeCycle
554
- }): Promise<ReturnType<ParseResult<H>> | null> | null {
555
- const hook = plugin[hookName]
556
-
557
- if (!hook) {
558
- return null
559
- }
338
+ getContext<TOptions extends PluginFactoryOptions>(plugin: NormalizedPlugin<TOptions>): GeneratorContext<TOptions> & Record<string, unknown> {
339
+ const driver = this
560
340
 
561
- this.events.emit('plugins:hook:processing:start', {
562
- strategy,
563
- hookName,
564
- parameters,
341
+ const baseContext = {
342
+ config: driver.config,
343
+ get root(): string {
344
+ return resolve(driver.config.root, driver.config.output.path)
345
+ },
346
+ getMode(output: { path: string }): 'single' | 'split' {
347
+ return PluginDriver.getMode(resolve(driver.config.root, driver.config.output.path, output.path))
348
+ },
349
+ hooks: driver.hooks,
565
350
  plugin,
566
- })
351
+ getPlugin: driver.getPlugin.bind(driver),
352
+ requirePlugin: driver.requirePlugin.bind(driver),
353
+ getResolver: driver.getResolver.bind(driver),
354
+ driver,
355
+ addFile: async (...files: Array<FileNode>) => {
356
+ driver.fileManager.add(...files)
357
+ },
358
+ upsertFile: async (...files: Array<FileNode>) => {
359
+ driver.fileManager.upsert(...files)
360
+ },
361
+ get inputNode(): InputNode | undefined {
362
+ return driver.inputNode
363
+ },
364
+ get adapter(): Adapter | undefined {
365
+ return driver.adapter
366
+ },
367
+ get resolver() {
368
+ return driver.getResolver(plugin.name)
369
+ },
370
+ get transformer() {
371
+ return plugin.transformer
372
+ },
373
+ warn(message: string) {
374
+ driver.hooks.emit('kubb:warn', { message })
375
+ },
376
+ error(error: string | Error) {
377
+ driver.hooks.emit('kubb:error', { error: typeof error === 'string' ? new Error(error) : error })
378
+ },
379
+ info(message: string) {
380
+ driver.hooks.emit('kubb:info', { message })
381
+ },
382
+ openInStudio(options?: DevtoolsOptions) {
383
+ if (!driver.config.devtools || driver.#studioIsOpen) {
384
+ return
385
+ }
567
386
 
568
- const startTime = performance.now()
387
+ if (typeof driver.config.devtools !== 'object') {
388
+ throw new Error('Devtools must be an object')
389
+ }
569
390
 
570
- const task = (async () => {
571
- try {
572
- const output =
573
- typeof hook === 'function' ? await Promise.resolve((hook as (...args: unknown[]) => unknown).apply(this.getContext(plugin), parameters ?? [])) : hook
391
+ if (!driver.inputNode || !driver.adapter) {
392
+ throw new Error('adapter is not defined, make sure you have set the parser in kubb.config.ts')
393
+ }
574
394
 
575
- this.#emitProcessingEnd({ startTime, output, strategy, hookName, plugin, parameters })
395
+ driver.#studioIsOpen = true
576
396
 
577
- return output as ReturnType<ParseResult<H>>
578
- } catch (error) {
579
- this.events.emit('error', error as Error, {
580
- plugin,
581
- hookName,
582
- strategy,
583
- duration: Math.round(performance.now() - startTime),
584
- })
397
+ const studioUrl = driver.config.devtools?.studioUrl ?? DEFAULT_STUDIO_URL
585
398
 
586
- return null
587
- }
588
- })()
399
+ return openInStudioFn(driver.inputNode, studioUrl, options)
400
+ },
401
+ } as unknown as GeneratorContext<TOptions>
589
402
 
590
- return task
403
+ return baseContext
591
404
  }
592
405
 
593
- /**
594
- * Run a sync plugin hook and return the result.
595
- * @param hookName Name of the plugin hook. Must be in `PluginHooks`.
596
- * @param args Arguments passed to the plugin hook.
597
- * @param plugin The actual plugin
598
- */
599
- #executeSync<H extends PluginLifecycleHooks>({
600
- strategy,
601
- hookName,
602
- parameters,
603
- plugin,
604
- }: {
605
- strategy: Strategy
606
- hookName: H
607
- parameters: PluginParameter<H>
608
- plugin: PluginWithLifeCycle
609
- }): ReturnType<ParseResult<H>> | null {
610
- const hook = plugin[hookName]
611
-
612
- if (!hook) {
613
- return null
614
- }
615
-
616
- this.events.emit('plugins:hook:processing:start', {
617
- strategy,
618
- hookName,
619
- parameters,
620
- plugin,
621
- })
622
-
623
- const startTime = performance.now()
624
-
625
- try {
626
- const output =
627
- typeof hook === 'function'
628
- ? ((hook as (...args: unknown[]) => unknown).apply(this.getContext(plugin), parameters) as ReturnType<ParseResult<H>>)
629
- : (hook as ReturnType<ParseResult<H>>)
630
-
631
- this.#emitProcessingEnd({ startTime, output, strategy, hookName, plugin, parameters })
632
-
633
- return output
634
- } catch (error) {
635
- this.events.emit('error', error as Error, {
636
- plugin,
637
- hookName,
638
- strategy,
639
- duration: Math.round(performance.now() - startTime),
640
- })
641
-
642
- return null
643
- }
406
+ getPlugin<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Plugin<Kubb.PluginRegistry[TName]> | undefined
407
+ getPlugin<TOptions extends PluginFactoryOptions = PluginFactoryOptions>(pluginName: string): Plugin<TOptions> | undefined
408
+ getPlugin(pluginName: string): Plugin | undefined {
409
+ return this.plugins.get(pluginName)
644
410
  }
645
411
 
646
- #parse(plugin: UserPlugin): Plugin {
647
- const usedPluginNames = this.#usedPluginNames
648
-
649
- setUniqueName(plugin.name, usedPluginNames)
650
-
651
- const usageCount = usedPluginNames[plugin.name]
652
- if (usageCount && usageCount > 1) {
653
- throw new ValidationPluginError(
654
- `Duplicate plugin "${plugin.name}" detected. Each plugin can only be used once. Use a different configuration instead of adding multiple instances of the same plugin.`,
655
- )
412
+ /**
413
+ * Like `getPlugin` but throws a descriptive error when the plugin is not found.
414
+ */
415
+ requirePlugin<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Plugin<Kubb.PluginRegistry[TName]>
416
+ requirePlugin<TOptions extends PluginFactoryOptions = PluginFactoryOptions>(pluginName: string): Plugin<TOptions>
417
+ requirePlugin(pluginName: string): Plugin {
418
+ const plugin = this.plugins.get(pluginName)
419
+ if (!plugin) {
420
+ throw new Error(`[kubb] Plugin "${pluginName}" is required but not found. Make sure it is included in your Kubb config.`)
656
421
  }
657
-
658
- return {
659
- install() {},
660
- ...plugin,
661
- } as unknown as Plugin
422
+ return plugin
662
423
  }
663
424
  }