@kubb/core 5.0.0-beta.15 → 5.0.0-beta.16

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
@@ -2,10 +2,10 @@ import { resolve } from 'node:path'
2
2
  import { version as nodeVersion } from 'node:process'
3
3
  import type { PossiblePromise } from '@internals/utils'
4
4
  import { AsyncEventEmitter, BuildError, exists, formatMs, getElapsedMs, URLPath } from '@internals/utils'
5
- import type { FileNode, InputNode, OperationNode, SchemaNode } from '@kubb/ast'
5
+ import type { FileNode, InputNode, InputStreamNode, OperationNode, SchemaNode } from '@kubb/ast'
6
6
  import { collectUsedSchemaNames, transform, walk } from '@kubb/ast'
7
7
  import { version as KubbVersion } from '../package.json'
8
- import { DEFAULT_BANNER, DEFAULT_EXTENSION, DEFAULT_STUDIO_URL } from './constants.ts'
8
+ import { DEFAULT_BANNER, DEFAULT_EXTENSION, DEFAULT_STUDIO_URL, STREAM_FLUSH_EVERY, STREAM_SCHEMA_THRESHOLD } from './constants.ts'
9
9
  import type { Adapter, AdapterSource } from './createAdapter.ts'
10
10
  import type { RendererFactory } from './createRenderer.ts'
11
11
  import { createStorage, type Storage } from './createStorage.ts'
@@ -674,7 +674,7 @@ export type CLIOptions = {
674
674
 
675
675
  /**
676
676
  * All accepted forms of a Kubb configuration.
677
- * Accepts `Config`/`Config[]`/promise or a factory (optionally receiving `TCliOptions`).
677
+ * Accepts `Config`/`Config[]`/promise or a factory (optionally receiving `TCliOptions`.
678
678
  */
679
679
  export type PossibleConfig<TCliOptions = undefined> =
680
680
  | PossiblePromise<Config | Config[]>
@@ -929,16 +929,45 @@ async function setup(userConfig: UserConfig, options: SetupOptions = {}): Promis
929
929
  })
930
930
 
931
931
  driver.adapter = config.adapter
932
- driver.inputNode = await config.adapter.parse(source)
933
932
 
934
- await hooks.emit('kubb:debug', {
935
- date: new Date(),
936
- logs: [
937
- `✓ Adapter '${config.adapter.name}' resolved InputNode`,
938
- ` • Schemas: ${driver.inputNode.schemas.length}`,
939
- ` • Operations: ${driver.inputNode.operations.length}`,
940
- ],
941
- })
933
+ if (config.adapter.count && config.adapter.stream) {
934
+ const { schemas: schemaCount, operations: operationCount } = await config.adapter.count(source)
935
+
936
+ if (schemaCount > STREAM_SCHEMA_THRESHOLD) {
937
+ driver.inputStreamNode = await config.adapter.stream(source)
938
+
939
+ await hooks.emit('kubb:debug', {
940
+ date: new Date(),
941
+ logs: [
942
+ `✓ Adapter '${config.adapter.name}' streaming InputStreamNode`,
943
+ ` • Schemas: ${schemaCount} (threshold: ${STREAM_SCHEMA_THRESHOLD})`,
944
+ ` • Operations: ${operationCount}`,
945
+ ],
946
+ })
947
+ } else {
948
+ driver.inputNode = await config.adapter.parse(source)
949
+
950
+ await hooks.emit('kubb:debug', {
951
+ date: new Date(),
952
+ logs: [
953
+ `✓ Adapter '${config.adapter.name}' resolved InputNode`,
954
+ ` • Schemas: ${driver.inputNode.schemas.length}`,
955
+ ` • Operations: ${driver.inputNode.operations.length}`,
956
+ ],
957
+ })
958
+ }
959
+ } else {
960
+ driver.inputNode = await config.adapter.parse(source)
961
+
962
+ await hooks.emit('kubb:debug', {
963
+ date: new Date(),
964
+ logs: [
965
+ `✓ Adapter '${config.adapter.name}' resolved InputNode`,
966
+ ` • Schemas: ${driver.inputNode.schemas.length}`,
967
+ ` • Operations: ${driver.inputNode.operations.length}`,
968
+ ],
969
+ })
970
+ }
942
971
  }
