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

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,21 +1,19 @@
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, forBatches, formatMs, getElapsedMs, URLPath, isPromise, withDrain } from '@internals/utils'
4
+ import { AsyncEventEmitter, BuildError, exists, URLPath } from '@internals/utils'
5
5
  import type { FileNode, InputMeta, OperationNode, SchemaNode } from '@kubb/ast'
6
- import { collectUsedSchemaNames, transform } from '@kubb/ast'
7
6
  import { version as KubbVersion } from '../package.json'
8
- import { DEFAULT_BANNER, DEFAULT_EXTENSION, DEFAULT_STUDIO_URL, SCHEMA_PARALLEL, STREAM_FLUSH_EVERY } from './constants.ts'
7
+ import { DEFAULT_BANNER, DEFAULT_EXTENSION, DEFAULT_STUDIO_URL } from './constants.ts'
9
8
  import type { Adapter } from './createAdapter.ts'
10
9
  import type { RendererFactory } from './createRenderer.ts'
11
10
  import { createStorage, type Storage } from './createStorage.ts'
12
- import type { GeneratorContext, Generator } from './defineGenerator.ts'
11
+ import type { GeneratorContext } from './defineGenerator.ts'
13
12
  import type { Middleware } from './defineMiddleware.ts'
14
13
  import type { Parser } from './defineParser.ts'
15
- import type { KubbPluginEndContext, KubbPluginSetupContext, KubbPluginStartContext, NormalizedPlugin, Plugin } from './definePlugin.ts'
16
- import { FileProcessor } from './FileProcessor.ts'
14
+ import type { KubbPluginEndContext, KubbPluginSetupContext, KubbPluginStartContext, Plugin } from './definePlugin.ts'
17
15
 
18
- import { applyHookResult, KubbDriver } from './KubbDriver.ts'
16
+ import { KubbDriver } from './KubbDriver.ts'
19
17
  import { fsStorage } from './storages/fsStorage.ts'
20
18
 
21
19
  /**
@@ -517,7 +515,7 @@ export interface KubbHooks {
517
515
  'kubb:warn': [ctx: KubbWarnContext]
518
516
  'kubb:debug': [ctx: KubbDebugContext]
519
517
  'kubb:files:processing:start': [ctx: KubbFilesProcessingStartContext]
520
- 'kubb:file:processing:update': [ctx: KubbFileProcessingUpdateContext]
518
+ 'kubb:files:processing:update': [ctx: KubbFilesProcessingUpdateContext]
521
519
  'kubb:files:processing:end': [ctx: KubbFilesProcessingEndContext]
522
520
  'kubb:plugin:start': [ctx: KubbPluginStartContext]
523
521
  'kubb:plugin:end': [ctx: KubbPluginEndContext]
@@ -736,7 +734,7 @@ export type KubbFilesProcessingStartContext = {
736
734
  files: Array<FileNode>
737
735
  }
738
736
 
739
- export type KubbFileProcessingUpdateContext = {
737
+ export type KubbFileProcessingUpdate = {
740
738
  /**
741
739
  * Number of files processed so far in this batch.
742
740
  */
@@ -763,6 +761,13 @@ export type KubbFileProcessingUpdateContext = {
763
761
  config: Config
764
762
  }
765
763
 
