@kubb/core 5.0.0-beta.19 → 5.0.0-beta.20

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.
@@ -1,7 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
- import type { AsyncEventEmitter } from '@internals/utils'
3
- import type { FileNode, InputNode, InputStreamNode, OperationNode, SchemaNode } from '@kubb/ast'
4
- import { createFile } from '@kubb/ast'
2
+ import { arrayToAsyncIterable, type AsyncEventEmitter, memoize, URLPath } from '@internals/utils'
3
+ import { createFile, createStreamInput } from '@kubb/ast'
4
+ import type { FileNode, InputMeta, InputNode, InputStreamNode, OperationNode, SchemaNode } from '@kubb/ast'
5
5
  import { DEFAULT_STUDIO_URL } from './constants.ts'
6
6
  import type { Generator } from './defineGenerator.ts'
7
7
  import type { Plugin } from './definePlugin.ts'
@@ -13,18 +13,18 @@ import type { RendererFactory } from './createRenderer.ts'
13
13
 
14
14
  import type {
15
15
  Adapter,
16
+ AdapterSource,
16
17
  Config,
17
18
  DevtoolsOptions,
18
19
  GeneratorContext,
19
20
  KubbHooks,
20
21
  KubbPluginSetupContext,
22
+ Middleware,
21
23
  NormalizedPlugin,
22
24
  PluginFactoryOptions,
23
25
  Resolver,
24
26
  } from './types.ts'
25
27
 
26
- // inspired by: https://github.com/rollup/rollup/blob/master/src/utils/PluginDriver.ts#
27
-
28
28
  type Options = {
29
29
  hooks: AsyncEventEmitter<KubbHooks>
30
30
  }
@@ -33,7 +33,7 @@ function enforceOrder(enforce: 'pre' | 'post' | undefined): number {
33
33
  return enforce === 'pre' ? -1 : enforce === 'post' ? 1 : 0
34
34
  }
35
35
 
