@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.
package/src/createKubb.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { version as nodeVersion } from 'node:process'
3
3
  import type { PossiblePromise } from '@internals/utils'
4
- import { AsyncEventEmitter, BuildError, exists, formatMs, getElapsedMs, URLPath, isPromise } from '@internals/utils'
5
- import type { FileNode, InputNode, OperationNode, SchemaNode } from '@kubb/ast'
6
- import { collectUsedSchemaNames, transform, walk } from '@kubb/ast'
4
+ import { AsyncEventEmitter, BuildError, exists, forBatches, formatMs, getElapsedMs, URLPath, isPromise, withDrain } from '@internals/utils'
5
+ import type { FileNode, InputMeta, OperationNode, SchemaNode } from '@kubb/ast'
6
+ import { collectUsedSchemaNames, transform } from '@kubb/ast'
7
7
  import { version as KubbVersion } from '../package.json'
8
- import { DEFAULT_BANNER, DEFAULT_EXTENSION, DEFAULT_STUDIO_URL, STREAM_SCHEMA_THRESHOLD } from './constants.ts'
9
- import type { Adapter, AdapterSource } from './createAdapter.ts'
8
+ import { DEFAULT_BANNER, DEFAULT_EXTENSION, DEFAULT_STUDIO_URL, SCHEMA_PARALLEL, STREAM_FLUSH_EVERY } from './constants.ts'
9
+ import type { Adapter } from './createAdapter.ts'
10
10
  import type { RendererFactory } from './createRenderer.ts'
11
11
  import { createStorage, type Storage } from './createStorage.ts'
12
12
  import type { GeneratorContext, Generator } from './defineGenerator.ts'
@@ -15,7 +15,7 @@ import type { Parser } from './defineParser.ts'
15
15
  import type { KubbPluginEndContext, KubbPluginSetupContext, KubbPluginStartContext, NormalizedPlugin, Plugin } from './definePlugin.ts'
16
16
  import { FileProcessor } from './FileProcessor.ts'
17
17
 
18
- import { applyHookResult, PluginDriver } from './PluginDriver.ts'
18
+ import { applyHookResult, KubbDriver } from './KubbDriver.ts'
19
19
  import { fsStorage } from './storages/fsStorage.ts'
20
20
 
21
21
  /**
@@ -113,7 +113,7 @@ export type Config<TInput = Input> = {
113
113
  */
114
114
  parsers: Array<Parser>
115
115
  /**
116
- * Adapter that parses input files into the universal `InputNode` representation.
116
+ * Adapter that parses input files into the universal AST representation.
117
117
  * Use `@kubb/adapter-oas` for OpenAPI/Swagger or `@kubb/adapter-asyncapi` for other formats.
118
118
  *
119
119
  * When omitted, Kubb runs in plugin-only mode: `kubb:plugin:setup` fires and files
@@ -540,10 +540,10 @@ export type KubbBuildStartContext = {
540
540
  */
541
541
  adapter: Adapter
542
542
  /**
543
- * Parsed input node. For streaming builds the node is a synthetic empty shell
544
- * with only `meta` populated use `kubb:generate:schema` / `kubb:generate:operation` to observe individual nodes.
543
+ * Metadata about the parsed document (title, version, base URL, circular schema names, enum names).
544
+ * To observe individual schemas and operations use the `kubb:generate:schema` / `kubb:generate:operation` hooks.
545
545
  */
546
- inputNode: InputNode
546
+ meta: InputMeta | undefined
547
547
  /**
548
548
  * Looks up a registered plugin by name, typed by the plugin registry.
549
549
  */