764
+ export type KubbFilesProcessingUpdateContext = {
765
+ /**
766
+ * All files processed in this flush chunk.
767
+ */
768
+ files: Array<KubbFileProcessingUpdate>
769
+ }
770
+
766
771
  export type KubbFilesProcessingEndContext = {
767
772
  /**
768
773
  * All files that were serialised in this batch.
@@ -836,10 +841,6 @@ export type PossibleConfig<TCliOptions = undefined> =
836
841
  | PossiblePromise<Config | Config[]>
837
842
  | ((...args: [TCliOptions] extends [undefined] ? [] : [TCliOptions]) => PossiblePromise<Config | Config[]>)
838
843
 
839
- type SetupOptions = {
840
- hooks?: AsyncEventEmitter<KubbHooks>
841
- }
842
-
843
844
  /**
844
845
  * Full output produced by a successful or failed build.
845
846
  */
@@ -877,76 +878,11 @@ export type BuildOutput = {
877
878
  storage: Storage
878
879
  }
879
880
 
880
- /**
881
- * Kubb code generation instance returned by {@link createKubb}.
882
- *
883
- * Use this when orchestrating multiple builds, inspecting plugin timings, or integrating Kubb into a larger toolchain.
884
- * For a single one-off build, chain directly: `await createKubb(config).build()`.
885
- */
886
- export type Kubb = {
887
- /**
888
- * Shared event emitter for lifecycle and status events. Attach listeners before calling `setup()` or `build()`.
889
- */
890
- readonly hooks: AsyncEventEmitter<KubbHooks>
891
- /**
892
- * Read-only view of the files from the most recent `build()` or `safeBuild()` call.
893
- * Only populated after the build completes.
894
- *
895
- * Keys are scoped to the current run. Reads go straight to `config.storage`,
896
- * so nothing extra is held in memory.
897
- *
898
- * @example Read a generated file
899
- * ```ts
900
- * const { storage } = await kubb.safeBuild()
901
- * const code = await storage.getItem('/src/gen/pet.ts')
902
- * ```
903
- *
904
- * @example Walk every generated file
905
- * ```ts
906
- * for (const path of await kubb.storage.getKeys()) {
907
- * const code = await kubb.storage.getItem(path)
908
- * }
909
- * ```
910
- */
911
- readonly storage: Storage
912
- /**
913
- * Plugin driver managing all plugins. Available after `setup()` completes.
914
- */
915
- readonly driver: KubbDriver
916
- /**
917
- * Resolved configuration with defaults applied. Available after `setup()` completes.
918
- */
919
- readonly config: Config
920
- /**
921
- * Resolves config and initializes the driver. `build()` calls this automatically.
922
- */
923
- setup(): Promise<void>
924
- /**
925
- * Runs the full pipeline and throws on any plugin error. Automatically calls `setup()` if needed.
926
- */
927
- build(): Promise<BuildOutput>
928
- /**
929
- * Runs the full pipeline and captures errors in `BuildOutput` instead of throwing. Automatically calls `setup()` if needed.
930
- */
931
- safeBuild(): Promise<BuildOutput>
932
- }
933
-
934
- type SetupResult = {
935
- hooks: AsyncEventEmitter<KubbHooks>
936
- driver: KubbDriver
937
- storage: Storage
938
- config: Config
939
- dispose: () => void
940
- [Symbol.dispose](): void
941
- }
942
-
943
881
  /**
944
882
  * Builds a `Storage` view scoped to the file paths produced by the current build.
945
- *
946
- * Reads delegate to the underlying `storage` (typically `fsStorage()`) so source bytes
947
- * stay where they were written instead of being held in an extra in-memory map.
948
- * Writing via `setItem` stores the content in the underlying storage and registers the
949
- * key so subsequent reads and `getKeys` are scoped to this build's output.
883
+ * Reads delegate to the underlying `storage` so source bytes stay where they were
884
+ * written; writes register the key so subsequent reads and `getKeys` are scoped
885
+ * to this build's output.
950
886
  */
951
887
  function createSourcesView(storage: Storage): Storage {
952
888
  const paths = new Set<string>()
@@ -982,9 +918,8 @@ function createSourcesView(storage: Storage): Storage {
982
918
  }))()
983
919
  }
984
920
 
