@kubb/core 5.0.0-beta.41 → 5.0.0-beta.42

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.
@@ -0,0 +1,98 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { relative } from 'node:path'
4
+ import { URLPath } from '@internals/utils'
5
+ import type { AdapterSource } from './createAdapter.ts'
6
+ import type { Config } from './createKubb.ts'
7
+
8
+ /**
9
+ * Computes the cache key for an incremental build. All methods are static, so call them as
10
+ * `Fingerprint.compute(...)` and `Fingerprint.stringify(...)`. The key holds no absolute
11
+ * paths or modification times, so it never depends on where the project lives on disk.
12
+ */
13
+ export class Fingerprint {
14
+ /**
15
+ * Bumped when the snapshot format or fingerprint inputs change in an incompatible way, so stale
16
+ * cache entries from older Kubb builds are never reused.
17
+ */
18
+ static version = 1
19
+
20
+ /**
21
+ * Deterministically serializes a value to JSON: object keys are sorted recursively and
22
+ * `undefined` values and functions are dropped. Two structurally equal configs produce the same
23
+ * string regardless of key order, which keeps the fingerprint stable across machines.
24
+ */
25
+ static stringify(value: unknown): string {
26
+ return JSON.stringify(Fingerprint.#normalize(value))
27
+ }
28
+
29
+ /**
30
+ * Computes a cache key from everything that affects the generated output: the spec content, the
31
+ * output-shaping config, each plugin's name and options, the middleware names, the running
32
+ * `@kubb/core` version, and the cache format version. Returns `null` when the input can't be
33
+ * fingerprinted (remote URL or no adapter source), which disables caching for that build.
34
+ */
35
+ static async compute({ config, adapterSource, version }: { config: Config; adapterSource: AdapterSource | null; version: string }): Promise<string | null> {
36
+ if (!adapterSource) {
37
+ return null
38
+ }
39
+
40
+ const spec = await Fingerprint.#readSpec(adapterSource, config.root)
41
+ if (spec === null) {
42
+ return null
43
+ }
44
+
45
+ const input = {
46
+ cacheVersion: Fingerprint.version,
47
+ version,
48
+ spec,
49
+ name: config.name,
50
+ output: config.output,
51
+ adapter: config.adapter?.name,
52
+ parsers: config.parsers.map((parser) => parser.name),
53
+ plugins: config.plugins.map((plugin) => ({ name: plugin.name, options: plugin.options })),
54
+ middleware: (config.middleware ?? []).map((middleware) => middleware.name),
55
+ }
56
+
57
+ return createHash('sha256').update(Fingerprint.stringify(input)).digest('hex')
58
+ }
59
+
60
+ static #normalize(value: unknown): unknown {
61
+ if (value === null || typeof value !== 'object') {
62
+ return typeof value === 'function' ? undefined : value
63
+ }
64
+ if (Array.isArray(value)) {
65
+ return value.map((item) => Fingerprint.#normalize(item))
66
+ }
67
+ const source = value as Record<string, unknown>
68
+ const result: Record<string, unknown> = {}
69
+ for (const key of Object.keys(source).sort()) {
70
+ const normalized = Fingerprint.#normalize(source[key])
71
+ if (normalized !== undefined) {
72
+ result[key] = normalized
73
+ }
74
+ }
75
+ return result
76
+ }
77
+
78
+ /**
79
+ * Reads the spec content that feeds the fingerprint. Returns `null` for a remote URL source
80
+ * (hashing remote content would mean fetching it on every run) or when a file can't be read, so a
81
+ * missing or virtual spec disables caching instead of failing the build.
82
+ */
83
+ static async #readSpec(source: AdapterSource, root: string): Promise<unknown> {
84
+ if (source.type === 'data') {
85
+ return { kind: 'data', data: typeof source.data === 'string' ? source.data : Fingerprint.stringify(source.data) }
86
+ }
87
+ const paths = source.type === 'paths' ? source.paths : [source.path]
88
+ if (paths.some((path) => new URLPath(path).isURL)) {
89
+ return null
90
+ }
91
+ try {
92
+ const contents = await Promise.all(paths.map(async (path) => ({ path: relative(root, path), content: await readFile(path, 'utf8') })))
93
+ return { kind: 'path', contents }
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+ }
package/src/KubbDriver.ts CHANGED
@@ -1,9 +1,12 @@
1
- import { resolve } from 'node:path'
1
+ import { basename, join, relative, resolve } from 'node:path'
2
2
  import { arrayToAsyncIterable, type AsyncEventEmitter, forBatches, getElapsedMs, isPromise, memoize, URLPath } from '@internals/utils'
3
3
  import { collectUsedSchemaNames, createFile, createStreamInput } from '@kubb/ast'
4
4
  import type { FileNode, InputMeta, InputStreamNode, OperationNode, SchemaNode } from '@kubb/ast'
5
+ import { version as coreVersion } from '../package.json'
5
6
  import { OPERATION_FILTER_TYPES, SCHEMA_PARALLEL } from './constants.ts'
7
+ import { Fingerprint } from './Fingerprint.ts'
6
8
  import { type Diagnostic, Diagnostics, type ProblemDiagnostic } from './diagnostics.ts'
9
+ import type { Cache } from './createCache.ts'
7
10
  import type { RendererFactory } from './createRenderer.ts'
8
11
  import type { Storage } from './createStorage.ts'
9
12
  import type { Generator } from './defineGenerator.ts'
@@ -105,9 +108,11 @@ export class KubbDriver {
105
108
  async setup() {
106
109
  const normalized: Array<NormalizedPlugin> = this.config.plugins.map((rawPlugin) => this.#normalizePlugin(rawPlugin as Plugin))
107
110
 
111
+ const dependenciesByName = new Map(normalized.map((plugin) => [plugin.name, new Set(plugin.dependencies ?? [])]))
112
+
108
113
  normalized.sort((a, b) => {
109
- if (b.dependencies?.includes(a.name)) return -1
110
- if (a.dependencies?.includes(b.name)) return 1
114
+ if (dependenciesByName.get(b.name)?.has(a.name)) return -1
115
+ if (dependenciesByName.get(a.name)?.has(b.name)) return 1
111
116
 
112
117
  return enforceOrder(a.enforce) - enforceOrder(b.enforce)
113
118
  })
@@ -352,8 +357,12 @@ export class KubbDriver {
352
357
  await hooks.emit('kubb:files:processing:start', { files })
353
358
  })
354
359
  const updateBuffer: Array<{ file: FileNode; source?: string; processed: number; total: number; percentage: number }> = []
360
+ // Final rendered source per output path, captured for the cache snapshot on a miss. Barrel
361
+ // files flow through here too, after the second `drain()`.
362
+ const snapshotSources = new Map<string, string>()
355
363
  processor.hooks.on('update', (item) => {
356
364
  updateBuffer.push(item)
365
+ if (item.source !== undefined) snapshotSources.set(item.file.path, item.source)
357
366
  })
358
367
  processor.hooks.on('end', async (files) => {
359
368
  await hooks.emit('kubb:files:processing:update', {
@@ -373,6 +382,16 @@ export class KubbDriver {
373
382
  (diagnostic) => diagnostics.push(diagnostic),
374
383
  async () => {
375
384
  try {
385
+ const cache = config.cache
386
+ const outputRoot = resolve(config.root, config.output.path)
387
+ const cacheKey = cache ? await Fingerprint.compute({ config, adapterSource: this.#adapterSource, version: coreVersion }) : null
388
+
389
+ // On a cache hit, restore the snapshot and skip everything below. Skipping the work is the
390
+ // whole point, so the only event emitted is `kubb:build:end`, which reporters key off.
391
+ if (cache && cacheKey && (await this.#restoreSnapshot({ cache, cacheKey, outputRoot, storage }))) {
392
+ return { diagnostics: Diagnostics.dedupe(diagnostics) }
393
+ }
394
+
376
395
  // Parse the adapter source into the streaming `InputNode`.
377
396
  await this.#parseInput()
378
397
  // Emit `kubb:plugin:setup` so plugins can register transformers via `setTransformer`.
@@ -431,7 +450,11 @@ export class KubbDriver {
431
450
  // Plugins-end listeners (barrel middleware etc.) may have queued more files.
432
451
  await processor.drain()
433
452
 
434
- await hooks.emit('kubb:build:end', { files: this.fileManager.files, config, outputDir: resolve(config.root, config.output.path) })
453
+ await hooks.emit('kubb:build:end', { files: this.fileManager.files, config, outputDir: outputRoot })
454
+
455
+ if (cache && cacheKey && !Diagnostics.hasError(diagnostics)) {
456
+ await this.#persistSnapshot({ cache, cacheKey, outputRoot, sources: snapshotSources })
457
+ }
435
458
 
436
459
  return { diagnostics: Diagnostics.dedupe(diagnostics) }
437
460
  } catch (caughtError) {
@@ -444,6 +467,45 @@ export class KubbDriver {
444
467
  )
445
468
  }
446
469
 
470
+ /**
471
+ * Writes a restored snapshot straight to storage and emits `kubb:build:end`. Returns `true` on a
472
+ * hit (the build is done), `false` on a miss so the caller falls through to a full build.
473
+ */
474
+ async #restoreSnapshot({ cache, cacheKey, outputRoot, storage }: { cache: Cache; cacheKey: string; outputRoot: string; storage: Storage }): Promise<boolean> {
475
+ const snapshot = await cache.restore({ key: cacheKey })
476
+ if (!snapshot) return false
477
+
478
+ for (const [relativePath, source] of Object.entries(snapshot.files)) {
479
+ const absolutePath = join(outputRoot, relativePath)
480
+ this.fileManager.upsert(createFile({ path: absolutePath, baseName: basename(relativePath) as `${string}.${string}` }))
481
+ await storage.setItem(absolutePath, source)
482
+ }
483
+ await this.hooks.emit('kubb:build:end', { files: this.fileManager.files, config: this.config, outputDir: outputRoot })
484
+ return true
485
+ }
486
+
487
+ /**
488
+ * Stores this run's rendered output, keyed by the input fingerprint, so the next unchanged build
489
+ * restores it instead of regenerating. `sources` is keyed by absolute path and relativized here.
490
+ */
491
+ async #persistSnapshot({
492
+ cache,
493
+ cacheKey,
494
+ outputRoot,
495
+ sources,
496
+ }: {
497
+ cache: Cache
498
+ cacheKey: string
499
+ outputRoot: string
500
+ sources: ReadonlyMap<string, string>
501
+ }): Promise<void> {
502
+ const files: Record<string, string> = {}
503
+ for (const [absolutePath, source] of sources) {
504
+ files[relative(outputRoot, absolutePath)] = source
505
+ }
506
+ await cache.persist({ key: cacheKey, snapshot: { files } })
507
+ }
508
+
447
509
  // Returns a fresh object with a lazy `files` getter and a bound `upsertFile`.
448
510
  // Caller must use `Object.assign(extra, this.#filesPayload())`, not object spread.
449
511
  // Spread would eagerly invoke the getter and freeze a stale snapshot into the payload.
@@ -536,10 +598,8 @@ export class KubbDriver {
536
598
  // Buffer the streaming adapter's nodes once. Each plugin reads the same buffer
537
599
  // instead of re-parsing the document per pass, and the pruning pre-scan below
538
600
  // shares it too (previously it iterated its own copies).
539
- const schemasBuffer: Array<SchemaNode> = []
540
- for await (const schema of schemas) schemasBuffer.push(schema)
541
- const operationsBuffer: Array<OperationNode> = []
542
- for await (const operation of operations) operationsBuffer.push(operation)
601
+ const schemasBuffer: Array<SchemaNode> = await Array.fromAsync(schemas)
602
+ const operationsBuffer: Array<OperationNode> = await Array.fromAsync(operations)
543
603
 
544
604
  // Pre-scan: plugins with operation-based includes (but no schemaName include) need
545
605
  // the reachable schema set. This requires the full schema graph in memory at once,
@@ -0,0 +1,85 @@
1
+ import { join } from 'node:path'
2
+ import { read } from '@internals/utils'
3
+
4
+ /**
5
+ * Bookkeeping for one cached build: the relative paths it covers and timestamps used by the pruner.
6
+ */
7
+ export type ManifestEntry = {
8
+ files: Array<string>
9
+ createdAt: number
10
+ lastAccess: number
11
+ }
12
+
13
+ /**
14
+ * The on-disk manifest: a version marker plus an entry per cached build, keyed by fingerprint.
15
+ */
16
+ export type ManifestData = {
17
+ version: number
18
+ entries: Record<string, ManifestEntry>
19
+ }
20
+
21
+ /**
22
+ * Reads and prunes the local cache manifest. All methods are static, so call them as
23
+ * `Manifest.read(dir)` and `Manifest.prune(data, ...)`. A damaged manifest reads as empty so the
24
+ * cache degrades to misses instead of throwing. Writing goes through `write` from `@internals/utils`.
25
+ */
26
+ export class Manifest {
27
+ /**
28
+ * On-disk layout version for the manifest itself. Bumped when the manifest shape changes; a
29
+ * mismatch makes the whole local cache read as empty.
30
+ */
31
+ static version = 1
32
+
33
+ /**
34
+ * Reads the manifest at `dir/manifest.json`. A missing, corrupt, or version-mismatched file reads
35
+ * as an empty manifest.
36
+ */
37
+ static async read(dir: string): Promise<ManifestData> {
38
+ try {
39
+ const parsed = JSON.parse(await read(join(dir, 'manifest.json'))) as ManifestData
40
+ if (parsed.version !== Manifest.version || typeof parsed.entries !== 'object') {
41
+ return Manifest.#empty()
42
+ }
43
+ return parsed
44
+ } catch {
45
+ return Manifest.#empty()
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Selects the keys to evict so the cache stays within `ttlDays` and `maxEntries`. Returns the
51
+ * surviving manifest plus the evicted keys (the caller deletes their blobs). Pure, does no IO.
52
+ */
53
+ static prune(
54
+ manifest: ManifestData,
55
+ { maxEntries, ttlDays, now }: { maxEntries: number; ttlDays: number; now: number },
56
+ ): {
57
+ manifest: ManifestData
58
+ removed: Array<string>
59
+ } {
60
+ const ttlMs = ttlDays * 24 * 60 * 60 * 1000
61
+ const removed: Array<string> = []
62
+ const kept: Array<[string, ManifestEntry]> = []
63
+
64
+ for (const [key, entry] of Object.entries(manifest.entries)) {
65
+ if (now - entry.lastAccess > ttlMs) {
66
+ removed.push(key)
67
+ } else {
68
+ kept.push([key, entry])
69
+ }
70
+ }
71
+
72
+ if (kept.length > maxEntries) {
73
+ kept.sort((a, b) => b[1].lastAccess - a[1].lastAccess)
74
+ for (const [key] of kept.splice(maxEntries)) {
75
+ removed.push(key)
76
+ }
77
+ }
78
+
79
+ return { manifest: { version: Manifest.version, entries: Object.fromEntries(kept) }, removed }
80
+ }
81
+
82
+ static #empty(): ManifestData {
83
+ return { version: Manifest.version, entries: {} }
84
+ }
85
+ }
package/src/Telemetry.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
  import os from 'node:os'
3
3
  import process from 'node:process'
4
- import { executeIfOnline, isCIEnvironment } from '@internals/utils'
4
+ import { executeIfOnline, getRuntimeName, getRuntimeVersion, isCIEnvironment, type RuntimeName } from '@internals/utils'
5
5
  import { OTLP_ENDPOINT } from './constants.ts'
6
6
 
7
7
  // OpenTelemetry OTLP JSON types
@@ -114,6 +114,14 @@ export type TelemetryEvent = {
114
114
  command: string
115
115
  kubbVersion: string
116
116
  nodeVersion: string
117
+ /**
118
+ * Name of the JavaScript runtime that executed the run, `'bun'`, `'deno'`, or `'node'`.
119
+ */
120
+ runtime: RuntimeName
121
+ /**
122
+ * Major version of the active runtime, e.g. `'1'` under Bun or `'22'` under Node.
123
+ */
124
+ runtimeVersion: string
117
125
  platform: string
118
126
  ci: boolean
119
127
  plugins: Array<TelemetryPlugin>
@@ -158,6 +166,8 @@ export class Telemetry {
158
166
  command: options.command,
159
167
  kubbVersion: options.kubbVersion,
160
168
  nodeVersion: process.versions.node.split('.')[0] as string,
169
+ runtime: getRuntimeName(),
170
+ runtimeVersion: getRuntimeVersion().split('.')[0] as string,
161
171
  platform: os.platform(),
162
172
  ci: isCIEnvironment(),
163
173
  plugins: options.plugins ?? [],
@@ -181,6 +191,8 @@ export class Telemetry {
181
191
  { key: 'kubb.command', value: { stringValue: event.command } },
182
192
  { key: 'kubb.version', value: { stringValue: event.kubbVersion } },
183
193
  { key: 'kubb.node_version', value: { stringValue: event.nodeVersion } },
194
+ { key: 'kubb.runtime', value: { stringValue: event.runtime } },
195
+ { key: 'kubb.runtime_version', value: { stringValue: event.runtimeVersion } },
184
196
  { key: 'kubb.platform', value: { stringValue: event.platform } },
185
197
  { key: 'kubb.ci', value: { boolValue: event.ci } },
186
198
  { key: 'kubb.files_created', value: { intValue: event.filesCreated } },
@@ -0,0 +1,103 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { join, resolve } from 'node:path'
3
+ import { clean, read, write } from '@internals/utils'
4
+ import { createCache } from '../createCache.ts'
5
+ import { Manifest } from '../Manifest.ts'
6
+
7
+ /**
8
+ * Options for {@link fsCache}.
9
+ */
10
+ export type FsCacheOptions = {
11
+ /**
12
+ * Directory that holds the cache. Resolved against `process.cwd()` when relative.
13
+ *
14
+ * @default 'node_modules/.cache/kubb'
15
+ */
16
+ dir?: string
17
+ /**
18
+ * Maximum number of build snapshots to keep. The least-recently-used entries are
19
+ * evicted once the cache grows past it.
20
+ *
21
+ * @default 50
22
+ */
23
+ maxEntries?: number
24
+ /**
25
+ * Days a snapshot may go untouched before it is evicted.
26
+ *
27
+ * @default 7
28
+ */
29
+ ttlDays?: number
30
+ }
31
+
32
+ type IndexFile = Array<{ path: string; blob: string }>
33
+
34
+ function blobName(relativePath: string): string {
35
+ return `${createHash('sha256').update(relativePath).digest('hex')}.blob`
36
+ }
37
+
38
+ /**
39
+ * Local filesystem cache. Stores each build snapshot as content blobs plus an index,
40
+ * tracked by a manifest under `node_modules/.cache/kubb/` (the Nx and Vitest
41
+ * convention). Least-recently-used and expired entries are pruned on every persist.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * import { fsCache } from '@kubb/core'
46
+ *
47
+ * export default defineConfig({
48
+ * cache: fsCache(),
49
+ * })
50
+ * ```
51
+ */
52
+ export const fsCache = createCache((options: FsCacheOptions = {}) => {
53
+ const dir = resolve(options.dir ?? join('node_modules', '.cache', 'kubb'))
54
+ const maxEntries = options.maxEntries ?? 50
55
+ const ttlDays = options.ttlDays ?? 7
56
+ const blobsDir = join(dir, 'blobs')
57
+ const manifestPath = join(dir, 'manifest.json')
58
+
59
+ return {
60
+ name: 'fs',
61
+ async restore({ key }) {
62
+ const manifest = await Manifest.read(dir)
63
+ const entry = manifest.entries[key]
64
+ if (!entry) {
65
+ return null
66
+ }
67
+
68
+ try {
69
+ const index = JSON.parse(await read(join(blobsDir, key, 'index.json'))) as IndexFile
70
+ const files: Record<string, string> = {}
71
+ for (const { path, blob } of index) {
72
+ files[path] = await read(join(blobsDir, key, blob))
73
+ }
74
+
75
+ entry.lastAccess = Date.now()
76
+ await write(manifestPath, JSON.stringify(manifest)).catch(() => {})
77
+
78
+ return { files }
79
+ } catch {
80
+ return null
81
+ }
82
+ },
83
+ async persist({ key, snapshot }) {
84
+ const entryDir = join(blobsDir, key)
85
+
86
+ const index: IndexFile = []
87
+ for (const [path, source] of Object.entries(snapshot.files)) {
88
+ const blob = blobName(path)
89
+ await write(join(entryDir, blob), source)
90
+ index.push({ path, blob })
91
+ }
92
+ await write(join(entryDir, 'index.json'), JSON.stringify(index))
93
+
94
+ const manifest = await Manifest.read(dir)
95
+ const now = Date.now()
96
+ manifest.entries[key] = { files: index.map((item) => item.path), createdAt: now, lastAccess: now }
97
+
98
+ const pruned = Manifest.prune(manifest, { maxEntries, ttlDays, now })
99
+ await Promise.all(pruned.removed.map((removedKey) => clean(join(blobsDir, removedKey))))
100
+ await write(manifestPath, JSON.stringify(pruned.manifest))
101
+ },
102
+ }
103
+ })
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A snapshot of a completed build: the final rendered source of every generated
3
+ * file, keyed by its path relative to the output root. Restoring a snapshot writes
4
+ * those sources straight to storage, skipping generation entirely.
5
+ *
6
+ * Paths are relative, not absolute, so the snapshot never depends on where the
7
+ * project lives on disk.
8
+ */
9
+ export type CachedSnapshot = {
10
+ /**
11
+ * Final source per output file, keyed by the path relative to
12
+ * `resolve(config.root, config.output.path)`.
13
+ */
14
+ files: Record<string, string>
15
+ }
16
+
17
+ /**
18
+ * Backend that stores build snapshots for incremental ("hot") rebuilds. When the
19
+ * input fingerprint matches a stored key, Kubb restores the snapshot instead of
20
+ * regenerating. Kubb ships with `fsCache` (local disk). Implement this interface to
21
+ * back the cache with any other store.
22
+ *
23
+ * @see {@link createCache} to build a custom backend.
24
+ */
25
+ export type Cache = {
26
+ /**
27
+ * Identifier used in logs and diagnostics (`'fs'`, `'memory'`).
28
+ */
29
+ readonly name: string
30
+ /**
31
+ * Returns the snapshot stored under `key`, or `null` on a miss. A backend never
32
+ * throws on a miss or a transient failure. It returns `null` so the build falls
33
+ * through to regeneration.
34
+ */
35
+ restore(params: { key: string }): Promise<CachedSnapshot | null>
36
+ /**
37
+ * Stores `snapshot` under `key`. Only called after a successful build with no
38
+ * error diagnostics.
39
+ */
40
+ persist(params: { key: string; snapshot: CachedSnapshot }): Promise<void>
41
+ /**
42
+ * Optional teardown called after the build. Use to flush buffers or close
43
+ * connections.
44
+ */
45
+ dispose?(): Promise<void>
46
+ }
47
+
48
+ /**
49
+ * Defines a custom cache backend. The builder receives user options and returns a
50
+ * {@link Cache}. Reach for this when the filesystem backend doesn't fit, for
51
+ * example to store snapshots in Redis or a database.
52
+ *
53
+ * @example In-memory cache (the built-in implementation)
54
+ * ```ts
55
+ * import { createCache } from '@kubb/core'
56
+ *
57
+ * export const memoryCache = createCache(() => {
58
+ * const store = new Map<string, CachedSnapshot>()
59
+ *
60
+ * return {
61
+ * name: 'memory',
62
+ * async restore({ key }) {
63
+ * return store.get(key) ?? null
64
+ * },
65
+ * async persist({ key, snapshot }) {
66
+ * store.set(key, snapshot)
67
+ * },
68
+ * }
69
+ * })
70
+ * ```
71
+ */
72
+ export function createCache<TOptions = Record<string, never>>(build: (options: TOptions) => Cache): (options?: TOptions) => Cache {
73
+ return (options) => build(options ?? ({} as TOptions))
74
+ }
package/src/createKubb.ts CHANGED
@@ -5,6 +5,7 @@ import type { FileNode, InputMeta, OperationNode, SchemaNode } from '@kubb/ast'
5
5
  import { HOOK_LISTENERS_PER_PLUGIN } from './constants.ts'
6
6
  import type { Adapter } from './createAdapter.ts'
7
7
  import { type Diagnostic, Diagnostics, type ProblemDiagnostic, type UpdateDiagnostic } from './diagnostics.ts'
8
+ import type { Cache } from './createCache.ts'
8
9
  import { createStorage, type Storage } from './createStorage.ts'
9
10
  import type { GeneratorContext } from './defineGenerator.ts'
10
11
  import type { Middleware } from './defineMiddleware.ts'
@@ -233,6 +234,26 @@ export type Config<TInput = Input> = {
233
234
  * @see {@link Storage} interface for implementing custom backends.
234
235
  */
235
236
  storage: Storage
237
+ /**
238
+ * Incremental build cache. Kubb fingerprints the inputs (spec content, config, plugin options,
239
+ * versions) and, on an unchanged "hot" run, restores the previously generated output instead of
240
+ * regenerating it. Same idea as Nx's computation cache.
241
+ *
242
+ * `defineConfig` enables `fsCache()` (local disk under `node_modules/.cache/kubb`) by default.
243
+ * Pass another backend to change where snapshots live, or `false` to turn caching off. A bare
244
+ * `createKubb` leaves it off unless a cache is provided.
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * import { fsCache } from '@kubb/core'
249
+ *
250
+ * cache: fsCache({ dir: '.kubb-cache' })
251
+ * cache: false
252
+ * ```
253
+ *
254
+ * @see {@link Cache} interface for implementing custom backends.
255
+ */
256
+ cache?: Cache
236
257
  /**
237
258
  * Plugins that run during the build to generate code and transform the AST. Each one processes
238
259
  * the adapter's AST and can emit files for a different target (TypeScript, Zod, Faker). A plugin
@@ -336,7 +357,12 @@ export type Config<TInput = Input> = {
336
357
  * })
337
358
  * ```
338
359
  */
339
- export type UserConfig<TInput = Input> = Omit<Config<TInput>, 'root' | 'plugins' | 'parsers' | 'adapter' | 'storage' | 'reporters'> & {
360
+ export type UserConfig<TInput = Input> = Omit<Config<TInput>, 'root' | 'plugins' | 'parsers' | 'adapter' | 'storage' | 'reporters' | 'cache'> & {
361
+ /**
362
+ * Incremental build cache. Defaults to `fsCache()` (local disk). Pass another {@link Cache}
363
+ * backend, or `false` to turn caching off.
364
+ */
365
+ cache?: Cache | false
340
366
  /**
341
367
  * Project root directory, absolute or relative to the config file location.
342
368
  * @default process.cwd()
@@ -658,7 +684,7 @@ export type KubbDiagnosticContext = {
658
684
 
659
685
  export type KubbFilesProcessingStartContext = {
660
686
  /**
661
- * Files about to be serialised and written.
687
+ * Files about to be serialized and written.
662
688
  */
663
689
  files: Array<FileNode>
664
690
  }
@@ -677,7 +703,7 @@ export type KubbFileProcessingUpdate = {
677
703
  */
678
704
  percentage: number
679
705
  /**
680
- * Serialised file content, or `undefined` when the file produced no output.
706
+ * Serialized file content, or `undefined` when the file produced no output.
681
707
  */
682
708
  source?: string
683
709
  /**
@@ -699,7 +725,7 @@ export type KubbFilesProcessingUpdateContext = {
699
725
 
700
726
  export type KubbFilesProcessingEndContext = {
701
727
  /**
702
- * All files that were serialised in this batch.
728
+ * All files that were serialized in this batch.
703
729
  */
704
730
  files: Array<FileNode>
705
731
  }
@@ -887,6 +913,9 @@ function resolveConfig(userConfig: UserConfig): Config {
887
913
  ...userConfig.output,
888
914
  },
889
915
  storage: userConfig.storage ?? fsStorage(),
916
+ // Resolve `false` to "no cache". The default `fsCache()` is applied by `defineConfig`, not here,
917
+ // so a raw `createKubb` stays deterministic (no surprise on-disk cache) unless a cache is passed.
918
+ cache: userConfig.cache === false ? undefined : userConfig.cache,
890
919
  reporters: userConfig.reporters ?? [],
891
920
  plugins: userConfig.plugins ?? [],
892
921
  }
@@ -23,7 +23,7 @@ export type Parser<TMeta extends object = object, TNode = unknown> = {
23
23
  */
24
24
  extNames: Array<FileNode['extname']> | undefined
25
25
  /**
26
- * Serialise the file's AST into source code.
26
+ * Serialize the file's AST into source code.
27
27
  */
28
28
  parse(file: FileNode<TMeta>, options?: PrintOptions): string
29
29
  /**
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { AsyncEventEmitter, URLPath } from '@internals/utils'
2
2
  export * as ast from '@kubb/ast'
3
3
  export { createAdapter } from './createAdapter.ts'
4
+ export { createCache } from './createCache.ts'
4
5
  export { Diagnostics } from './diagnostics.ts'
5
6
  export { createKubb } from './createKubb.ts'
6
7
  export { createReporter, selectReporters } from './createReporter.ts'
@@ -17,6 +18,7 @@ export { defineParser } from './defineParser.ts'
17
18
  export { definePlugin } from './definePlugin.ts'
18
19
  export { defineResolver } from './defineResolver.ts'
19
20
  export { KubbDriver } from './KubbDriver.ts'
21
+ export { fsCache } from './caches/fsCache.ts'
20
22
  export { fsStorage } from './storages/fsStorage.ts'
21
23
  export { memoryStorage } from './storages/memoryStorage.ts'
22
24
  export * from './types.ts'