36
- export class PluginDriver {
36
+ export class KubbDriver {
37
37
  readonly config: Config
38
38
  readonly options: Options
39
39
 
@@ -42,8 +42,8 @@ export class PluginDriver {
42
42
  *
43
43
  * @example
44
44
  * ```ts
45
- * PluginDriver.getMode('src/gen/types.ts') // 'single'
46
- * PluginDriver.getMode('src/gen/types') // 'split'
45
+ * KubbDriver.getMode('src/gen/types.ts') // 'single'
46
+ * KubbDriver.getMode('src/gen/types') // 'split'
47
47
  * ```
48
48
  */
49
49
  static getMode(fileOrFolder: string | undefined | null): 'single' | 'split' {
@@ -51,17 +51,32 @@ export class PluginDriver {
51
51
  }
52
52
 
53
53
  /**
54
- * The universal `@kubb/ast` `InputNode` produced by the adapter, set by
55
- * the build pipeline after the adapter's `parse()` resolves.
54
+ * The streaming `InputStreamNode` produced by the adapter.
55
+ * Always set after adapter setup — parse-only adapters are wrapped automatically.
56
56
  */
57
- inputNode: InputNode | undefined = undefined
57
+ inputNode: InputStreamNode | undefined = undefined
58
+ adapter: Adapter | undefined = undefined
58
59
  /**
59
- * Set when the adapter returns a streaming `InputStreamNode` (large specs).
60
- * Mutually exclusive with `inputNode` — exactly one is set after adapter setup.
60
+ * Studio session state, kept together so `dispose()` can reset it atomically.
61
+ *
62
+ * - `source` holds the raw adapter source so `adapter.parse()` can be called lazily.
63
+ * Intentionally outlives the build; cleared by `dispose()`.
64
+ * - `isOpen` prevents opening the studio more than once per build.
65
+ * - `inputNode` caches the parse promise so `adapter.parse()` is called at most once
66
+ * per studio session, even when `openInStudio()` is called multiple times.
61
67
  */
62
- inputStreamNode: InputStreamNode | undefined = undefined
63
- adapter: Adapter | undefined = undefined
64
- #studioIsOpen = false
68
+ #studio: { source: AdapterSource | undefined; isOpen: boolean; inputNode: Promise<InputNode> | undefined } = {
69
+ source: undefined,
70
+ isOpen: false,
71
+ inputNode: undefined,
72
+ }
73
+
74
+ // Register middleware hooks after all plugin hooks are registered.
75
+ // Because AsyncEventEmitter calls listeners in registration order,
76
+ // middleware hooks for any event fire after all plugin hooks for that event.
77
+ // Handlers are tracked so they can be removed after each build (disposeMiddleware),
78
+ // preventing accumulation when multiple configs share the same hooks instance.
79
+ #middlewareListeners: Array<[keyof KubbHooks & string, (...args: never[]) => void | Promise<void>]> = []
65
80
 
66
81
  /**
67
82
  * Central file store for all generated files.
@@ -76,7 +91,7 @@ export class PluginDriver {
76
91
  * Tracks which plugins have generators registered via `addGenerator()` (event-based path).
77
92
  * Used by the build loop to decide whether to emit generator events for a given plugin.
78
93
  */
79
- readonly #pluginsWithEventGenerators = new Set<string>()
94
+ readonly #eventGeneratorPlugins = new Set<string>()
80
95
  readonly #resolvers = new Map<string, Resolver>()
81
96
  readonly #defaultResolvers = new Map<string, Resolver>()
82
97
  readonly #hookListeners = new Map<keyof KubbHooks, Set<(...args: never[]) => void | Promise<void>>>()
@@ -84,23 +99,38 @@ export class PluginDriver {
84
99
  constructor(config: Config, options: Options) {
85
100
  this.config = config
86
101
  this.options = options
87
- config.plugins
88
- .map((rawPlugin) => this.#normalizePlugin(rawPlugin as Plugin))
89
- .filter((plugin) => {
90
- if (typeof plugin.apply === 'function') {
91
- return plugin.apply(config)
102
+ this.adapter = config.adapter
103
+ }
104
+
105
+ async setup() {
106
+ const normalized: NormalizedPlugin[] = this.config.plugins.map((rawPlugin) => this.#normalizePlugin(rawPlugin as Plugin))
107
+
108
+ normalized.sort((a, b) => {
109
+ if (b.dependencies?.includes(a.name)) return -1
110
+ if (a.dependencies?.includes(b.name)) return 1
111
+
112
+ return enforceOrder(a.enforce) - enforceOrder(b.enforce)
113
+ })
114
+
115
+ for (const plugin of normalized) {
116
+ if (plugin.apply) {
117
+ plugin.apply(this.config)
118
+ }
119
+
120
+ this.#registerPlugin(plugin)
121
+ this.plugins.set(plugin.name, plugin)
122
+ }
123
+
124
+ if (this.config.middleware) {
125
+ for (const middleware of this.config.middleware) {
126
+ for (const event of Object.keys(middleware.hooks) as Array<keyof KubbHooks & string>) {
127
+ this.#registerMiddleware(event, middleware.hooks)
92
128
  }
93
- return true
94
- })
95
- .sort((a, b) => {
96
- if (b.dependencies?.includes(a.name)) return -1
97
- if (a.dependencies?.includes(b.name)) return 1
98
- // enforce: 'pre' plugins run first, 'post' plugins run last
99
- return enforceOrder(a.enforce) - enforceOrder(b.enforce)
100
- })
101
- .forEach((plugin) => {
102
- this.plugins.set(plugin.name, plugin)
103
- })
129
+ }
130
+ }
131
+ if (this.config.adapter) {
132
+ await this.#registerAdapter(this.config.adapter)
133
+ }
104
134
  }
105
135
 
106
136
  get hooks() {
@@ -111,16 +141,59 @@ export class PluginDriver {
111
141
  * Creates an `NormalizedPlugin` from a hook-style plugin and registers
112
142
  * its lifecycle handlers on the `AsyncEventEmitter`.
113
143
  */
114
- #normalizePlugin(hookPlugin: Plugin): NormalizedPlugin {
115
- const normalizedPlugin = {
116
- name: hookPlugin.name,
117
- dependencies: hookPlugin.dependencies,
118
- enforce: hookPlugin.enforce,
119
- options: { output: { path: '.' }, exclude: [], override: [] },
120
- } as unknown as NormalizedPlugin
121
-
122
- this.registerPluginHooks(hookPlugin, normalizedPlugin)
123
- return normalizedPlugin
144
+ #normalizePlugin(plugin: Plugin): NormalizedPlugin {
145
+ const normalized: NormalizedPlugin = {
146
+ name: plugin.name,
147
+ dependencies: plugin.dependencies,
148
+ enforce: plugin.enforce,
149
+ hooks: plugin.hooks,
150
+ options: plugin.options ?? { output: { path: '.' }, exclude: [], override: [] },
151
+ } as NormalizedPlugin
152
+
153
+ if ('apply' in plugin && typeof plugin.apply === 'function') {
154
+ normalized.apply = plugin.apply as (config: Config) => boolean
155
+ }
156
+
157
+ return normalized
158
+ }
159
+
160
+ async #registerAdapter(adapter: Adapter) {
161
+ const source = inputToAdapterSource(this.config)
162
+ this.#studio.source = source
163
+
164
+ if (adapter.stream) {
165
+ this.inputNode = await adapter.stream(source)
166
+
167
+ await this.hooks.emit('kubb:debug', {
168
+ date: new Date(),
169
+ logs: [`✓ Adapter '${adapter.name}' producing input stream`],
170
+ })
171
+ } else {
172
+ // Adapter does not implement stream() — eagerly parse and wrap in a
173
+ // reusable AsyncIterable so the rest of the pipeline stays stream-only.
174
+ const inputNode = await adapter.parse(source)
175
+ this.inputNode = createStreamInput(arrayToAsyncIterable(inputNode.schemas), arrayToAsyncIterable(inputNode.operations), inputNode.meta)
176
+
177
+ await this.hooks.emit('kubb:debug', {
178
+ date: new Date(),
179
+ logs: [
180
+ `✓ Adapter '${adapter.name}' resolved InputNode (wrapped as stream)`,
181
+ ` • Schemas: ${inputNode.schemas.length}`,
182
+ ` • Operations: ${inputNode.operations.length}`,
183
+ ],
184
+ })
185
+ }
186
+ }
187
+
188
+ #registerMiddleware<K extends keyof KubbHooks & string>(event: K, middlewareHooks: Middleware['hooks']) {
189
+ const handler = middlewareHooks[event]
190
+
191
+ if (!handler) {
192
+ return
193
+ }
194
+
195
+ this.hooks.on(event, handler)
196
+ this.#middlewareListeners.push([event, handler as (...args: never[]) => void | Promise<void>])
124
197
  }
125
198
 
126
199
  /**
@@ -138,8 +211,8 @@ export class PluginDriver {
138
211
  *
139
212
  * @internal
140
213
  */
141
- registerPluginHooks(hookPlugin: Plugin, normalizedPlugin: NormalizedPlugin): void {
142
- const { hooks } = hookPlugin
214
+ #registerPlugin(plugin: NormalizedPlugin): void {
215
+ const { hooks } = plugin
143
216
 
144
217
  if (!hooks) return
145
218
 
@@ -150,21 +223,21 @@ export class PluginDriver {
150
223
  const setupHandler = (globalCtx: KubbPluginSetupContext) => {
151
224
  const pluginCtx: KubbPluginSetupContext = {
152
225
  ...globalCtx,
153
- options: hookPlugin.options ?? {},
226
+ options: plugin.options ?? {},
154
227
  addGenerator: (gen) => {
155
- this.registerGenerator(normalizedPlugin.name, gen)
228
+ this.registerGenerator(plugin.name, gen)
156
229
  },
157
230
  setResolver: (resolver) => {
158
- this.setPluginResolver(normalizedPlugin.name, resolver)
231
+ this.setPluginResolver(plugin.name, resolver)
159
232
  },
160
233
  setTransformer: (visitor) => {
161
- normalizedPlugin.transformer = visitor
234
+ plugin.transformer = visitor
162
235
  },
163
236
  setRenderer: (renderer) => {
164
- normalizedPlugin.renderer = renderer
237
+ plugin.renderer = renderer
165
238
  },
166
239
  setOptions: (opts) => {
167
- normalizedPlugin.options = { ...normalizedPlugin.options, ...opts }
240
+ plugin.options = { ...plugin.options, ...opts }
168
241
  },
169
242
  injectFile: (userFileNode) => {
170
243
  this.fileManager.add(createFile(userFileNode))
@@ -261,7 +334,7 @@ export class PluginDriver {
261
334
  this.#trackHookListener('kubb:generate:operations', operationsHandler as (...args: never[]) => void | Promise<void>)
262
335
  }
263
336
 
264
- this.#pluginsWithEventGenerators.add(pluginName)
337
+ this.#eventGeneratorPlugins.add(pluginName)
265
338
  }
266
339
 
267
340
  /**
@@ -271,8 +344,8 @@ export class PluginDriver {
271
344
  * Used by the build loop to decide whether to walk the AST and emit generator events
272
345
  * for a plugin that has no static `plugin.generators`.
273
346
  */
274
- hasRegisteredGenerators(pluginName: string): boolean {
275
- return this.#pluginsWithEventGenerators.has(pluginName)
347
+ hasEventGenerators(pluginName: string): boolean {
348
+ return this.#eventGeneratorPlugins.has(pluginName)
276
349
  }
277
350
 
278
351
  /**
@@ -287,18 +360,23 @@ export class PluginDriver {
287
360
  this.hooks.off(event, handler as never)
288
361
  }
289
362
  }
363
+
290
364
  this.#hookListeners.clear()
291
- this.#pluginsWithEventGenerators.clear()
365
+ this.#eventGeneratorPlugins.clear()
292
366
  // Release resolver closures — the driver is rebuilt for each build() call
293
367
  // so there is no value in retaining these maps after disposal.
294
368
  this.#resolvers.clear()
295
369
  this.#defaultResolvers.clear()
296
- // Release the parsed adapter graph and the FileNode cache once the build
297
- // has finished; the returned `BuildOutput.files` array still references
298
- // any FileNodes the caller needs to inspect.
370
+ // Release the FileNode cache, parsed adapter graph, and studio state so
371
+ // memory is reclaimed between builds. The returned `BuildOutput.files`
372
+ // array still references any FileNodes the caller needs to inspect.
299
373
  this.fileManager.dispose()
300
374
  this.inputNode = undefined
301
- this.inputStreamNode = undefined
375
+ this.#studio = { source: undefined, isOpen: false, inputNode: undefined }
376
+
377
+ for (const [event, handler] of this.#middlewareListeners) {
378
+ this.hooks.off(event, handler as never)
379
+ }
302
380
  }
303
381
 
304
382
  [Symbol.dispose](): void {
@@ -314,19 +392,10 @@ export class PluginDriver {
314
392
  handlers.add(handler)
315
393
  }
316
394
 
317
- #createDefaultResolver(pluginName: string): Resolver {
318
- const existingResolver = this.#defaultResolvers.get(pluginName)
319
- if (existingResolver) {
320
- return existingResolver
321
- }
322
-
323
- const resolver = defineResolver<PluginFactoryOptions>(() => ({
324
- name: 'default',
325
- pluginName,
326
- }))
327
- this.#defaultResolvers.set(pluginName, resolver)
328
- return resolver
329
- }
395
+ #getDefaultResolver = memoize(
396
+ this.#defaultResolvers,
397
+ (pluginName: string): Resolver => defineResolver<PluginFactoryOptions>(() => ({ name: 'default', pluginName })),
398
+ )
330
399
 
331
400
  /**
332
401
  * Merges `partial` with the plugin's default resolver and stores the result.
@@ -334,7 +403,7 @@ export class PluginDriver {
334
403
  * get the up-to-date resolver without going through `getResolver()`.
335
404
  */
336
405
  setPluginResolver(pluginName: string, partial: Partial<Resolver>): void {
337
- const defaultResolver = this.#createDefaultResolver(pluginName)
406
+ const defaultResolver = this.#getDefaultResolver(pluginName)
338
407
  const merged = { ...defaultResolver, ...partial }
339
408
  this.#resolvers.set(pluginName, merged)
340
409
  const plugin = this.plugins.get(pluginName)
@@ -352,19 +421,19 @@ export class PluginDriver {
352
421
  getResolver<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Kubb.PluginRegistry[TName]['resolver']
353
422
  getResolver<TResolver extends Resolver = Resolver>(pluginName: string): TResolver
354
423
  getResolver(pluginName: string): Resolver {
355
- return this.#resolvers.get(pluginName) ?? this.plugins.get(pluginName)?.resolver ?? this.#createDefaultResolver(pluginName)
424
+ return this.#resolvers.get(pluginName) ?? this.plugins.get(pluginName)?.resolver ?? this.#getDefaultResolver(pluginName)
356
425
  }
357
426
 
358
427
  getContext<TOptions extends PluginFactoryOptions>(plugin: NormalizedPlugin<TOptions>): GeneratorContext<TOptions> & Record<string, unknown> {
359
428
  const driver = this
360
429
 
361
- const baseContext = {
430
+ return {
362
431
  config: driver.config,
363
432
  get root(): string {
364
433
  return resolve(driver.config.root, driver.config.output.path)
365
434
  },
366
435
  getMode(output: { path: string }): 'single' | 'split' {
367
- return PluginDriver.getMode(resolve(driver.config.root, driver.config.output.path, output.path))
436
+ return KubbDriver.getMode(resolve(driver.config.root, driver.config.output.path, output.path))
368
437
  },
369
438
  hooks: driver.hooks,
370
439
  plugin,
@@ -378,9 +447,8 @@ export class PluginDriver {
378
447
  upsertFile: async (...files: Array<FileNode>) => {
379
448
  driver.fileManager.upsert(...files)
380
449
  },
381
- get inputNode(): InputNode {
382
- if (driver.inputNode) return driver.inputNode
383
- return { kind: 'Input' as const, schemas: [], operations: [], meta: driver.inputStreamNode?.meta }
450
+ get meta(): InputMeta {
451
+ return driver.inputNode?.meta ?? { circularNames: [], enumNames: [] }
384
452
  },
385
453
  get adapter(): Adapter | undefined {
386
454
  return driver.adapter
@@ -400,8 +468,8 @@ export class PluginDriver {
400
468
  info(message: string) {
401
469
  driver.hooks.emit('kubb:info', { message })
402
470
  },
403
- openInStudio(options?: DevtoolsOptions) {
404
- if (!driver.config.devtools || driver.#studioIsOpen) {
471
+ async openInStudio(options?: DevtoolsOptions) {
472
+ if (!driver.config.devtools || driver.#studio.isOpen) {
405
473
  return
406
474
  }
407
475
 
@@ -409,19 +477,19 @@ export class PluginDriver {
409
477
  throw new Error('Devtools must be an object')
410
478
  }
411
479
 
412
- if (!driver.inputNode || !driver.adapter) {
480
+ if (!driver.adapter || !driver.#studio.source) {
413
481
  throw new Error('adapter is not defined, make sure you have set the parser in kubb.config.ts')
414
482
  }
415
483
 
416
- driver.#studioIsOpen = true
484
+ driver.#studio.isOpen = true
417
485
 
418
486
  const studioUrl = driver.config.devtools?.studioUrl ?? DEFAULT_STUDIO_URL
487
+ driver.#studio.inputNode ??= Promise.resolve(driver.adapter.parse(driver.#studio.source))
488
+ const inputNode = await driver.#studio.inputNode
419
489
 
420
- return openInStudioFn(driver.inputNode, studioUrl, options)
490
+ return openInStudioFn(inputNode, studioUrl, options)
421
491
  },
422
492
  } as unknown as GeneratorContext<TOptions>
423
-
424
- return baseContext
425
493
  }
426
494
 
427
495
  getPlugin<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Plugin<Kubb.PluginRegistry[TName]> | undefined
@@ -460,7 +528,7 @@ export function applyHookResult<TElement = unknown>({
460
528
  rendererFactory,
461
529
  }: {
462
530
  result: TElement | Array<FileNode> | void
463
- driver: PluginDriver
531
+ driver: KubbDriver
464
532
  rendererFactory?: RendererFactory<TElement>
465
533
  }): void | Promise<void> {
466
534
  if (!result) return
@@ -492,9 +560,28 @@ async function applyAsyncRender<TElement>({
492
560
  }: {
493
561
  renderer: { render(el: TElement): Promise<void>; files: ReadonlyArray<FileNode>; unmount(): void }
494
562
  result: TElement
495
- driver: PluginDriver
563
+ driver: KubbDriver
496
564
  }): Promise<void> {
497
565
  await renderer.render(result)
498
566
  driver.fileManager.upsert(...renderer.files)
499
567
  renderer.unmount()
500
568
  }
569
+
570
+ function inputToAdapterSource(config: Config): AdapterSource {
571
+ const input = config.input
572
+ if (!input) {
573
+ throw new Error('[kubb] input is required when using an adapter. Provide input.path or input.data in your config.')
574
+ }
575
+
576
+ if ('data' in input) {
577
+ return { type: 'data', data: input.data }
578
+ }
579
+
580
+ if (new URLPath(input.path).isURL) {
581
+ return { type: 'path', path: input.path }
582
+ }
583
+
584
+ const resolved = resolve(config.root, input.path)
585
+
586
+ return { type: 'path', path: resolved }
587
+ }
package/src/constants.ts CHANGED
@@ -16,14 +16,14 @@ export const DEFAULT_BANNER = 'simple' as const
16
16
  export const DEFAULT_EXTENSION: Record<FileNode['extname'], FileNode['extname'] | ''> = { '.ts': '.ts' }
17
17
 
18
18
  /**
19
- * Schema count above which the adapter's `stream()` path is used instead of `parse()`.
19
+ * Number of file writes to batch in parallel during `flushPendingFiles`.
20
20
  */
21
- export const STREAM_SCHEMA_THRESHOLD = 100
21
+ export const STREAM_FLUSH_EVERY = 50
22
22
 
23
23
  /**
24
- * In streaming mode, flush generated files to disk every N schemas to bound in-memory file buffers.
24
+ * Number of schema/operation nodes to dispatch concurrently during generation.
25
25
  */
26
- export const STREAM_FLUSH_EVERY = 50
26
+ export const SCHEMA_PARALLEL = 8
27
27
 
28
28
  /**
29
29
  * Numeric log-level thresholds used internally to compare verbosity.
@@ -73,14 +73,6 @@ export type Adapter<TOptions extends AdapterFactoryOptions = AdapterFactoryOptio
73
73
  * Validate the document at the given path or URL.
74
74
  */
75
75
  validate: (input: string, options?: { throwOnError?: boolean }) => Promise<void>
76
- /**
77
- * Lightweight pre-flight count of schemas and operations without parsing AST nodes.
78
- * The adapter should cache the loaded document so subsequent `parse()` or `stream()` calls
79
- * do not reload it.
80
- *
81
- * Used by the core to decide whether to use `parse()` or `stream()`.
82
- */
83
- count?: (source: AdapterSource) => Promise<{ schemas: number; operations: number }>
84
76
  /**
85
77
  * Memory-efficient streaming variant of `parse()`.
86
78
  *