985
- async function setup(userConfig: UserConfig, options: SetupOptions = {}): Promise<SetupResult> {
986
- const hooks = options.hooks ?? new AsyncEventEmitter<KubbHooks>()
987
- const config: Config = {
921
+ function resolveConfig(userConfig: UserConfig): Config {
922
+ return {
988
923
  ...userConfig,
989
924
  root: userConfig.root || process.cwd(),
990
925
  parsers: userConfig.parsers ?? [],
@@ -1004,518 +939,6 @@ async function setup(userConfig: UserConfig, options: SetupOptions = {}): Promis
1004
939
  : undefined,
1005
940
  plugins: (userConfig.plugins ?? []) as unknown as Config['plugins'],
1006
941
  }
1007
- const driver = new KubbDriver(config, {
1008
- hooks,
1009
- })
1010
- const storage = createSourcesView(config.storage)
1011
- const diagnosticInfo = getDiagnosticInfo()
1012
-
1013
- await hooks.emit('kubb:debug', {
1014
- date: new Date(),
1015
- logs: [
1016
- 'Configuration:',
1017
- ` • Name: ${userConfig.name || 'unnamed'}`,
1018
- ` • Root: ${userConfig.root || process.cwd()}`,
1019
- ` • Output: ${userConfig.output?.path || 'not specified'}`,
1020
- ` • Plugins: ${userConfig.plugins?.length || 0}`,
1021
- 'Output Settings:',
1022
- ` • Storage: ${config.storage.name}`,
1023
- ` • Formatter: ${userConfig.output?.format || 'none'}`,
1024
- ` • Linter: ${userConfig.output?.lint || 'none'}`,
1025
- `Running adapter: ${config.adapter?.name || 'none'}`,
1026
- 'Environment:',
1027
- Object.entries(diagnosticInfo)
1028
- .map(([key, value]) => ` • ${key}: ${value}`)
1029
- .join('\n'),
1030
- ],
1031
- })
1032
-
1033
- try {
1034
- if (isInputPath(userConfig) && !new URLPath(userConfig.input.path).isURL) {
1035
- await exists(userConfig.input.path)
1036
-
1037
- await hooks.emit('kubb:debug', {
1038
- date: new Date(),
1039
- logs: [`✓ Input file validated: ${userConfig.input.path}`],
1040
- })
1041
- }
1042
- } catch (caughtError) {
1043
- if (isInputPath(userConfig)) {
1044
- const error = caughtError as Error
1045
-
1046
- throw new Error(
1047
- `Cannot read file/URL defined in \`input.path\` or set with \`kubb generate PATH\` in the CLI of your Kubb config ${userConfig.input.path}`,
1048
- {
1049
- cause: error,
1050
- },
1051
- )
1052
- }
1053
- }
1054
-
1055
- if (config.output.clean) {
1056
- await hooks.emit('kubb:debug', {
1057
- date: new Date(),
1058
- logs: ['Cleaning output directories', ` • Output: ${config.output.path}`],
1059
- })
1060
-
1061
- await config.storage.clear(resolve(config.root, config.output.path))
1062
- }
1063
-
1064
- await driver.setup()
1065
-
1066
- return {
1067
- config,
1068
- hooks,
1069
- driver,
1070
- storage,
1071
- dispose,
1072
- [Symbol.dispose]: dispose,
1073
- }
1074
-
1075
- function dispose() {
1076
- driver.dispose()
1077
- }
1078
- }
1079
-
1080
- type GeneratorEntry = { plugin: NormalizedPlugin; context: GeneratorContext; hrStart: ReturnType<typeof process.hrtime> }
1081
-
1082
- async function safeBuild(setupResult: SetupResult): Promise<BuildOutput> {
1083
- using _cleanup = setupResult
1084
- const { driver, hooks, storage } = setupResult
1085
-
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
- }
1099
- }
1100
-
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
1105
- }
1106
-
1107
- await hooks.emit('kubb:debug', {
1108
- date: new Date(),
1109
- logs: [`Writing ${files.length} files...`],
1110
- })
1111
-
1112
- await hooks.emit('kubb:files:processing:start', { files })
1113
-
1114
- const stream = fileProcessor.stream(files, { parsers: parsersMap, extension: config.output.extension })
1115
-
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)
1132
-
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
- }
1139
-
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) })
1150
- }
1151
- await driver.hooks.emit('kubb:generate:operations', collectedOperations, ctx)
1152
- }
1153
-
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
- }
1181
-
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
- })
1200
-
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
- })
1208
-
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
- }
1220
-
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
- }
1230
-
1231
- // Derive the allowed schema name set per pruning plugin.
1232
- for (const state of pruningStates) {
1233
- state.allowedSchemaNames = collectUsedSchemaNames(includedOpsByState.get(state) ?? [], allSchemas)
1234
- }
1235
- }
1236
-
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
- }
1240
-
1241
- async function dispatchSchema(state: PluginState, node: SchemaNode): Promise<void> {
1242
- if (state.failed) return
1243
-
1244
- try {
1245
- const { plugin, generatorContext, generators } = state
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 }
1261
-
1262
- for (const gen of generators) {
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
1268
- }
1269
-
1270
- await driver.hooks.emit('kubb:generate:schema', transformedNode, ctx)
1271
- } catch (caughtError) {
1272
- state.failed = true
1273
- state.error = caughtError as Error
1274
- }
1275
- }
1276
-
1277
- async function dispatchOperation(state: PluginState, node: OperationNode): Promise<void> {
1278
- if (state.failed) return
1279
-
1280
- try {
1281
- const { plugin, generatorContext, generators } = state
1282
-
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
1289
-
1290
- const ctx = { ...generatorContext, options }
1291
-
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
- }
1299
-
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
- }
1306
-
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
- })
1314
-
1315
- const collectedOperations: OperationNode[] = []
1316
-
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
- )
1325
-
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
- }
1336
- }
1337
-
1338
- const duration = getElapsedMs(state.hrStart)
1339
- pluginTimings.set(state.plugin.name, duration)
1340
-
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),
1351
- })
1352
-
1353
- if (state.failed && state.error) {
1354
- failedPlugins.add({ plugin: state.plugin, error: state.error })
1355
- }
1356
-
1357
- await driver.hooks.emit('kubb:debug', {
1358
- date: new Date(),
1359
- logs: [state.failed ? '✗ Plugin start failed' : `✓ Plugin started successfully (${formatMs(duration)})`],
1360
- })
1361
- }
1362
- }
1363
-
1364
- try {
1365
- await driver.emitSetupHooks()
1366
-
1367
- if (driver.adapter && driver.inputNode) {
1368
- await hooks.emit('kubb:build:start', {
1369
- config,
1370
- adapter: driver.adapter,
1371
- meta: driver.inputNode.meta,
1372
- getPlugin: driver.getPlugin.bind(driver),
1373
- get files() {
1374
- return driver.fileManager.files
1375
- },
1376
- upsertFile: (...files) => driver.fileManager.upsert(...files),
1377
- })
1378
- }
1379
-
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> = []
1384
-
1385
- for (const plugin of driver.plugins.values()) {
1386
- const context = driver.getContext(plugin)
1387
- const hrStart = process.hrtime()
1388
-
1389
- try {
1390
- await hooks.emit('kubb:plugin:start', { plugin })
1391
- await hooks.emit('kubb:debug', {
1392
- date: new Date(),
1393
- logs: ['Starting plugin...', ` • Plugin Name: ${plugin.name}`],
1394
- })
1395
- } catch (caughtError) {
1396
- const error = caughtError as Error
1397
- const duration = getElapsedMs(hrStart)
1398
- pluginTimings.set(plugin.name, duration)
1399
- await hooks.emit('kubb:plugin:end', {
1400
- plugin,
1401
- duration,
1402
- success: false,
1403
- error,
1404
- config,
1405
- get files() {
1406
- return driver.fileManager.files
1407
- },
1408
- upsertFile: (...files) => driver.fileManager.upsert(...files),
1409
- })
1410
- failedPlugins.add({ plugin, error })
1411
- continue
1412
- }
1413
-
1414
- if (plugin.generators?.length || driver.hasEventGenerators(plugin.name)) {
1415
- generatorPlugins.push({ plugin, context, hrStart })
1416
- continue
1417
- }
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
- }
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) {
1445
- const duration = getElapsedMs(hrStart)
1446
- pluginTimings.set(plugin.name, duration)
1447
- await hooks.emit('kubb:plugin:end', {
1448
- plugin,
1449
- duration,
1450
- success: true,
1451
- config,
1452
- get files() {
1453
- return driver.fileManager.files
1454
- },
1455
- upsertFile: (...files) => driver.fileManager.upsert(...files),
1456
- })
1457
- }
1458
- }
1459
- }
1460
-
1461
- await hooks.emit('kubb:plugins:end', {
1462
- config,
1463
- get files() {
1464
- return driver.fileManager.files
1465
- },
1466
- upsertFile: (...files) => driver.fileManager.upsert(...files),
1467
- })
1468
-
1469
- await flushPendingFiles()
1470
-
1471
- const files = driver.fileManager.files
1472
-
1473
- await hooks.emit('kubb:build:end', {
1474
- files,
1475
- config,
1476
- outputDir: resolve(config.root, config.output.path),
1477
- })
1478
-
1479
- return {
1480
- failedPlugins,
1481
- files,
1482
- driver,
1483
- pluginTimings,
1484
- storage,
1485
- }
1486
- } catch (error) {
1487
- return {
1488
- failedPlugins,
1489
- files: [],
1490
- driver,
1491
- pluginTimings,
1492
- error: error as Error,
1493
- storage,
1494
- }
1495
- }
1496
- }
1497
-
1498
- async function build(setupResult: SetupResult): Promise<BuildOutput> {
1499
- const { files, driver, failedPlugins, pluginTimings, error, storage } = await safeBuild(setupResult)
1500
-
1501
- if (error) {
1502
- throw error
1503
- }
1504
-
1505
- if (failedPlugins.size > 0) {
1506
- const errors = [...failedPlugins].map(({ error }) => error)
1507
-
1508
- throw new BuildError(`Build Error with ${failedPlugins.size} failed plugins`, { errors })
1509
- }
1510
-
1511
- return {
1512
- failedPlugins,
1513
- files,
1514
- driver,
1515
- pluginTimings,
1516
- error: undefined,
1517
- storage,
1518
- }
1519
942
  }