@@ -855,7 +855,7 @@ export type BuildOutput = {
855
855
  /**
856
856
  * The plugin driver that orchestrated this build.
857
857
  */
858
- driver: PluginDriver
858
+ driver: KubbDriver
859
859
  /**
860
860
  * Elapsed milliseconds per plugin, keyed by plugin name.
861
861
  */
@@ -912,7 +912,7 @@ export type Kubb = {
912
912
  /**
913
913
  * Plugin driver managing all plugins. Available after `setup()` completes.
914
914
  */
915
- readonly driver: PluginDriver
915
+ readonly driver: KubbDriver
916
916
  /**
917
917
  * Resolved configuration with defaults applied. Available after `setup()` completes.
918
918
  */
@@ -933,7 +933,7 @@ export type Kubb = {
933
933
 
934
934
  type SetupResult = {
935
935
  hooks: AsyncEventEmitter<KubbHooks>
936
- driver: PluginDriver
936
+ driver: KubbDriver
937
937
  storage: Storage
938
938
  config: Config
939
939
  dispose: () => void
@@ -950,6 +950,7 @@ type SetupResult = {
950
950
  */
951
951
  function createSourcesView(storage: Storage): Storage {
952
952
  const paths = new Set<string>()
953
+
953
954
  return createStorage(() => ({
954
955
  name: `${storage.name}:sources`,
955
956
  async hasItem(key: string) {
@@ -987,7 +988,6 @@ async function setup(userConfig: UserConfig, options: SetupOptions = {}): Promis
987
988
  ...userConfig,
988
989
  root: userConfig.root || process.cwd(),
989
990
  parsers: userConfig.parsers ?? [],
990
- adapter: userConfig.adapter,
991
991
  output: {
992
992
  format: false,
993
993
  lint: false,
@@ -1004,10 +1004,10 @@ async function setup(userConfig: UserConfig, options: SetupOptions = {}): Promis
1004
1004
  : undefined,
1005
1005
  plugins: (userConfig.plugins ?? []) as unknown as Config['plugins'],
1006
1006
  }
1007
- const driver = new PluginDriver(config, {
1007
+ const driver = new KubbDriver(config, {
1008
1008
  hooks,
1009
1009
  })
1010
- const storage: Storage = createSourcesView(config.storage)
1010
+ const storage = createSourcesView(config.storage)
1011
1011
  const diagnosticInfo = getDiagnosticInfo()
1012
1012
 
1013
1013
  await hooks.emit('kubb:debug', {
@@ -1022,6 +1022,7 @@ async function setup(userConfig: UserConfig, options: SetupOptions = {}): Promis
1022
1022
  ` • Storage: ${config.storage.name}`,
1023
1023
  ` • Formatter: ${userConfig.output?.format || 'none'}`,
1024
1024
  ` • Linter: ${userConfig.output?.lint || 'none'}`,
1025
+ `Running adapter: ${config.adapter?.name || 'none'}`,
1025
1026
  'Environment:',
1026
1027
  Object.entries(diagnosticInfo)
1027
1028
  .map(([key, value]) => ` • ${key}: ${value}`)
@@ -1056,70 +1057,11 @@ async function setup(userConfig: UserConfig, options: SetupOptions = {}): Promis
1056
1057
  date: new Date(),
1057
1058
  logs: ['Cleaning output directories', ` • Output: ${config.output.path}`],
1058
1059
  })
1059
- await config.storage.clear(resolve(config.root, config.output.path))
1060
- }
1061
-
1062
- // Register middleware hooks after all plugin hooks are registered.
1063
- // Because AsyncEventEmitter calls listeners in registration order,
1064
- // middleware hooks for any event fire after all plugin hooks for that event.
1065
- // Handlers are tracked so they can be removed after each build (disposeMiddleware),
1066
- // preventing accumulation when multiple configs share the same hooks instance.
1067
- const middlewareListeners: Array<[keyof KubbHooks & string, (...args: never[]) => void | Promise<void>]> = []
1068
-
1069
- function registerMiddlewareHook<K extends keyof KubbHooks & string>(event: K, middlewareHooks: Middleware['hooks']) {
1070
- const handler = middlewareHooks[event]
1071
- if (handler) {
1072
- hooks.on(event, handler)
1073
- middlewareListeners.push([event, handler as (...args: never[]) => void | Promise<void>])
1074
- }
1075
- }
1076
1060
 
1077
- for (const middleware of config.middleware ?? []) {
1078
- for (const event of Object.keys(middleware.hooks) as Array<keyof KubbHooks & string>) {
1079
- registerMiddlewareHook(event, middleware.hooks)
1080
- }
1061
+ await config.storage.clear(resolve(config.root, config.output.path))
1081
1062
  }
1082
1063
 
1083
- if (config.adapter) {
1084
- const source = inputToAdapterSource(config)
1085
-
1086
- await hooks.emit('kubb:debug', {
1087
- date: new Date(),
1088
- logs: [`Running adapter: ${config.adapter.name}`],
1089
- })
1090
-
1091
- driver.adapter = config.adapter
1092
-
1093
- if (config.adapter.count && config.adapter.stream) {
1094
- const { schemas: schemaCount, operations: operationCount } = await config.adapter.count(source)
1095
-
1096
- if (schemaCount > STREAM_SCHEMA_THRESHOLD) {
1097
- driver.inputStreamNode = await config.adapter.stream(source)
1098
-
1099
- await hooks.emit('kubb:debug', {
1100
- date: new Date(),
1101
- logs: [
1102
- `✓ Adapter '${config.adapter.name}' streaming InputStreamNode`,
1103
- ` • Schemas: ${schemaCount} (threshold: ${STREAM_SCHEMA_THRESHOLD})`,
1104
- ` • Operations: ${operationCount}`,
1105
- ],
1106
- })
1107
- }
1108
- }
1109
-
1110
- if (!driver.inputStreamNode) {
1111
- driver.inputNode = await config.adapter.parse(source)
1112
-
1113
- await hooks.emit('kubb:debug', {
1114
- date: new Date(),
1115
- logs: [
1116
- `✓ Adapter '${config.adapter.name}' resolved InputNode`,
1117
- ` • Schemas: ${driver.inputNode.schemas.length}`,
1118
- ` • Operations: ${driver.inputNode.operations.length}`,
1119
- ],
1120
- })
1121
- }
1122
- }
1064
+ await driver.setup()
1123
1065
 
1124
1066
  return {
1125
1067
  config,
@@ -1132,368 +1074,301 @@ async function setup(userConfig: UserConfig, options: SetupOptions = {}): Promis
1132
1074
 
1133
1075
  function dispose() {
1134
1076
  driver.dispose()
1135
- for (const [event, handler] of middlewareListeners) {
1136
- hooks.off(event, handler as never)
1137
- }
1138
1077
  }
1139
1078
  }
1140
1079
 
1141
- type PluginStreamEntry = {
1142
- plugin: NormalizedPlugin
1143
- context: GeneratorContext
1144
- hrStart: ReturnType<typeof process.hrtime>
1145
- }
1080
+ type GeneratorEntry = { plugin: NormalizedPlugin; context: GeneratorContext; hrStart: ReturnType<typeof process.hrtime> }
1146
1081
 
1147
- type PluginState = {
1148
- plugin: NormalizedPlugin
1149
- generatorContext: GeneratorContext
1150
- generators: Generator[]
1151
- hrStart: ReturnType<typeof process.hrtime>
1152
- failed: boolean
1153
- error: Error | undefined
1154
- /**
1155
- * `true` when the plugin's options have no `include`, `exclude`, or `override`
1156
- * filters. The per-node `resolveOptions` call always returns the same `options`
1157
- * reference in that case, so the inner loop can skip it entirely.
1158
- */
1159
- optionsAreStatic: boolean
1160
- }
1082
+ async function safeBuild(setupResult: SetupResult): Promise<BuildOutput> {
1083
+ using _cleanup = setupResult
1084
+ const { driver, hooks, storage } = setupResult
1161
1085
 
1162
- /**
1163
- * Single-pass fan-out for streaming mode.
1164
- *
1165
- * Iterates `inputStreamNode.schemas` and `.operations` exactly once, distributing each
1166
- * node to every plugin in parallel. This replaces the N-pass-per-plugin pattern (where
1167
- * each plugin got its own `for await` iterator) with a single parse pass fanned to all
1168
- * plugins — eliminating the N×parse-time overhead for multi-plugin builds.
1169
- *
1170
- * Each plugin still gets independent `plugin:start` / `plugin:end` events and its own
1171
- * timing, but the schema and operation nodes are parsed only once total.
1172
- */
1173
- async function runPluginStreamHooks({
1174
- entries,
1175
- driver,
1176
- pluginTimings,
1177
- failedPlugins,
1178
- }: {
1179
- entries: PluginStreamEntry[]
1180
- driver: PluginDriver
1181
- pluginTimings: Map<string, number>
1182
- failedPlugins: Set<{ plugin: Plugin; error: Error }>
1183
- }): Promise<void> {
1184
- const inputStreamNode = driver.inputStreamNode!
1185
- function resolveRendererFor(gen: Generator, state: PluginState): RendererFactory | undefined {
1186
- return gen.renderer === null ? undefined : (gen.renderer ?? state.plugin.renderer ?? state.generatorContext.config.renderer)
1086
+ const failedPlugins = new Set<{ plugin: Plugin; error: Error }>()
1087
+ const pluginTimings = new Map<string, number>()
1088
+ const config = driver.config
1089
+ const writtenPaths = new Set<string>()
1090
+ const parsersMap = new Map<FileNode['extname'], Parser>()
1091
+ const fileProcessor = new FileProcessor()
1092
+
1093
+ for (const parser of config.parsers) {
1094
+ if (parser.extNames) {
1095
+ for (const extname of parser.extNames) {
1096
+ parsersMap.set(extname, parser)
1097
+ }
1098
+ }
1187
1099
  }
1188
1100
 
1189
- const states: PluginState[] = entries.map(({ plugin, context, hrStart }) => {
1190
- const { exclude, include, override } = plugin.options
1191
- const hasExclude = Array.isArray(exclude) && exclude.length > 0
1192
- const hasInclude = Array.isArray(include) && include.length > 0
1193
- const hasOverride = Array.isArray(override) && override.length > 0
1194
- return {
1195
- plugin,
1196
- generatorContext: { ...context, resolver: driver.getResolver(plugin.name) },
1197
- generators: plugin.generators ?? [],
1198
- hrStart,
1199
- failed: false,
1200
- error: undefined,
1201
- optionsAreStatic: !hasExclude && !hasInclude && !hasOverride,
1101
+ async function flushPendingFiles(): Promise<void> {
1102
+ const files = driver.fileManager.files.filter((f) => !writtenPaths.has(f.path))
1103
+ if (files.length === 0) {
1104
+ return
1202
1105
  }
1203
- })
1204
1106
 
1205
- async function dispatchSchema(state: PluginState, node: SchemaNode): Promise<void> {
1206
- if (state.failed) return
1107
+ await hooks.emit('kubb:debug', {
1108
+ date: new Date(),
1109
+ logs: [`Writing ${files.length} files...`],
1110
+ })
1207
1111
 
1208
- try {
1209
- const { plugin, generatorContext, generators } = state
1210
- const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
1211
- const { exclude, include, override } = plugin.options
1212
- const options: typeof plugin.options | null = state.optionsAreStatic
1213
- ? plugin.options
1214
- : generatorContext.resolver.resolveOptions(transformedNode, { options: plugin.options, exclude, include, override })
1112
+ await hooks.emit('kubb:files:processing:start', { files })
1215
1113
 
1216
- if (options === null) return
1114
+ const stream = fileProcessor.stream(files, { parsers: parsersMap, extension: config.output.extension })
1217
1115
 
1218
- const ctx = { ...generatorContext, options }
1219
- for (const gen of generators) {
1220
- if (!gen.schema) continue
1116
+ const queue: Array<Promise<void>> = []
1117
+ for (const { file, source, processed, total, percentage } of stream) {
1118
+ writtenPaths.add(file.path)
1119
+ queue.push(
1120
+ (async () => {
1121
+ await hooks.emit('kubb:file:processing:update', { file, source, processed, total, percentage, config })
1122
+ if (source) {
1123
+ await storage.setItem(file.path, source)
1124
+ }
1125
+ })(),
1126
+ )
1127
+ if (queue.length >= STREAM_FLUSH_EVERY) {
1128
+ await Promise.all(queue.splice(0))
1129
+ }
1130
+ }
1131
+ await Promise.all(queue)
1221
1132
 
1222
- const raw = gen.schema(transformedNode, ctx)
1223
- const result = isPromise(raw) ? await raw : raw
1224
- const applied = applyHookResult({ result, driver, rendererFactory: resolveRendererFor(gen, state) })
1133
+ await hooks.emit('kubb:files:processing:end', { files })
1134
+ await hooks.emit('kubb:debug', {
1135
+ date: new Date(),
1136
+ logs: [`✓ File write process completed for ${files.length} files`],
1137
+ })
1138
+ }
1225
1139
 
1226
- if (isPromise(applied)) await applied
1227
- }
1228
- await driver.hooks.emit('kubb:generate:schema', transformedNode, ctx)
1229
- } catch (caughtError) {
1230
- state.failed = true
1231
- state.error = caughtError as Error
1140
+ async function dispatchOperationsToGenerators(
1141
+ generators: Generator[],
1142
+ collectedOperations: OperationNode[],
1143
+ ctx: GeneratorContext,
1144
+ rendererFor: (gen: Generator) => RendererFactory | undefined,
1145
+ ): Promise<void> {
1146
+ for (const gen of generators) {
1147
+ if (!gen.operations) continue
1148
+ const result = await gen.operations(collectedOperations, ctx)
1149
+ await applyHookResult({ result, driver, rendererFactory: rendererFor(gen) })
1232
1150
  }
1151
+ await driver.hooks.emit('kubb:generate:operations', collectedOperations, ctx)
1233
1152
  }
1234
1153
 
1235
- async function dispatchOperation(state: PluginState, node: OperationNode): Promise<void> {
1236
- if (state.failed) return
1237
- try {
1238
- const { plugin, generatorContext, generators } = state
1239
- const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
1240
- const { exclude, include, override } = plugin.options
1241
- const options: typeof plugin.options | null = state.optionsAreStatic
1242
- ? plugin.options
1243
- : generatorContext.resolver.resolveOptions(transformedNode, { options: plugin.options, exclude, include, override })
1154
+ /**
1155
+ * Single-pass fan-out: iterates all schemas and operations once, distributing each node
1156
+ * to every generator-plugin in parallel. This replaces the N-pass-per-plugin pattern
1157
+ * (each plugin getting its own iterator) with one parse pass fanned to all plugins,
1158
+ * eliminating the N×parse-time overhead for multi-plugin builds.
1159
+ */
1160
+ async function runPlugins(entries: Array<GeneratorEntry>): Promise<void> {
1161
+ type PluginState = {
1162
+ plugin: NormalizedPlugin
1163
+ generatorContext: GeneratorContext
1164
+ generators: Generator[]
1165
+ hrStart: ReturnType<typeof process.hrtime>
1166
+ failed: boolean
1167
+ error: Error | undefined
1168
+ /**
1169
+ * `true` when the plugin's options have no `include`, `exclude`, or `override`
1170
+ * filters. The per-node `resolveOptions` call always returns the same `options`
1171
+ * reference in that case, so the inner loop can skip it entirely.
1172
+ */
1173
+ optionsAreStatic: boolean
1174
+ /**
1175
+ * Set when the plugin has operation-based includes (tag, operationId, path, method, contentType)
1176
+ * but no schemaName includes. Schema nodes whose name is not in this set are skipped,
1177
+ * matching the pruning behavior of the eager path.
1178
+ */
1179
+ allowedSchemaNames: Set<string> | undefined
1180
+ }
1244
1181
 
1245
- if (options === null) return
1182
+ const { schemas, operations } = driver.inputNode!
1183
+ const operationFilterTypes = new Set(['tag', 'operationId', 'path', 'method', 'contentType'])
1184
+ const states: PluginState[] = entries.map(({ plugin, context, hrStart }) => {
1185
+ const { exclude, include, override } = plugin.options
1186
+ const hasExclude = Array.isArray(exclude) && exclude.length > 0
1187
+ const hasInclude = Array.isArray(include) && include.length > 0
1188
+ const hasOverride = Array.isArray(override) && override.length > 0
1189
+ return {
1190
+ plugin,
1191
+ generatorContext: { ...context, resolver: driver.getResolver(plugin.name) },
1192
+ generators: plugin.generators ?? [],
1193
+ hrStart,
1194
+ failed: false,
1195
+ error: undefined,
1196
+ optionsAreStatic: !hasExclude && !hasInclude && !hasOverride,
1197
+ allowedSchemaNames: undefined,
1198
+ }
1199
+ })
1246
1200
 
1247
- const ctx = { ...generatorContext, options }
1201
+ // Pre-scan: compute allowedSchemaNames for plugins that use operation-based includes
1202
+ // without schemaName filters. Each AsyncIterable yields a fresh iterator on every call,
1203
+ // so consuming them here does not affect the main dispatch passes below.
1204
+ const pruningStates = states.filter(({ plugin }) => {
1205
+ const { include } = plugin.options
1206
+ return (include?.some(({ type }) => operationFilterTypes.has(type)) ?? false) && !(include?.some(({ type }) => type === 'schemaName') ?? false)
1207
+ })
1248
1208
 
1249
- for (const gen of generators) {
1250
- if (!gen.operation) continue
1209
+ if (pruningStates.length > 0) {
1210
+ // Known trade-off: computing the reachable-schema set for operation-based includes
1211
+ // requires the full schema graph in memory at once — there is no way to determine
1212
+ // transitive reachability from a single schema node in isolation.
1213
+ // `allSchemas` is released as soon as this block exits; it is never held past
1214
+ // the pruning pre-scan. The main dispatch passes below each get their own
1215
+ // fresh iterator from the AsyncIterable, so this consumption does not affect them.
1216
+ const allSchemas: SchemaNode[] = []
1217
+ for await (const schema of schemas) {
1218
+ allSchemas.push(schema)
1219
+ }
1251
1220
 
1252
- const raw = gen.operation(transformedNode, ctx)
1253
- const result = isPromise(raw) ? await raw : raw
1254
- const applied = applyHookResult({ result, driver, rendererFactory: resolveRendererFor(gen, state) })
1221
+ // Collect the included operations for each pruning plugin in one shared pass.
1222
+ const includedOpsByState = new Map<PluginState, OperationNode[]>(pruningStates.map((s) => [s, []]))
1223
+ for await (const operation of operations) {
1224
+ for (const state of pruningStates) {
1225
+ const { exclude, include, override } = state.plugin.options
1226
+ const options = state.generatorContext.resolver.resolveOptions(operation, { options: state.plugin.options, exclude, include, override })
1227
+ if (options !== null) includedOpsByState.get(state)?.push(operation)
1228
+ }
1229
+ }
1255
1230
 
1256
- if (isPromise(applied)) await applied
1231
+ // Derive the allowed schema name set per pruning plugin.
1232
+ for (const state of pruningStates) {
1233
+ state.allowedSchemaNames = collectUsedSchemaNames(includedOpsByState.get(state) ?? [], allSchemas)
1257
1234
  }
1258
- await driver.hooks.emit('kubb:generate:operation', transformedNode, ctx)
1259
- } catch (caughtError) {
1260
- state.failed = true
1261
- state.error = caughtError as Error
1262
1235
  }
1263
- }
1264
1236
 
1265
- for await (const node of inputStreamNode.schemas) {
1266
- // Plugins are dispatched concurrently; per-plugin work (the inner generator
1267
- // loop) stays sequential so `FileManager.upsert` ordering for any single
1268
- // plugin chain remains deterministic.
1269
- await Promise.all(states.map((state) => dispatchSchema(state, node)))
1270
- }
1271
-
1272
- const collectedOperations: OperationNode[] = []
1273
-
1274
- for await (const node of inputStreamNode.operations) {
1275
- collectedOperations.push(node)
1237
+ function resolveRendererFor(gen: Generator, state: PluginState): RendererFactory | undefined {
1238
+ return gen.renderer === null ? undefined : (gen.renderer ?? state.plugin.renderer ?? state.generatorContext.config.renderer)
1239
+ }
1276
1240
 
1277
- await Promise.all(states.map((state) => dispatchOperation(state, node)))
1278
- }
1241
+ async function dispatchSchema(state: PluginState, node: SchemaNode): Promise<void> {
1242
+ if (state.failed) return
1279
1243
 
1280
- // After stream: gen.operations for each plugin, then emit plugin:end
1281
- for (const state of states) {
1282
- if (!state.failed) {
1283
1244
  try {
1284
1245
  const { plugin, generatorContext, generators } = state
1285
- const ctx = { ...generatorContext, options: plugin.options }
1246
+
1247
+ const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
1248
+
1249
+ // Skip named top-level schemas not reachable from any included operation.
1250
+ if (state.allowedSchemaNames !== undefined && transformedNode.name && !state.allowedSchemaNames.has(transformedNode.name)) {
1251
+ return
1252
+ }
1253
+
1254
+ const { exclude, include, override } = plugin.options
1255
+ const options = state.optionsAreStatic
1256
+ ? plugin.options
1257
+ : generatorContext.resolver.resolveOptions(transformedNode, { options: plugin.options, exclude, include, override })
1258
+ if (options === null) return
1259
+
1260
+ const ctx = { ...generatorContext, options }
1286
1261
 
1287
1262
  for (const gen of generators) {
1288
- if (!gen.operations) continue
1289
- const result = await gen.operations(collectedOperations, ctx)
1290
- await applyHookResult({ result, driver, rendererFactory: resolveRendererFor(gen, state) })
1263
+ if (!gen.schema) continue
1264
+ const raw = gen.schema(transformedNode, ctx)
1265
+ const result = isPromise(raw) ? await raw : raw
1266
+ const applied = applyHookResult({ result, driver, rendererFactory: resolveRendererFor(gen, state) })
1267
+ if (isPromise(applied)) await applied
1291
1268
  }
1292
1269
 
1293
- await driver.hooks.emit('kubb:generate:operations', collectedOperations, ctx)
1270
+ await driver.hooks.emit('kubb:generate:schema', transformedNode, ctx)
1294
1271
  } catch (caughtError) {
1295
1272
  state.failed = true
1296
1273
  state.error = caughtError as Error
1297
1274
  }
1298
1275
  }
1299
1276
 
1300
- const duration = getElapsedMs(state.hrStart)
1301
- pluginTimings.set(state.plugin.name, duration)
1277
+ async function dispatchOperation(state: PluginState, node: OperationNode): Promise<void> {
1278
+ if (state.failed) return
1302
1279
 
1303
- await driver.hooks.emit('kubb:plugin:end', {
1304
- plugin: state.plugin,
1305
- duration,
1306
- success: !state.failed,
1307
- ...(state.failed && state.error ? { error: state.error } : {}),
1308
- config: driver.config,
1309
- get files() {
1310
- return driver.fileManager.files
1311
- },
1312
- upsertFile: (...files) => driver.fileManager.upsert(...files),
1313
- })
1280
+ try {
1281
+ const { plugin, generatorContext, generators } = state
1314
1282
 
1315
- if (state.failed && state.error) {
1316
- failedPlugins.add({ plugin: state.plugin, error: state.error })
1317
- }
1283
+ const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
1284
+ const { exclude, include, override } = plugin.options
1285
+ const options = state.optionsAreStatic
1286
+ ? plugin.options
1287
+ : generatorContext.resolver.resolveOptions(transformedNode, { options: plugin.options, exclude, include, override })
1288
+ if (options === null) return
1318
1289
 
1319
- await driver.hooks.emit('kubb:debug', {
1320
- date: new Date(),
1321
- logs: [state.failed ? '✗ Plugin start failed' : `✓ Plugin started successfully (${formatMs(duration)})`],
1322
- })
1323
- }
1324
- }
1290
+ const ctx = { ...generatorContext, options }
1325
1291
 
1326
- /**
1327
- * Walks the AST and dispatches nodes to a plugin's direct AST hooks
1328
- * (`schema`, `operation`, `operations`).
1329
- *
1330
- * When `include` contains only operation-scoped filters (`tag`, `operationId`, `path`,
1331
- * `method`, `contentType`) and no `schemaName` filter, the function pre-computes the set
1332
- * of top-level schema names transitively reachable from the included operations and skips
1333
- * schemas that fall outside that set. This ensures that component schemas referenced
1334
- * exclusively by excluded operations are not generated.
1335
- */
1336
- async function runPluginAstHooks(plugin: NormalizedPlugin, context: GeneratorContext): Promise<void> {
1337
- const { adapter, inputNode, resolver, driver } = context
1338
- const { exclude, include, override } = plugin.options
1292
+ for (const gen of generators) {
1293
+ if (!gen.operation) continue
1294
+ const raw = gen.operation(transformedNode, ctx)
1295
+ const result = isPromise(raw) ? await raw : raw
1296
+ const applied = applyHookResult({ result, driver, rendererFactory: resolveRendererFor(gen, state) })
1297
+ if (isPromise(applied)) await applied
1298
+ }
1339
1299
 
1340
- if (!adapter || !inputNode) {
1341
- throw new Error(`[${plugin.name}] No adapter found. Add an OAS adapter (e.g. adapterOas()) before this plugin in your Kubb config.`)
1342
- }
1300
+ await driver.hooks.emit('kubb:generate:operation', transformedNode, ctx)
1301
+ } catch (caughtError) {
1302
+ state.failed = true
1303
+ state.error = caughtError as Error
1304
+ }
1305
+ }
1343
1306
 
1344
- function resolveRenderer(gen: Generator): RendererFactory | undefined {
1345
- return gen.renderer === null ? undefined : (gen.renderer ?? plugin.renderer ?? context.config.renderer)
1346
- }
1307
+ // Batch schemas: SCHEMA_PARALLEL nodes dispatched across all plugins concurrently.
1308
+ // Per-plugin work inside dispatchSchema stays sequential so FileManager.upsert
1309
+ // ordering for any single plugin chain remains deterministic.
1310
+ await forBatches(schemas, (nodes) => Promise.all(nodes.flatMap((n) => states.map((state) => dispatchSchema(state, n)))), {
1311
+ concurrency: SCHEMA_PARALLEL,
1312
+ flush: flushPendingFiles,
1313
+ })
1347
1314
 
1348
- const generators = plugin.generators ?? []
1349
- const collectedOperations: Array<OperationNode> = []
1315
+ const collectedOperations: OperationNode[] = []
1350
1316
 
1351
- const generatorContext = {
1352
- ...context,
1353
- resolver: driver.getResolver(plugin.name),
1354
- }
1317
+ await forBatches(
1318
+ operations,
1319
+ (nodes) => {
1320
+ collectedOperations.push(...nodes)
1321
+ return Promise.all(nodes.flatMap((n) => states.map((state) => dispatchOperation(state, n))))
1322
+ },
1323
+ { concurrency: SCHEMA_PARALLEL, flush: flushPendingFiles },
1324
+ )
1355
1325
 
1356
- // When `include` has operation-based filters (tag, operationId, path, method, contentType)
1357
- // but no schema-level filters (schemaName), pre-compute the set of top-level schema names
1358
- // that are transitively referenced by the included operations. Schemas outside that set are
1359
- // skipped so that types belonging exclusively to excluded operations are not generated.
1360
- const operationFilterTypes = new Set(['tag', 'operationId', 'path', 'method', 'contentType'])
1361
- const hasOperationBasedIncludes = include?.some(({ type }) => operationFilterTypes.has(type)) ?? false
1362
- const hasSchemaNameIncludes = include?.some(({ type }) => type === 'schemaName') ?? false
1363
-
1364
- const allowedSchemaNames: Set<string> | undefined = (() => {
1365
- if (!hasOperationBasedIncludes || hasSchemaNameIncludes) return undefined
1366
- const includedOps = inputNode!.operations.filter((op) => resolver.resolveOptions(op, { options: plugin.options, exclude, include, override }) !== null)
1367
- return collectUsedSchemaNames(includedOps, inputNode!.schemas)
1368
- })()
1369
-
1370
- await walk(inputNode!, {
1371
- depth: 'shallow',
1372
- async schema(node) {
1373
- const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
1374
-
1375
- // Skip named top-level schemas that are not reachable from any included operation.
1376
- if (allowedSchemaNames !== undefined && transformedNode.name && !allowedSchemaNames.has(transformedNode.name)) {
1377
- return
1326
+ for (const state of states) {
1327
+ if (!state.failed) {
1328
+ try {
1329
+ const { plugin, generatorContext, generators } = state
1330
+ const ctx = { ...generatorContext, options: plugin.options }
1331
+ await dispatchOperationsToGenerators(generators, collectedOperations, ctx, (gen) => resolveRendererFor(gen, state))
1332
+ } catch (caughtError) {
1333
+ state.failed = true
1334
+ state.error = caughtError as Error
1335
+ }
1378
1336
  }
1379
1337
 
1380
- const options = resolver.resolveOptions(transformedNode, {
1381
- options: plugin.options,
1382
- exclude,
1383
- include,
1384
- override,
1385
- })
1386
- if (options === null) return
1387
-
1388
- const ctx = { ...generatorContext, options }
1389
-
1390
- await Promise.all(
1391
- generators
1392
- .filter((gen) => gen.schema)
1393
- .map(async (gen) => {
1394
- const result = await gen.schema!(transformedNode, ctx)
1395
- return applyHookResult({ result, driver, rendererFactory: resolveRenderer(gen) })
1396
- }),
1397
- )
1338
+ const duration = getElapsedMs(state.hrStart)
1339
+ pluginTimings.set(state.plugin.name, duration)
1398
1340
 
1399
- await driver.hooks.emit('kubb:generate:schema', transformedNode, ctx)
1400
- },
1401
- async operation(node) {
1402
- const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
1403
- const options = resolver.resolveOptions(transformedNode, {
1404
- options: plugin.options,
1405
- exclude,
1406
- include,
1407
- override,
1341
+ await driver.hooks.emit('kubb:plugin:end', {
1342
+ plugin: state.plugin,
1343
+ duration,
1344
+ success: !state.failed,
1345
+ ...(state.failed && state.error ? { error: state.error } : {}),
1346
+ config: driver.config,
1347
+ get files() {
1348
+ return driver.fileManager.files
1349
+ },
1350
+ upsertFile: (...files) => driver.fileManager.upsert(...files),
1408
1351
  })
1409
- if (options === null) return
1410
-
1411
- collectedOperations.push(transformedNode)
1412
-
1413
- const ctx = { ...generatorContext, options }
1414
-
1415
- await Promise.all(
1416
- generators
1417
- .filter((gen) => gen.operation)
1418
- .map(async (gen) => {
1419
- const result = await gen.operation!(transformedNode, ctx)
1420
- return applyHookResult({ result, driver, rendererFactory: resolveRenderer(gen) })
1421
- }),
1422
- )
1423
-
1424
- await driver.hooks.emit('kubb:generate:operation', transformedNode, ctx)
1425
- },
1426
- })
1427
-
1428
- if (collectedOperations.length > 0) {
1429
- const ctx = { ...generatorContext, options: plugin.options }
1430
-
1431
- for (const gen of generators) {
1432
- if (!gen.operations) continue
1433
- const result = await gen.operations(collectedOperations, ctx)
1434
- await applyHookResult({ result, driver, rendererFactory: resolveRenderer(gen) })
1435
- }
1436
-
1437
- await driver.hooks.emit('kubb:generate:operations', collectedOperations, ctx)
1438
- }
1439
- }
1440
-
1441
- async function safeBuild(setupResult: SetupResult): Promise<BuildOutput> {
1442
- using _cleanup = setupResult
1443
- const { driver, hooks, storage } = setupResult
1444
1352
 
1445
- const failedPlugins = new Set<{ plugin: Plugin; error: Error }>()
1446
- const pluginTimings = new Map<string, number>()
1447
- const config = driver.config
1448
- const writtenPaths = new Set<string>()
1449
- const parsersMap = new Map<FileNode['extname'], Parser>()
1450
- for (const parser of config.parsers) {
1451
- if (parser.extNames) {
1452
- for (const extname of parser.extNames) {
1453
- parsersMap.set(extname, parser)
1353
+ if (state.failed && state.error) {
1354
+ failedPlugins.add({ plugin: state.plugin, error: state.error })
1454
1355
  }
1455
- }
1456
- }
1457
- const fileProcessor = new FileProcessor()
1458
-
1459
- async function flushPendingFiles(): Promise<void> {
1460
- const files = driver.fileManager.files.filter((f) => !writtenPaths.has(f.path))
1461
- if (files.length === 0) {
1462
- return
1463
- }
1464
-
1465
- await hooks.emit('kubb:debug', {
1466
- date: new Date(),
1467
- logs: [`Writing ${files.length} files...`],
1468
- })
1469
-
1470
- await hooks.emit('kubb:files:processing:start', { files })
1471
1356
 
1472
- const stream = fileProcessor.stream(files, { parsers: parsersMap, extension: config.output.extension })
1473
-
1474
- for (const { file, source, processed, total, percentage } of stream) {
1475
- await hooks.emit('kubb:file:processing:update', { file, source, processed, total, percentage, config })
1476
- if (source) {
1477
- await storage.setItem(file.path, source)
1478
- }
1479
- writtenPaths.add(file.path)
1357
+ await driver.hooks.emit('kubb:debug', {
1358
+ date: new Date(),
1359
+ logs: [state.failed ? '✗ Plugin start failed' : `✓ Plugin started successfully (${formatMs(duration)})`],
1360
+ })
1480
1361
  }
1481
-
1482
- await hooks.emit('kubb:files:processing:end', { files })
1483
- await hooks.emit('kubb:debug', {
1484
- date: new Date(),
1485
- logs: [`✓ File write process completed for ${files.length} files`],
1486
- })
1487
1362
  }
1488
1363
 
1489
1364
  try {
1490
1365
  await driver.emitSetupHooks()
1491
1366
 
1492
- if (driver.adapter && (driver.inputNode || driver.inputStreamNode)) {
1367
+ if (driver.adapter && driver.inputNode) {
1493
1368
  await hooks.emit('kubb:build:start', {
1494
1369
  config,
1495
1370
  adapter: driver.adapter,
1496
- inputNode: driver.inputNode ?? { kind: 'Input' as const, schemas: [], operations: [], meta: driver.inputStreamNode?.meta },
1371
+ meta: driver.inputNode.meta,
1497
1372
  getPlugin: driver.getPlugin.bind(driver),
1498
1373
  get files() {
1499
1374
  return driver.fileManager.files
@@ -1502,70 +1377,73 @@ async function safeBuild(setupResult: SetupResult): Promise<BuildOutput> {
1502
1377
  })
1503
1378
  }
1504
1379
 
1505
- const inputStreamNode = driver.inputStreamNode
1506
- if (inputStreamNode) {
1507
- // Emit plugin:start for all plugins up front, collect generator-plugins
1508
- // for the fan-out pass, then handle non-generator plugins immediately.
1509
- const streamPluginEntries: PluginStreamEntry[] = []
1380
+ // Always run the plugin lifecycle so middleware hooks (kubb:plugin:start,
1381
+ // kubb:plugin:end) fire even when no adapter is configured.
1382
+ // Generator-plugins are collected for the stream fan-out pass below.
1383
+ const generatorPlugins: Array<GeneratorEntry> = []
1510
1384
 
1511
- for (const plugin of driver.plugins.values()) {
1512
- const context = driver.getContext(plugin)
1513
- const hrStart = process.hrtime()
1385
+ for (const plugin of driver.plugins.values()) {
1386
+ const context = driver.getContext(plugin)
1387
+ const hrStart = process.hrtime()
1514
1388
 
1389
+ try {
1515
1390
  await hooks.emit('kubb:plugin:start', { plugin })
1516
1391
  await hooks.emit('kubb:debug', {
1517
1392
  date: new Date(),
1518
1393
  logs: ['Starting plugin...', ` • Plugin Name: ${plugin.name}`],
1519
1394
  })
1520
-
1521
- if (plugin.generators?.length || driver.hasRegisteredGenerators(plugin.name)) {
1522
- streamPluginEntries.push({ plugin, context, hrStart })
1523
- continue
1524
- }
1525
- // No generators: plugin ran via setup hooks; finish it now.
1395
+ } catch (caughtError) {
1396
+ const error = caughtError as Error
1526
1397
  const duration = getElapsedMs(hrStart)
1527
1398
  pluginTimings.set(plugin.name, duration)
1528
1399
  await hooks.emit('kubb:plugin:end', {
1529
1400
  plugin,
1530
1401
  duration,
1531
- success: true,
1402
+ success: false,
1403
+ error,
1532
1404
  config,
1533
1405
  get files() {
1534
1406
  return driver.fileManager.files
1535
1407
  },
1536
1408
  upsertFile: (...files) => driver.fileManager.upsert(...files),
1537
1409
  })
1538
- await hooks.emit('kubb:debug', {
1539
- date: new Date(),
1540
- logs: [`✓ Plugin started successfully (${formatMs(duration)})`],
1541
- })
1410
+ failedPlugins.add({ plugin, error })
1411
+ continue
1542
1412
  }
1543
1413
 
1544
- if (streamPluginEntries.length > 0) {
1545
- await runPluginStreamHooks({ entries: streamPluginEntries, driver, pluginTimings, failedPlugins })
1546
- await flushPendingFiles()
1414
+ if (plugin.generators?.length || driver.hasEventGenerators(plugin.name)) {
1415
+ generatorPlugins.push({ plugin, context, hrStart })
1416
+ continue
1547
1417
  }
1548
- } else {
1549
- for (const plugin of driver.plugins.values()) {
1550
- const context = driver.getContext(plugin)
1551
- const hrStart = process.hrtime()
1552
-
1553
- try {
1554
- const timestamp = new Date()
1555
-
1556
- await hooks.emit('kubb:plugin:start', { plugin })
1557
- await hooks.emit('kubb:debug', {
1558
- date: timestamp,
1559
- logs: ['Starting plugin...', ` • Plugin Name: ${plugin.name}`],
1560
- })
1561
-
1562
- if (plugin.generators?.length || driver.hasRegisteredGenerators(plugin.name)) {
1563
- await runPluginAstHooks(plugin, context)
1564
- }
1418
+ // No generators: plugin ran via setup hooks; finish it now.
1419
+ const duration = getElapsedMs(hrStart)
1420
+ pluginTimings.set(plugin.name, duration)
1421
+ await hooks.emit('kubb:plugin:end', {
1422
+ plugin,
1423
+ duration,
1424
+ success: true,
1425
+ config,
1426
+ get files() {
1427
+ return driver.fileManager.files
1428
+ },
1429
+ upsertFile: (...files) => driver.fileManager.upsert(...files),
1430
+ })
1431
+ await hooks.emit('kubb:debug', {
1432
+ date: new Date(),
1433
+ logs: [`✓ Plugin started successfully (${formatMs(duration)})`],
1434
+ })
1435
+ }
1565
1436
 
1437
+ if (generatorPlugins.length > 0) {
1438
+ if (driver.inputNode) {
1439
+ // Normal path: fan-out schemas and operations to all generator-plugins in one pass.
1440
+ await withDrain(() => runPlugins(generatorPlugins), flushPendingFiles)
1441
+ } else {
1442
+ // No adapter configured — generator-plugins have nothing to process.
1443
+ // Still emit plugin:end so middleware hooks (e.g. barrel) complete their lifecycle.
1444
+ for (const { plugin, hrStart } of generatorPlugins) {
1566
1445
  const duration = getElapsedMs(hrStart)
1567
1446
  pluginTimings.set(plugin.name, duration)
1568
-
1569
1447
  await hooks.emit('kubb:plugin:end', {
1570
1448
  plugin,
1571
1449
  duration,
@@ -1576,42 +1454,7 @@ async function safeBuild(setupResult: SetupResult): Promise<BuildOutput> {
1576
1454
  },
1577
1455
  upsertFile: (...files) => driver.fileManager.upsert(...files),
1578
1456
  })
1579
-
1580
- await hooks.emit('kubb:debug', {
1581
- date: new Date(),
1582
- logs: [`✓ Plugin started successfully (${formatMs(duration)})`],
1583
- })
1584
- } catch (caughtError) {
1585
- const error = caughtError as Error
1586
- const errorTimestamp = new Date()
1587
- const duration = getElapsedMs(hrStart)
1588
-
1589
- await hooks.emit('kubb:plugin:end', {
1590
- plugin,
1591
- duration,
1592
- success: false,
1593
- error,
1594
- config,
1595
- get files() {
1596
- return driver.fileManager.files
1597
- },
1598
- upsertFile: (...files) => driver.fileManager.upsert(...files),
1599
- })
1600
-
1601
- await hooks.emit('kubb:debug', {
1602
- date: errorTimestamp,
1603
- logs: [
1604
- '✗ Plugin start failed',
1605
- ` • Plugin Name: ${plugin.name}`,
1606
- ` • Error: ${error.constructor.name} - ${error.message}`,
1607
- ' • Stack Trace:',
1608
- error.stack || 'No stack trace available',
1609
- ],
1610
- })
1611
-
1612
- failedPlugins.add({ plugin, error })
1613
1457
  }
1614
- await flushPendingFiles()
1615
1458
  }
1616
1459
  }
1617
1460
 
@@ -1700,25 +1543,6 @@ export function isInputPath(config: Config | UserConfig | undefined): config is
1700
1543
  return typeof config?.input === 'object' && config.input !== null && 'path' in config.input
1701
1544
  }
1702
1545
 
1703
- function inputToAdapterSource(config: Config): AdapterSource {
1704
- const input = config.input
1705
- if (!input) {
1706
- throw new Error('[kubb] input is required when using an adapter. Provide input.path or input.data in your config.')
1707
- }
1708
-
1709
- if ('data' in input) {
1710
- return { type: 'data', data: input.data }
1711
- }
1712
-
1713
- if (new URLPath(input.path).isURL) {
1714
- return { type: 'path', path: input.path }
1715
- }
1716
-
1717
- const resolved = resolve(config.root, input.path)
1718
-
1719
- return { type: 'path', path: resolved }
1720
- }
1721
-
1722
1546
  type CreateKubbOptions = {
1723
1547
  hooks?: AsyncEventEmitter<KubbHooks>
1724
1548
  }