@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.
- package/dist/{diagnostics-Ba-FcsPo.d.ts → diagnostics-DZGgDzSv.d.ts} +150 -7
- package/dist/index.cjs +267 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.js +268 -19
- package/dist/index.js.map +1 -1
- package/dist/{memoryStorage-DA1bnMte.js → memoryStorage-BOnaknb7.js} +149 -10
- package/dist/memoryStorage-BOnaknb7.js.map +1 -0
- package/dist/{memoryStorage-DZqlEW7H.cjs → memoryStorage-Dldu8sRT.cjs} +148 -9
- package/dist/memoryStorage-Dldu8sRT.cjs.map +1 -0
- package/dist/mocks.cjs +1 -1
- package/dist/mocks.d.ts +1 -1
- package/dist/mocks.js +1 -1
- package/package.json +4 -4
- package/src/Fingerprint.ts +98 -0
- package/src/KubbDriver.ts +68 -8
- package/src/Manifest.ts +85 -0
- package/src/Telemetry.ts +13 -1
- package/src/caches/fsCache.ts +103 -0
- package/src/createCache.ts +74 -0
- package/src/createKubb.ts +33 -4
- package/src/defineParser.ts +1 -1
- package/src/index.ts +2 -0
- package/src/storages/fsStorage.ts +15 -23
- package/src/types.ts +2 -0
- package/dist/memoryStorage-DA1bnMte.js.map +0 -1
- package/dist/memoryStorage-DZqlEW7H.cjs.map +0 -1
|
@@ -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.
|
|
110
|
-
if (a.
|
|
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:
|
|
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
|
-
|
|
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,
|
package/src/Manifest.ts
ADDED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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
|
}
|
package/src/defineParser.ts
CHANGED
|
@@ -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
|
-
*
|
|
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'
|