1520
943
 
1521
944
  /**
@@ -1548,66 +971,141 @@ type CreateKubbOptions = {
1548
971
  }
1549
972
 
1550
973
  /**
1551
- * Creates a Kubb instance bound to a single config entry.
974
+ * Kubb code-generation instance bound to a single config entry. Resolves the user
975
+ * config during `setup()` and shares `hooks`, `storage`, `driver`, and `config` across
976
+ * the `setup → build` lifecycle.
1552
977
  *
1553
- * Accepts a user-facing config shape and resolves it to a full {@link Config} during
1554
- * `setup()`. The instance then holds shared state (`hooks`, `storage`, `driver`, `config`)
1555
- * across the `setup → build` lifecycle. Attach event listeners to `kubb.hooks` before
1556
- * calling `setup()` or `build()`.
978
+ * Attach event listeners to `.hooks` before calling `setup()` or `build()`.
1557
979
  *
1558
980
  * @example
1559
981
  * ```ts
1560
982
  * const kubb = createKubb(userConfig)
1561
- *
1562
- * kubb.hooks.on('kubb:plugin:end', ({ plugin, duration }) => {
1563
- * console.log(`${plugin.name} completed in ${duration}ms`)
1564
- * })
1565
- *
983
+ * kubb.hooks.on('kubb:plugin:end', ({ plugin, duration }) => console.log(plugin.name, duration))
1566
984
  * const { files, failedPlugins } = await kubb.safeBuild()
1567
985
  * ```
1568
986
  */