943
972
 
944
973
  return {
@@ -965,6 +994,156 @@ async function setup(userConfig: UserConfig, options: SetupOptions = {}): Promis
965
994
  * schemas that fall outside that set. This ensures that component schemas referenced
966
995
  * exclusively by excluded operations are not generated.
967
996
  */
997
+ type PluginStreamEntry = {
998
+ plugin: NormalizedPlugin
999
+ context: GeneratorContext
1000
+ hrStart: ReturnType<typeof process.hrtime>
1001
+ }
1002
+
1003
+ /**
1004
+ * Single-pass fan-out for streaming mode.
1005
+ *
1006
+ * Iterates `inputStreamNode.schemas` and `.operations` exactly once, distributing each
1007
+ * node to every plugin in parallel. This replaces the N-pass-per-plugin pattern (where
1008
+ * each plugin got its own `for await` iterator) with a single parse pass fanned to all
1009
+ * plugins — eliminating the N×parse-time overhead for multi-plugin builds.
1010
+ *
1011
+ * Each plugin still gets independent `plugin:start` / `plugin:end` events and its own
1012
+ * timing, but the schema and operation nodes are parsed only once total.
1013
+ */
1014
+ async function runPluginStreamHooks(
1015
+ inputStreamNode: InputStreamNode,
1016
+ entries: PluginStreamEntry[],
1017
+ driver: PluginDriver,
1018
+ hooks: AsyncEventEmitter<KubbHooks>,
1019
+ config: Config,
1020
+ pluginTimings: Map<string, number>,
1021
+ failedPlugins: Set<{ plugin: Plugin; error: Error }>,
1022
+ flushPendingFiles: () => Promise<void>,
1023
+ ): Promise<void> {
1024
+ type PluginState = {
1025
+ plugin: NormalizedPlugin
1026
+ generatorContext: GeneratorContext
1027
+ generators: Generator[]
1028
+ hrStart: ReturnType<typeof process.hrtime>
1029
+ failed: boolean
1030
+ error: Error | undefined
1031
+ }
1032
+
1033
+ function resolveRendererFor(gen: Generator, state: PluginState): RendererFactory | undefined {
1034
+ return gen.renderer === null ? undefined : (gen.renderer ?? state.plugin.renderer ?? state.generatorContext.config.renderer)
1035
+ }
1036
+
1037
+ const states: PluginState[] = entries.map(({ plugin, context, hrStart }) => ({
1038
+ plugin,
1039
+ generatorContext: { ...context, resolver: driver.getResolver(plugin.name) },
1040
+ generators: plugin.generators ?? [],
1041
+ hrStart,
1042
+ failed: false,
1043
+ error: undefined,
1044
+ }))
1045
+
1046
+ let schemasProcessed = 0
1047
+ for await (const node of inputStreamNode.schemas) {
1048
+ for (const state of states) {
1049
+ if (state.failed) continue
1050
+ try {
1051
+ const { plugin, generatorContext, generators } = state
1052
+ const { exclude, include, override } = plugin.options
1053
+ const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
1054
+ const options = generatorContext.resolver.resolveOptions(transformedNode, { options: plugin.options, exclude, include, override })
1055
+ if (options === null) continue
1056
+
1057
+ const ctx = { ...generatorContext, options }
1058
+ for (const gen of generators) {
1059
+ if (!gen.schema) continue
1060
+ const result = await gen.schema(transformedNode, ctx)
1061
+ await applyHookResult(result, driver, resolveRendererFor(gen, state))
1062
+ }
1063
+ await driver.hooks.emit('kubb:generate:schema', transformedNode, ctx)
1064
+ } catch (caughtError) {
1065
+ state.failed = true
1066
+ state.error = caughtError as Error
1067
+ }
1068
+ }
1069
+ schemasProcessed++
1070
+ if (schemasProcessed % STREAM_FLUSH_EVERY === 0) {
1071
+ await flushPendingFiles()
1072
+ }
1073
+ }
1074
+
1075
+ const collectedOperations: OperationNode[] = []
1076
+ for await (const node of inputStreamNode.operations) {
1077
+ collectedOperations.push(node)
1078
+ for (const state of states) {
1079
+ if (state.failed) continue
1080
+ try {
1081
+ const { plugin, generatorContext, generators } = state
1082
+ const { exclude, include, override } = plugin.options
1083
+ const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
1084
+ const options = generatorContext.resolver.resolveOptions(transformedNode, { options: plugin.options, exclude, include, override })
1085
+ if (options === null) continue
1086
+
1087
+ const ctx = { ...generatorContext, options }
1088
+ for (const gen of generators) {
1089
+ if (!gen.operation) continue
1090
+ const result = await gen.operation(transformedNode, ctx)
1091
+ await applyHookResult(result, driver, resolveRendererFor(gen, state))
1092
+ }
1093
+ await driver.hooks.emit('kubb:generate:operation', transformedNode, ctx)
1094
+ } catch (caughtError) {
1095
+ state.failed = true
1096
+ state.error = caughtError as Error
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ // After stream: gen.operations for each plugin, then emit plugin:end
1102
+ for (const state of states) {
1103
+ if (!state.failed) {
1104
+ try {
1105
+ const { plugin, generatorContext, generators } = state
1106
+ const ctx = { ...generatorContext, options: plugin.options }
1107
+ for (const gen of generators) {
1108
+ if (!gen.operations) continue
1109
+ const result = await gen.operations(collectedOperations, ctx)
1110
+ await applyHookResult(result, driver, resolveRendererFor(gen, state))
1111
+ }
1112
+ await driver.hooks.emit('kubb:generate:operations', collectedOperations, ctx)
1113
+ } catch (caughtError) {
1114
+ state.failed = true
1115
+ state.error = caughtError as Error
1116
+ }
1117
+ }
1118
+
1119
+ const duration = getElapsedMs(state.hrStart)
1120
+ pluginTimings.set(state.plugin.name, duration)
1121
+
1122
+ await hooks.emit('kubb:plugin:end', {
1123
+ plugin: state.plugin,
1124
+ duration,
1125
+ success: !state.failed,
1126
+ ...(state.failed && state.error ? { error: state.error } : {}),
1127
+ config,
1128
+ get files() {
1129
+ return driver.fileManager.files
1130
+ },
1131
+ upsertFile: (...files) => driver.fileManager.upsert(...files),
1132
+ })
1133
+
1134
+ if (state.failed && state.error) {
1135
+ failedPlugins.add({ plugin: state.plugin, error: state.error })
1136
+ }
1137
+
1138
+ await hooks.emit('kubb:debug', {
1139
+ date: new Date(),
1140
+ logs: [state.failed ? '✗ Plugin start failed' : `✓ Plugin started successfully (${formatMs(duration)})`],
1141
+ })
1142
+ }
1143
+
1144
+ await flushPendingFiles()
1145
+ }
1146
+
968
1147
  async function runPluginAstHooks(plugin: NormalizedPlugin, context: GeneratorContext): Promise<void> {
969
1148
  const { adapter, inputNode, resolver, driver } = context
970
1149
  const { exclude, include, override } = plugin.options
@@ -985,6 +1164,7 @@ async function runPluginAstHooks(plugin: NormalizedPlugin, context: GeneratorCon
985
1164
  resolver: driver.getResolver(plugin.name),
986
1165
  }
987
1166
 
1167
+ // ── BATCH PATH ────────────────────────────────────────────────────────────
988
1168
  // When `include` has operation-based filters (tag, operationId, path, method, contentType)
989
1169
  // but no schema-level filters (schemaName), pre-compute the set of top-level schema names
990
1170
  // that are transitively referenced by the included operations. Schemas outside that set are
@@ -995,11 +1175,11 @@ async function runPluginAstHooks(plugin: NormalizedPlugin, context: GeneratorCon
995
1175
 
996
1176
  let allowedSchemaNames: Set<string> | undefined
997
1177
  if (hasOperationBasedIncludes && !hasSchemaNameIncludes) {
998
- const includedOps = inputNode.operations.filter((op) => resolver.resolveOptions(op, { options: plugin.options, exclude, include, override }) !== null)
999
- allowedSchemaNames = collectUsedSchemaNames(includedOps, inputNode.schemas)
1178
+ const includedOps = inputNode!.operations.filter((op) => resolver.resolveOptions(op, { options: plugin.options, exclude, include, override }) !== null)
1179
+ allowedSchemaNames = collectUsedSchemaNames(includedOps, inputNode!.schemas)
1000
1180
  }
1001
1181
 
1002
- await walk(inputNode, {
1182
+ await walk(inputNode!, {
1003
1183
  depth: 'shallow',
1004
1184
  async schema(node) {
1005
1185
  const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
@@ -1081,34 +1261,8 @@ async function safeBuild(setupResult: SetupResult): Promise<BuildOutput> {
1081
1261
  }
1082
1262
  const fileProcessor = new FileProcessor()
1083
1263
 
1084
- fileProcessor.events.on('start', async (processingFiles) => {
1085
- await hooks.emit('kubb:files:processing:start', { files: processingFiles })
1086
- })
1087
-
1088
- fileProcessor.events.on('update', async ({ file, source, processed, total, percentage }) => {
1089
- await hooks.emit('kubb:file:processing:update', {
1090
- file,
1091
- source,
1092
- processed,
1093
- total,
1094
- percentage,
1095
- config,
1096
- })
1097
- if (source) {
1098
- await storage.setItem(file.path, source)
1099
- }
1100
- })
1101
-
1102
- fileProcessor.events.on('end', async (processed) => {
1103
- await hooks.emit('kubb:files:processing:end', { files: processed })
1104
- await hooks.emit('kubb:debug', {
1105
- date: new Date(),
1106
- logs: [`✓ File write process completed for ${processed.length} files`],
1107
- })
1108
- })
1109
-
1110
- async function flushPendingFiles(): Promise<void> {
1111
- const files = driver.fileManager.files.filter((f) => !writtenPaths.has(f.path))
1264
+ async function flushPendingFiles(snapshot?: ReadonlySet<string>): Promise<void> {
1265
+ const files = driver.fileManager.files.filter((f) => !writtenPaths.has(f.path) && (!snapshot || !snapshot.has(f.path)))
1112
1266
  if (files.length === 0) {
1113
1267
  return
1114
1268
  }
@@ -1118,25 +1272,33 @@ async function safeBuild(setupResult: SetupResult): Promise<BuildOutput> {
1118
1272
  logs: [`Writing ${files.length} files...`],
1119
1273
  })
1120
1274
 
1121
- await fileProcessor.run(files, {
1122
- parsers: parsersMap,
1123
- mode: 'parallel',
1124
- extension: config.output.extension,
1125
- })
1275
+ await hooks.emit('kubb:files:processing:start', { files })
1126
1276
 
1127
- for (const file of files) {
1277
+ const stream = fileProcessor.stream(files, { parsers: parsersMap, extension: config.output.extension })
1278
+
1279
+ for await (const { file, source, processed, total, percentage } of stream) {
1280
+ await hooks.emit('kubb:file:processing:update', { file, source, processed, total, percentage, config })
1281
+ if (source) {
1282
+ await storage.setItem(file.path, source)
1283
+ }
1128
1284
  writtenPaths.add(file.path)
1129
1285
  }
1286
+
1287
+ await hooks.emit('kubb:files:processing:end', { files })
1288
+ await hooks.emit('kubb:debug', {
1289
+ date: new Date(),
1290
+ logs: [`✓ File write process completed for ${files.length} files`],
1291
+ })
1130
1292
  }
1131
1293
 
1132
1294
  try {
1133
1295
  await driver.emitSetupHooks()
1134
1296
 
1135
- if (driver.adapter && driver.inputNode) {
1297
+ if (driver.adapter && (driver.inputNode || driver.inputStreamNode)) {
1136
1298
  await hooks.emit('kubb:build:start', {
1137
1299
  config,
1138
1300
  adapter: driver.adapter,
1139
- inputNode: driver.inputNode,
1301
+ inputNode: driver.inputNode ?? { kind: 'Input' as const, schemas: [], operations: [], meta: driver.inputStreamNode?.meta },
1140
1302
  getPlugin: driver.getPlugin.bind(driver),
1141
1303
  get files() {
1142
1304
  return driver.fileManager.files
@@ -1145,70 +1307,117 @@ async function safeBuild(setupResult: SetupResult): Promise<BuildOutput> {
1145
1307
  })
1146
1308
  }
1147
1309
 
1148
- for (const plugin of driver.plugins.values()) {
1149
- const context = driver.getContext(plugin)
1150
- const hrStart = process.hrtime()
1310
+ const inputStreamNode = driver.inputStreamNode
1311
+ if (inputStreamNode) {
1312
+ // ── STREAMING: fan-out single-pass ────────────────────────────────────
1313
+ // Emit plugin:start for all plugins up front, collect generator-plugins
1314
+ // for the fan-out pass, then handle non-generator plugins immediately.
1315
+ const streamPluginEntries: PluginStreamEntry[] = []
1151
1316
 
1152
- try {
1153
- const timestamp = new Date()
1317
+ for (const plugin of driver.plugins.values()) {
1318
+ const context = driver.getContext(plugin)
1319
+ const hrStart = process.hrtime()
1154
1320
 
1155
1321
  await hooks.emit('kubb:plugin:start', { plugin })
1156
1322
  await hooks.emit('kubb:debug', {
1157
- date: timestamp,
1323
+ date: new Date(),
1158
1324
  logs: ['Starting plugin...', ` • Plugin Name: ${plugin.name}`],
1159
1325
  })
1160
1326
 
1161
1327
  if (plugin.generators?.length || driver.hasRegisteredGenerators(plugin.name)) {
1162
- await runPluginAstHooks(plugin, context)
1328
+ streamPluginEntries.push({ plugin, context, hrStart })
1329
+ } else {
1330
+ // No generators: plugin ran via setup hooks; finish it now.
1331
+ const duration = getElapsedMs(hrStart)
1332
+ pluginTimings.set(plugin.name, duration)
1333
+ await hooks.emit('kubb:plugin:end', {
1334
+ plugin,
1335
+ duration,
1336
+ success: true,
1337
+ config,
1338
+ get files() {
1339
+ return driver.fileManager.files
1340
+ },
1341
+ upsertFile: (...files) => driver.fileManager.upsert(...files),
1342
+ })
1343
+ await hooks.emit('kubb:debug', {
1344
+ date: new Date(),
1345
+ logs: [`✓ Plugin started successfully (${formatMs(duration)})`],
1346
+ })
1163
1347
  }
1348
+ }
1164
1349
 
1165
- const duration = getElapsedMs(hrStart)
1166
- pluginTimings.set(plugin.name, duration)
1167
-
1168
- await hooks.emit('kubb:plugin:end', {
1169
- plugin,
1170
- duration,
1171
- success: true,
1172
- config,
1173
- get files() {
1174
- return driver.fileManager.files
1175
- },
1176
- upsertFile: (...files) => driver.fileManager.upsert(...files),
1177
- })
1178
-
1179
- await hooks.emit('kubb:debug', {
1180
- date: new Date(),
1181
- logs: [`✓ Plugin started successfully (${formatMs(duration)})`],
1182
- })
1183
- } catch (caughtError) {
1184
- const error = caughtError as Error
1185
- const errorTimestamp = new Date()
1186
- const duration = getElapsedMs(hrStart)
1187
-
1188
- await hooks.emit('kubb:plugin:end', {
1189
- plugin,
1190
- duration,
1191
- success: false,
1192
- error,
1193
- config,
1194
- get files() {
1195
- return driver.fileManager.files
1196
- },
1197
- upsertFile: (...files) => driver.fileManager.upsert(...files),
1198
- })
1199
-
1200
- await hooks.emit('kubb:debug', {
1201
- date: errorTimestamp,
1202
- logs: [
1203
- '✗ Plugin start failed',
1204
- ` • Plugin Name: ${plugin.name}`,
1205
- ` • Error: ${error.constructor.name} - ${error.message}`,
1206
- ' • Stack Trace:',
1207
- error.stack || 'No stack trace available',
1208
- ],
1209
- })
1210
-
1211
- failedPlugins.add({ plugin, error })
1350
+ if (streamPluginEntries.length > 0) {
1351
+ await runPluginStreamHooks(inputStreamNode, streamPluginEntries, driver, hooks, config, pluginTimings, failedPlugins, flushPendingFiles)
1352
+ }
1353
+ } else {
1354
+ // ── BATCH: existing per-plugin sequential loop ────────────────────────
1355
+ for (const plugin of driver.plugins.values()) {
1356
+ const context = driver.getContext(plugin)
1357
+ const hrStart = process.hrtime()
1358
+
1359
+ try {
1360
+ const timestamp = new Date()
1361
+
1362
+ await hooks.emit('kubb:plugin:start', { plugin })
1363
+ await hooks.emit('kubb:debug', {
1364
+ date: timestamp,
1365
+ logs: ['Starting plugin...', ` • Plugin Name: ${plugin.name}`],
1366
+ })
1367
+
1368
+ if (plugin.generators?.length || driver.hasRegisteredGenerators(plugin.name)) {
1369
+ await runPluginAstHooks(plugin, context)
1370
+ }
1371
+
1372
+ const duration = getElapsedMs(hrStart)
1373
+ pluginTimings.set(plugin.name, duration)
1374
+
1375
+ await hooks.emit('kubb:plugin:end', {
1376
+ plugin,
1377
+ duration,
1378
+ success: true,
1379
+ config,
1380
+ get files() {
1381
+ return driver.fileManager.files
1382
+ },
1383
+ upsertFile: (...files) => driver.fileManager.upsert(...files),
1384
+ })
1385
+
1386
+ await hooks.emit('kubb:debug', {
1387
+ date: new Date(),
1388
+ logs: [`✓ Plugin started successfully (${formatMs(duration)})`],
1389
+ })
1390
+ } catch (caughtError) {
1391
+ const error = caughtError as Error
1392
+ const errorTimestamp = new Date()
1393
+ const duration = getElapsedMs(hrStart)
1394
+
1395
+ await hooks.emit('kubb:plugin:end', {
1396
+ plugin,
1397
+ duration,
1398
+ success: false,
1399
+ error,
1400
+ config,
1401
+ get files() {
1402
+ return driver.fileManager.files
1403
+ },
1404
+ upsertFile: (...files) => driver.fileManager.upsert(...files),
1405
+ })
1406
+
1407
+ await hooks.emit('kubb:debug', {
1408
+ date: errorTimestamp,
1409
+ logs: [
1410
+ '✗ Plugin start failed',
1411
+ ` • Plugin Name: ${plugin.name}`,
1412
+ ` • Error: ${error.constructor.name} - ${error.message}`,
1413
+ ' • Stack Trace:',
1414
+ error.stack || 'No stack trace available',
1415
+ ],
1416
+ })
1417
+
1418
+ failedPlugins.add({ plugin, error })
1419
+ }
1420
+ await flushPendingFiles()
1212
1421
  }
1213
1422
  }
1214
1423
 
@@ -160,7 +160,10 @@ export type Generator<TOptions extends PluginFactoryOptions = PluginFactoryOptio
160
160
  * `ctx` carries the plugin context with `adapter` and `inputNode` guaranteed present,
161
161
  * plus `ctx.options` with the plugin-level options for the batch call.
162
162
  */
163
- operations?: (nodes: Array<OperationNode>, ctx: GeneratorContext<TOptions>) => PossiblePromise<TElement | Array<FileNode> | void>
163
+ operations?: (
164
+ nodes: Array<OperationNode> | AsyncIterable<OperationNode>,
165
+ ctx: GeneratorContext<TOptions>,
166
+ ) => PossiblePromise<TElement | Array<FileNode> | void>
164
167
  }
165
168
 
166
169
  /**
package/src/types.ts CHANGED
@@ -32,7 +32,7 @@ export type {
32
32
  } from './createKubb.ts'
33
33
  export type { Renderer, RendererFactory } from './createRenderer.ts'
34
34
  export type { Storage } from './createStorage.ts'
35
- export type { FileProcessorEvents } from './FileProcessor.ts'
35
+ export type { FileProcessorEvents, ParsedFile } from './FileProcessor.ts'
36
36
  export type { Generator, GeneratorContext } from './defineGenerator.ts'
37
37
  export type { Logger, LoggerContext, LoggerOptions, UserLogger } from './defineLogger.ts'
38
38
  export type { Middleware } from './defineMiddleware.ts'