1569
- export function createKubb(userConfig: UserConfig, options: CreateKubbOptions = {}): Kubb {
1570
- const hooks = options.hooks ?? new AsyncEventEmitter<KubbHooks>()
1571
- let setupResult: SetupResult | undefined
987
+ export class Kubb {
988
+ readonly hooks: AsyncEventEmitter<KubbHooks>
989
+ readonly #userConfig: UserConfig
990
+ #config: Config | null = null
991
+ #driver: KubbDriver | null = null
992
+ #storage: Storage | null = null
993
+
994
+ constructor(userConfig: UserConfig, options: CreateKubbOptions = {}) {
995
+ this.#userConfig = userConfig
996
+ this.hooks = options.hooks ?? new AsyncEventEmitter<KubbHooks>()
997
+ }
1572
998
 
1573
- const instance: Kubb = {
1574
- get hooks() {
1575
- return hooks
1576
- },
1577
- get storage() {
1578
- if (!setupResult) {
1579
- throw new Error('[kubb] setup() must be called before accessing storage')
1580
- }
1581
- return setupResult.storage
1582
- },
1583
- get driver() {
1584
- if (!setupResult) {
1585
- throw new Error('[kubb] setup() must be called before accessing driver')
1586
- }
1587
- return setupResult.driver
1588
- },
1589
- get config() {
1590
- if (!setupResult) {
1591
- throw new Error('[kubb] setup() must be called before accessing config')
1592
- }
1593
- return setupResult.config
1594
- },
1595
- async setup() {
1596
- setupResult = await setup(userConfig, { hooks })
1597
- },
1598
- async build() {
1599
- if (!setupResult) {
1600
- await instance.setup()
1601
- }
1602
- return build(setupResult!)
1603
- },
1604
- async safeBuild() {
1605
- if (!setupResult) {
1606
- await instance.setup()
999
+ get storage(): Storage {
1000
+ if (!this.#storage) throw new Error('[kubb] setup() must be called before accessing storage')
1001
+ return this.#storage
1002
+ }
1003
+
1004
+ get driver(): KubbDriver {
1005
+ if (!this.#driver) throw new Error('[kubb] setup() must be called before accessing driver')
1006
+ return this.#driver
1007
+ }
1008
+
1009
+ get config(): Config {
1010
+ if (!this.#config) throw new Error('[kubb] setup() must be called before accessing config')
1011
+ return this.#config
1012
+ }
1013
+
1014
+ /**
1015
+ * Resolves config and initializes the driver. `build()` calls this automatically.
1016
+ */
1017
+ async setup(): Promise<void> {
1018
+ const config = resolveConfig(this.#userConfig)
1019
+ const driver = new KubbDriver(config, { hooks: this.hooks })
1020
+ const storage = createSourcesView(config.storage)
1021
+
1022
+ await this.hooks.emit('kubb:debug', { date: new Date(), logs: this.#configLogs(config) })
1023
+
1024
+ if (isInputPath(this.#userConfig) && !new URLPath(this.#userConfig.input.path).isURL) {
1025
+ try {
1026
+ await exists(this.#userConfig.input.path)
1027
+ await this.hooks.emit('kubb:debug', { date: new Date(), logs: [`✓ Input file validated: ${this.#userConfig.input.path}`] })
1028
+ } catch (caughtError) {
1029
+ throw new Error(
1030
+ `Cannot read file/URL defined in \`input.path\` or set with \`kubb generate PATH\` in the CLI of your Kubb config ${this.#userConfig.input.path}`,
1031
+ { cause: caughtError as Error },
1032
+ )
1607
1033
  }
1608
- return safeBuild(setupResult!)
1609
- },
1034
+ }
1035
+
1036
+ if (config.output.clean) {
1037
+ await this.hooks.emit('kubb:debug', { date: new Date(), logs: ['Cleaning output directories', ` • Output: ${config.output.path}`] })
1038
+ await config.storage.clear(resolve(config.root, config.output.path))
1039
+ }
1040
+
1041
+ await driver.setup()
1042
+
1043
+ this.#config = config
1044
+ this.#driver = driver
1045
+ this.#storage = storage
1046
+ }
1047
+
1048
+ /**
1049
+ * Runs the full pipeline and throws on any plugin error.
1050
+ * Automatically calls `setup()` if needed.
1051
+ */
1052
+ async build(): Promise<BuildOutput> {
1053
+ const out = await this.safeBuild()
1054
+ if (out.error) throw out.error
1055
+ if (out.failedPlugins.size > 0) {
1056
+ const errors = [...out.failedPlugins].map(({ error }) => error)
1057
+ throw new BuildError(`Build Error with ${out.failedPlugins.size} failed plugins`, { errors })
1058
+ }
1059
+ return out
1060
+ }
1061
+
1062
+ /**
1063
+ * Runs the full pipeline and captures errors in `BuildOutput` instead of throwing.
1064
+ * Automatically calls `setup()` if needed.
1065
+ */
1066
+ async safeBuild(): Promise<BuildOutput> {
1067
+ if (!this.#driver) await this.setup()
1068
+ using cleanup = this
1069
+ const driver = cleanup.driver
1070
+ const storage = cleanup.storage
1071
+ const { failedPlugins, pluginTimings, error } = await driver.run({ storage })
1072
+ return { failedPlugins, files: driver.fileManager.files, driver, pluginTimings, storage, ...(error ? { error } : {}) }
1073
+ }
1074
+
1075
+ dispose(): void {
1076
+ this.#driver?.dispose()
1610
1077
  }
1611
1078
 
1612
- return instance
1079
+ [Symbol.dispose](): void {
1080
+ this.dispose()
1081
+ }
1082
+
1083
+ #configLogs(config: Config): Array<string> {
1084
+ const u = this.#userConfig
1085
+ const diag = getDiagnosticInfo()
1086
+ return [
1087
+ 'Configuration:',
1088
+ ` • Name: ${u.name || 'unnamed'}`,
1089
+ ` • Root: ${u.root || process.cwd()}`,
1090
+ ` • Output: ${u.output?.path || 'not specified'}`,
1091
+ ` • Plugins: ${u.plugins?.length || 0}`,
1092
+ 'Output Settings:',
1093
+ ` • Storage: ${config.storage.name}`,
1094
+ ` • Formatter: ${u.output?.format || 'none'}`,
1095
+ ` • Linter: ${u.output?.lint || 'none'}`,
1096
+ `Running adapter: ${config.adapter?.name || 'none'}`,
1097
+ 'Environment:',
1098
+ Object.entries(diag)
1099
+ .map(([key, value]) => ` • ${key}: ${value}`)
1100
+ .join('\n'),
1101
+ ]
1102
+ }
1103
+ }
1104
+
1105
+ /**
1106
+ * Factory for {@link Kubb}. Equivalent to `new Kubb(userConfig, options)` and kept
1107
+ * as the canonical public entry point.
1108
+ */
1109
+ export function createKubb(userConfig: UserConfig, options: CreateKubbOptions = {}): Kubb {
1110
+ return new Kubb(userConfig, options)
1613
1111
  }