From a640d60c9e4d16f85d816ba6f4f2aad7a30cc67b Mon Sep 17 00:00:00 2001 From: dev-seth-vapi Date: Tue, 15 Sep 2026 18:36:56 -0700 Subject: [PATCH 1/4] docs: cache Fern API property examples across builds --- .github/actions/setup-fern/action.yml | 22 +++++++ .github/workflows/check.yml | 5 +- .github/workflows/preview-docs.yml | 11 ++-- .github/workflows/publish-docs.yml | 5 +- .gitignore | 1 + scripts/fern/README.md | 55 +++++++++++++++++ scripts/fern/example-cache.cjs | 81 +++++++++++++++++++++++++ scripts/fern/example-cache.test.cjs | 85 +++++++++++++++++++++++++++ scripts/fern/register.cjs | 4 ++ scripts/fern/run.cjs | 54 +++++++++++++++++ 10 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 .github/actions/setup-fern/action.yml create mode 100644 scripts/fern/README.md create mode 100644 scripts/fern/example-cache.cjs create mode 100644 scripts/fern/example-cache.test.cjs create mode 100644 scripts/fern/register.cjs create mode 100644 scripts/fern/run.cjs diff --git a/.github/actions/setup-fern/action.yml b/.github/actions/setup-fern/action.yml new file mode 100644 index 000000000..75c9cb3b4 --- /dev/null +++ b/.github/actions/setup-fern/action.yml @@ -0,0 +1,22 @@ +name: Set up Fern with an example cache +description: Install the pinned CLI and restore generated API examples without skipping validation. +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22.22.2 + - name: Install pinned Fern + shell: bash + run: | + npm install --prefix "$RUNNER_TEMP/vapi-fern" --no-save --no-audit --no-fund fern-api@5.112.0 + echo "FERN_CLI_PATH=$RUNNER_TEMP/vapi-fern/node_modules/fern-api/cli.cjs" >> "$GITHUB_ENV" + - name: Test cache safeguards + shell: bash + run: node --test scripts/fern/example-cache.test.cjs + - uses: actions/cache@v4 + with: + path: .cache/fern-examples + key: fern-examples-v1-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('fern/apis/**', 'fern/fern.config.json', 'scripts/fern/**') }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + fern-examples-v1-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('fern/apis/**', 'fern/fern.config.json', 'scripts/fern/**') }}- diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 65a6b2a55..b8f63ce4b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -13,8 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Fern - run: npm install -g fern-api + - uses: ./.github/actions/setup-fern - name: Check API is valid - run: fern check + run: node scripts/fern/run.cjs check diff --git a/.github/workflows/preview-docs.yml b/.github/workflows/preview-docs.yml index ce6eadf4f..d7b61f5d6 100644 --- a/.github/workflows/preview-docs.yml +++ b/.github/workflows/preview-docs.yml @@ -10,8 +10,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Fern - run: npm install -g fern-api + - uses: ./.github/actions/setup-fern - name: Generate preview URL id: generate-docs @@ -19,13 +18,13 @@ jobs: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} POSTHOG_PROJECT_API_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} run: | - OUTPUT=$(fern generate --docs --preview --log-level debug 2>&1) || true - echo "$OUTPUT" - URL=$(echo "$OUTPUT" | grep -oP 'Published docs to \K.*(?= \()') + set -euo pipefail + node scripts/fern/run.cjs generate --docs --preview --log-level debug 2>&1 | tee "$RUNNER_TEMP/fern-preview.log" + URL=$(grep -oP 'Published docs to \K.*(?= \()' "$RUNNER_TEMP/fern-preview.log") echo "Preview URL: $URL" echo "🌿 Preview your docs: $URL" > preview_url.txt - name: Comment URL in PR uses: thollander/actions-comment-pull-request@v2.4.3 with: - filePath: preview_url.txt \ No newline at end of file + filePath: preview_url.txt diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 21735321b..afce822fe 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -13,11 +13,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Fern - run: npm install -g fern-api + - uses: ./.github/actions/setup-fern - name: Publish Docs env: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} POSTHOG_PROJECT_API_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} - run: fern generate --docs --log-level debug \ No newline at end of file + run: node scripts/fern/run.cjs generate --docs --log-level debug diff --git a/.gitignore b/.gitignore index 587625b32..d63663f59 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist/ .env .DS_Store .tool-versions +.cache/ # Fern AI-generated examples (regenerated on each build; not committed) fern/apis/api/ai_examples_override.yml diff --git a/scripts/fern/README.md b/scripts/fern/README.md new file mode 100644 index 000000000..eac56c692 --- /dev/null +++ b/scripts/fern/README.md @@ -0,0 +1,55 @@ +# Fern example cache + +Fern 5.112.0 repeatedly generates examples for nested API properties during +validation and publication. The GPT-Live docs publish spent about 24 minutes +preparing validation and another 25 minutes building the API navigation. Its +final deployment took six seconds. + +This adapter caches the exact return values of Fern's property-example generator. +It does not remove fields, shorten examples, skip schema checks, disable SDK +snippets, or change the API playground. Changes are confined to this docs repo. + +## How it works + +- A cold miss invokes the unmodified property-example generator. +- Successful values are stored with V8 serialization, preserving `undefined` + values that JSON would discard. Each entry has a checksum and is written + atomically. Every read returns an independent object. +- Any call that reports a diagnostic through Fern's error collector is not + cached. Exceptions still fail the build. Unreadable or corrupt entries are + regenerated normally. +- The cache namespace includes every file under `fern/apis`, the Fern config, + adapter implementation, exact CLI checksum, Node/V8 versions, OS, and CPU + architecture. Each entry also includes the resolved API spec, parser settings, + example settings, schema, property ID, and breadcrumbs. Markdown changes do + not invalidate API examples. +- GitHub Actions restores only caches with the same API/tooling fingerprint. + Each successful run saves a new snapshot, so additional examples generated + during publishing can extend the validation cache. It never falls back to + different API inputs. The first build for new inputs fills the cache. + +## Maintenance and escape hatch + +This is a small, version-specific adapter to Fern's bundled CLI, not a public +Fern extension API. `run.cjs` verifies the complete official CLI SHA-256 and the +single expected function signature before adding the wrapper. An unexpected +version or bundle fails before publishing. Upgrading Fern requires reviewing the +adapter and repeating the output and timing checks. + +To bypass the cache, invoke the installed Fern CLI directly. No source schema or +published content needs to change. Removing the setup action and restoring the +original Fern commands also removes this optimization. + +## Local verification + +```sh +npm install --prefix /tmp/vapi-fern --no-save fern-api@5.112.0 +export FERN_CLI_PATH=/tmp/vapi-fern/node_modules/fern-api/cli.cjs +node --test scripts/fern/example-cache.test.cjs +node scripts/fern/run.cjs check --local --log-level debug +node scripts/fern/run.cjs generate --docs --preview --log-level debug +``` + +Only use `--preview` for verification. Production publishing remains in the +existing main-branch workflow. Logs print cache hits, misses, and uncached +diagnostic-producing calls at exit. diff --git a/scripts/fern/example-cache.cjs b/scripts/fern/example-cache.cjs new file mode 100644 index 000000000..6d71624b3 --- /dev/null +++ b/scripts/fern/example-cache.cjs @@ -0,0 +1,81 @@ +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const v8 = require('node:v8'); + +const digest = (value) => crypto.createHash('sha256').update(value).digest('hex'); + +// Cache only the return value of Fern's property-example generator. API parsing, +// schema validation, markdown validation, and deployment still run normally. +function createExampleCache({ directory, namespace }) { + const contexts = new WeakMap(); + const stats = { hits: 0, misses: 0, skipped: 0 }; + + function contextKey(context) { + let key = contexts.get(context); + if (key !== undefined) return key; + const { + spec, settings, generationLanguage, smartCasing, namespace: apiNamespace, + exampleGenerationArgs, authOverrides, environmentOverrides, + globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples, + } = context; + key = digest(JSON.stringify({ + spec, settings, generationLanguage, smartCasing, namespace: apiNamespace, + exampleGenerationArgs, authOverrides, environmentOverrides, + globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples, + })); + contexts.set(context, key); + return key; + } + + function run(args, generate) { + const { context, ...inputs } = args; + const file = path.join(directory, namespace, contextKey(context), `${digest(JSON.stringify(inputs))}.bin`); + try { + const entry = fs.readFileSync(file); + const payload = entry.subarray(65); + if (entry[64] !== 10 || entry.subarray(0, 64).toString() !== digest(payload)) throw new Error('Invalid cache checksum'); + const result = v8.deserialize(payload); + stats.hits++; + return result; + } catch { + // A missing, stale, or corrupt cache is a normal cold build. + } + stats.misses++; + const collector = context.errorCollector; + const owned = Object.hasOwn(collector, 'collect'); + const collect = collector.collect; + let clean = true; + collector.collect = function (...values) { + clean = false; + return collect.apply(this, values); + }; + let result; + try { + result = generate(); + } finally { + if (owned) collector.collect = collect; + else delete collector.collect; + } + // Never suppress diagnostics on subsequent builds. + if (!clean) { + stats.skipped++; + return result; + } + const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + const payload = v8.serialize(result); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(temporary, Buffer.concat([Buffer.from(`${digest(payload)}\n`), payload])); + fs.renameSync(temporary, file); + } catch { + // Cache storage must not make an otherwise valid build fail. + try { fs.unlinkSync(temporary); } catch {} + } + return result; + } + + return { run, stats }; +} + +module.exports = { createExampleCache, digest }; diff --git a/scripts/fern/example-cache.test.cjs b/scripts/fern/example-cache.test.cjs new file mode 100644 index 000000000..da347b299 --- /dev/null +++ b/scripts/fern/example-cache.test.cjs @@ -0,0 +1,85 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createExampleCache } = require('./example-cache.cjs'); +const { inputDigest } = require('./run.cjs'); + +function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fern-cache-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const errors = []; + const context = { + spec: { components: { schemas: { Voice: { type: 'string', enum: ['marin'] } } } }, + settings: {}, + errorCollector: { collect(value) { errors.push(value); } }, + }; + const args = { context, propertySchema: { $ref: '#/components/schemas/Voice' }, breadcrumbs: ['voice'], propertyId: 'voice' }; + return { directory, context, args, errors, cache: createExampleCache({ directory, namespace: 'test' }) }; +} + +test('a new process can reuse an exact result without sharing mutable objects', (t) => { + const { directory, args, cache } = fixture(t); + const expected = { userSpecifiedExamples: {}, autogeneratedExamples: { voice: { value: 'marin', missing: undefined } } }; + assert.deepEqual(cache.run(args, () => expected), expected); + expected.autogeneratedExamples.voice.value = 'changed after conversion'; + const next = createExampleCache({ directory, namespace: 'test' }); + const hit = next.run(args, () => { throw new Error('must be a cache hit'); }); + assert.equal(hit.autogeneratedExamples.voice.value, 'marin'); + assert.ok(Object.hasOwn(hit.autogeneratedExamples.voice, 'missing')); + hit.autogeneratedExamples.voice.value = 'changed after cache read'; + assert.equal(next.run(args, () => assert.fail()).autogeneratedExamples.voice.value, 'marin'); +}); + +test('schemas, parser settings, property inputs, and namespaces invalidate entries', (t) => { + const { directory, args, cache } = fixture(t); + cache.run(args, () => 'old'); + for (const context of [ + { ...args.context, spec: { changed: true } }, + { ...args.context, settings: { changed: true } }, + { ...args.context, generationLanguage: 'python' }, + { ...args.context, smartCasing: true }, + ]) assert.equal(cache.run({ ...args, context }, () => 'new'), 'new'); + assert.equal(cache.run({ ...args, propertySchema: { type: 'number' } }, () => 'new'), 'new'); + assert.equal(createExampleCache({ directory, namespace: 'new-version' }).run(args, () => 'new'), 'new'); +}); + +test('diagnostics are emitted on every run and the collector is restored', (t) => { + const { args, context, errors, cache } = fixture(t); + const original = context.errorCollector.collect; + for (let i = 0; i < 2; i++) { + cache.run(args, () => { context.errorCollector.collect('invalid example'); return {}; }); + assert.equal(context.errorCollector.collect, original); + } + assert.deepEqual(errors, ['invalid example', 'invalid example']); + assert.equal(cache.stats.hits, 0); +}); + +test('generation errors propagate and do not poison the cache', (t) => { + const { args, context, cache } = fixture(t); + const original = context.errorCollector.collect; + assert.throws(() => cache.run(args, () => { throw new Error('invalid schema'); }), /invalid schema/); + assert.equal(context.errorCollector.collect, original); + assert.equal(cache.run(args, () => 'recovered'), 'recovered'); +}); + +test('corrupt entries fall back to original generation', (t) => { + const { directory, args, cache } = fixture(t); + cache.run(args, () => 'old'); + const contextDirectory = path.join(directory, 'test', fs.readdirSync(path.join(directory, 'test'))[0]); + fs.writeFileSync(path.join(contextDirectory, fs.readdirSync(contextDirectory)[0]), 'corrupt'); + assert.equal(cache.run(args, () => 'regenerated'), 'regenerated'); +}); + +test('all local API inputs invalidate the cache but prose does not', (t) => { + const { directory } = fixture(t); + fs.mkdirSync(path.join(directory, 'fern/apis/api'), { recursive: true }); + fs.writeFileSync(path.join(directory, 'fern/fern.config.json'), '{"version":"5.112.0"}'); + fs.writeFileSync(path.join(directory, 'fern/apis/api/openapi.json'), '{}'); + const before = inputDigest(directory); + fs.writeFileSync(path.join(directory, 'fern/overview.mdx'), 'new prose'); + assert.equal(inputDigest(directory), before); + fs.writeFileSync(path.join(directory, 'fern/apis/api/overrides.yml'), 'new schema override'); + assert.notEqual(inputDigest(directory), before); +}); diff --git a/scripts/fern/register.cjs b/scripts/fern/register.cjs new file mode 100644 index 000000000..434916106 --- /dev/null +++ b/scripts/fern/register.cjs @@ -0,0 +1,4 @@ +const { createExampleCache } = require('./example-cache.cjs'); +const cache = createExampleCache({ directory: process.env.VAPI_FERN_CACHE_DIR, namespace: process.env.VAPI_FERN_CACHE_NAMESPACE }); +globalThis.__vapiFernExamples = cache.run; +process.on('exit', () => console.error('[fern-example-cache]', JSON.stringify(cache.stats))); diff --git a/scripts/fern/run.cjs b/scripts/fern/run.cjs new file mode 100644 index 000000000..0188a9660 --- /dev/null +++ b/scripts/fern/run.cjs @@ -0,0 +1,54 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); +const { digest } = require('./example-cache.cjs'); + +const VERSION = '5.112.0'; +const CLI_SHA256 = '9932a459d6e109b40a1dcf4551317e9737779c5c3750f0c78701c0c4d58d6305'; +const MARKER = 'function u8d({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})'; + +function inputDigest(root) { + const inputs = []; + function visit(relative) { + const absolute = path.join(root, relative); + for (const entry of fs.readdirSync(absolute, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const child = path.join(relative, entry.name); + if (entry.isDirectory()) visit(child); + else if (entry.isFile()) inputs.push([child, digest(fs.readFileSync(path.join(root, child)))]); + else throw new Error(`Unsupported API input: ${child}`); + } + } + visit('fern/apis'); + inputs.push(['fern/fern.config.json', digest(fs.readFileSync(path.join(root, 'fern/fern.config.json')))]); + inputs.push(['runtime', VERSION, CLI_SHA256, process.versions.node, process.versions.v8, process.platform, process.arch]); + inputs.push(['cache-code', digest(fs.readFileSync(path.join(__dirname, 'example-cache.cjs')))]); + return digest(JSON.stringify(inputs)); +} + +function main() { + const root = path.resolve(__dirname, '../..'); + const config = JSON.parse(fs.readFileSync(path.join(root, 'fern/fern.config.json'), 'utf8')); + if (config.version !== VERSION) throw new Error('Fern version changed. Review and revalidate the example-cache adapter before publishing.'); + if (!process.env.FERN_CLI_PATH) throw new Error('Set FERN_CLI_PATH to the installed fern-api/cli.cjs (see scripts/fern/README.md).'); + const source = fs.readFileSync(process.env.FERN_CLI_PATH, 'utf8'); + if (digest(source) !== CLI_SHA256 || source.split(MARKER).length !== 2) throw new Error('Unexpected Fern CLI contents. Refusing to apply the cache adapter.'); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'vapi-fern-')); + const patched = source.replace(MARKER, 'function u8d(args){return globalThis.__vapiFernExamples(args,()=>vapiOriginalExamples(args))}function vapiOriginalExamples({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})'); + try { + const cli = path.join(temporary, 'fern.cjs'); + fs.writeFileSync(cli, patched); + const result = spawnSync(process.execPath, ['--require', path.join(__dirname, 'register.cjs'), cli, ...process.argv.slice(2)], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, VAPI_FERN_CACHE_NAMESPACE: inputDigest(root), VAPI_FERN_CACHE_DIR: path.join(root, '.cache/fern-examples') }, + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +if (require.main === module) main(); +module.exports = { inputDigest }; From 39539bbb207dd64f9d979f5100f06d0253c00d41 Mon Sep 17 00:00:00 2001 From: dev-seth-vapi Date: Tue, 15 Sep 2026 18:38:30 -0700 Subject: [PATCH 2/4] docs: include external reference base in example cache keys --- scripts/fern/example-cache.cjs | 2 ++ scripts/fern/example-cache.test.cjs | 1 + 2 files changed, 3 insertions(+) diff --git a/scripts/fern/example-cache.cjs b/scripts/fern/example-cache.cjs index 6d71624b3..45bfeb3bf 100644 --- a/scripts/fern/example-cache.cjs +++ b/scripts/fern/example-cache.cjs @@ -18,11 +18,13 @@ function createExampleCache({ directory, namespace }) { spec, settings, generationLanguage, smartCasing, namespace: apiNamespace, exampleGenerationArgs, authOverrides, environmentOverrides, globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples, + documentBaseDir, } = context; key = digest(JSON.stringify({ spec, settings, generationLanguage, smartCasing, namespace: apiNamespace, exampleGenerationArgs, authOverrides, environmentOverrides, globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples, + documentBaseDir, })); contexts.set(context, key); return key; diff --git a/scripts/fern/example-cache.test.cjs b/scripts/fern/example-cache.test.cjs index da347b299..584d35cff 100644 --- a/scripts/fern/example-cache.test.cjs +++ b/scripts/fern/example-cache.test.cjs @@ -40,6 +40,7 @@ test('schemas, parser settings, property inputs, and namespaces invalidate entri { ...args.context, settings: { changed: true } }, { ...args.context, generationLanguage: 'python' }, { ...args.context, smartCasing: true }, + { ...args.context, documentBaseDir: '/different-reference-base' }, ]) assert.equal(cache.run({ ...args, context }, () => 'new'), 'new'); assert.equal(cache.run({ ...args, propertySchema: { type: 'number' } }, () => 'new'), 'new'); assert.equal(createExampleCache({ directory, namespace: 'new-version' }).run(args, () => 'new'), 'new'); From 4a853cc1dbcd4415e1306c905975916cb6b17ed9 Mon Sep 17 00:00:00 2001 From: dev-seth-vapi Date: Tue, 15 Sep 2026 18:47:57 -0700 Subject: [PATCH 3/4] docs: cache request examples with stable typed input keys --- scripts/fern/README.md | 13 ++++++++-- scripts/fern/example-cache.cjs | 40 +++++++++++++++++++++++++---- scripts/fern/example-cache.test.cjs | 20 ++++++++++++++- scripts/fern/run.cjs | 7 +++-- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/scripts/fern/README.md b/scripts/fern/README.md index eac56c692..d025798db 100644 --- a/scripts/fern/README.md +++ b/scripts/fern/README.md @@ -5,7 +5,8 @@ validation and publication. The GPT-Live docs publish spent about 24 minutes preparing validation and another 25 minutes building the API navigation. Its final deployment took six seconds. -This adapter caches the exact return values of Fern's property-example generator. +This adapter caches the exact return values of Fern's property and request/response +example generators. It does not remove fields, shorten examples, skip schema checks, disable SDK snippets, or change the API playground. Changes are confined to this docs repo. @@ -32,7 +33,7 @@ snippets, or change the API playground. Changes are confined to this docs repo. This is a small, version-specific adapter to Fern's bundled CLI, not a public Fern extension API. `run.cjs` verifies the complete official CLI SHA-256 and the -single expected function signature before adding the wrapper. An unexpected +two expected function signatures before adding the wrappers. An unexpected version or bundle fails before publishing. Upgrading Fern requires reviewing the adapter and repeating the output and timing checks. @@ -53,3 +54,11 @@ node scripts/fern/run.cjs generate --docs --preview --log-level debug Only use `--preview` for verification. Production publishing remains in the existing main-branch workflow. Logs print cache hits, misses, and uncached diagnostic-producing calls at exit. + +## Measured validation results + +On the same machine and API inputs, the original generators took 697 seconds +in docs validation setup. A fresh process using the cache took 6.9 seconds, +with 2,266 hits and zero misses. Both complete local checks reported zero errors +and 11 warnings, with identical warning text. The cached full check took about +15 seconds. This measures validation, not end-to-end publication. diff --git a/scripts/fern/example-cache.cjs b/scripts/fern/example-cache.cjs index 45bfeb3bf..a478b86f9 100644 --- a/scripts/fern/example-cache.cjs +++ b/scripts/fern/example-cache.cjs @@ -5,7 +5,31 @@ const v8 = require('node:v8'); const digest = (value) => crypto.createHash('sha256').update(value).digest('hex'); -// Cache only the return value of Fern's property-example generator. API parsing, +// V8 serialization preserves values but is not a canonical encoding for keys. +// Tag every value so undefined, null, NaN, and user-authored objects cannot collide. +function fingerprint(value) { + const ancestors = new Set(); + function encode(value) { + if (value === null) return ['null']; + if (value === undefined) return ['undefined']; + if (typeof value === 'string' || typeof value === 'boolean') return [typeof value, value]; + if (typeof value === 'number') return ['number', Object.is(value, -0) ? '-0' : String(value)]; + if (typeof value !== 'object') throw new Error('Unsupported cache input'); + if (value instanceof Date) return ['date', value.toISOString()]; + if (ancestors.has(value)) throw new Error('Cyclic cache input'); + ancestors.add(value); + try { + if (Array.isArray(value)) return ['array', Array.from({ length: value.length }, (_, i) => Object.hasOwn(value, i) ? encode(value[i]) : ['hole'])]; + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error('Unsupported cache input prototype'); + return ['object', Object.keys(value).sort().map((key) => [key, encode(value[key])])]; + } finally { + ancestors.delete(value); + } + } + return digest(JSON.stringify(encode(value))); +} + +// Cache only return values of Fern's property and request/response example generators. API parsing, // schema validation, markdown validation, and deployment still run normally. function createExampleCache({ directory, namespace }) { const contexts = new WeakMap(); @@ -20,19 +44,25 @@ function createExampleCache({ directory, namespace }) { globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples, documentBaseDir, } = context; - key = digest(JSON.stringify({ + key = fingerprint({ spec, settings, generationLanguage, smartCasing, namespace: apiNamespace, exampleGenerationArgs, authOverrides, environmentOverrides, globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples, documentBaseDir, - })); + }); contexts.set(context, key); return key; } function run(args, generate) { const { context, ...inputs } = args; - const file = path.join(directory, namespace, contextKey(context), `${digest(JSON.stringify(inputs))}.bin`); + let file; + try { + file = path.join(directory, namespace, contextKey(context), `${fingerprint(inputs)}.bin`); + } catch { + stats.skipped++; + return generate(); + } try { const entry = fs.readFileSync(file); const payload = entry.subarray(65); @@ -80,4 +110,4 @@ function createExampleCache({ directory, namespace }) { return { run, stats }; } -module.exports = { createExampleCache, digest }; +module.exports = { createExampleCache, digest, fingerprint }; diff --git a/scripts/fern/example-cache.test.cjs b/scripts/fern/example-cache.test.cjs index 584d35cff..eb01e68c7 100644 --- a/scripts/fern/example-cache.test.cjs +++ b/scripts/fern/example-cache.test.cjs @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { createExampleCache } = require('./example-cache.cjs'); +const { createExampleCache, fingerprint } = require('./example-cache.cjs'); const { inputDigest } = require('./run.cjs'); function fixture(t) { @@ -65,6 +65,24 @@ test('generation errors propagate and do not poison the cache', (t) => { assert.equal(cache.run(args, () => 'recovered'), 'recovered'); }); +test('cache keys preserve null, undefined, and non-JSON numeric values', (t) => { + const { args, cache } = fixture(t); + for (const example of [null, undefined, NaN, Infinity]) { + const inputs = { ...args, mediaExampleArgs: { example } }; + assert.deepEqual(cache.run(inputs, () => ({ example })), { example }); + assert.deepEqual(cache.run(inputs, () => assert.fail()), { example }); + } + assert.equal(cache.stats.misses, 4); + assert.equal(cache.stats.hits, 4); +}); + +test('fingerprints are stable across key order and shared object identities', () => { + const shared = { text: 'hello', number: 3 }; + assert.equal(fingerprint({ a: shared, b: shared }), fingerprint({ b: { number: 3, text: 'hello' }, a: { text: 'hello', number: 3 } })); + assert.notEqual(fingerprint([undefined]), fingerprint(Array(1))); + assert.notEqual(fingerprint(NaN), fingerprint(['number', 'NaN'])); +}); + test('corrupt entries fall back to original generation', (t) => { const { directory, args, cache } = fixture(t); cache.run(args, () => 'old'); diff --git a/scripts/fern/run.cjs b/scripts/fern/run.cjs index 0188a9660..7f242c984 100644 --- a/scripts/fern/run.cjs +++ b/scripts/fern/run.cjs @@ -7,6 +7,7 @@ const { digest } = require('./example-cache.cjs'); const VERSION = '5.112.0'; const CLI_SHA256 = '9932a459d6e109b40a1dcf4551317e9737779c5c3750f0c78701c0c4d58d6305'; const MARKER = 'function u8d({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})'; +const MEDIA_MARKER = 'generateOrValidateExample({schema:t,example:r,generateOptionalProperties:n,exampleGenerationStrategy:i})'; function inputDigest(root) { const inputs = []; @@ -32,9 +33,11 @@ function main() { if (config.version !== VERSION) throw new Error('Fern version changed. Review and revalidate the example-cache adapter before publishing.'); if (!process.env.FERN_CLI_PATH) throw new Error('Set FERN_CLI_PATH to the installed fern-api/cli.cjs (see scripts/fern/README.md).'); const source = fs.readFileSync(process.env.FERN_CLI_PATH, 'utf8'); - if (digest(source) !== CLI_SHA256 || source.split(MARKER).length !== 2) throw new Error('Unexpected Fern CLI contents. Refusing to apply the cache adapter.'); + if (digest(source) !== CLI_SHA256 || source.split(MARKER).length !== 2 || source.split(MEDIA_MARKER).length !== 2) throw new Error('Unexpected Fern CLI contents. Refusing to apply the cache adapter.'); const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'vapi-fern-')); - const patched = source.replace(MARKER, 'function u8d(args){return globalThis.__vapiFernExamples(args,()=>vapiOriginalExamples(args))}function vapiOriginalExamples({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})'); + const patched = source + .replace(MARKER, 'function u8d(args){return globalThis.__vapiFernExamples(args,()=>vapiOriginalExamples(args))}function vapiOriginalExamples({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})') + .replace(MEDIA_MARKER, 'generateOrValidateExample(args){return globalThis.__vapiFernExamples({context:this.context,breadcrumbs:this.breadcrumbs,mediaExampleArgs:args},()=>this.vapiOriginalMediaExample(args))}vapiOriginalMediaExample({schema:t,example:r,generateOptionalProperties:n,exampleGenerationStrategy:i})'); try { const cli = path.join(temporary, 'fern.cjs'); fs.writeFileSync(cli, patched); From f918bd90aa2ec4cf4a54e1ce4b5c3397baa9562a Mon Sep 17 00:00:00 2001 From: dev-seth-vapi Date: Tue, 15 Sep 2026 18:55:16 -0700 Subject: [PATCH 4/4] docs: preserve warm caches across generated build outputs --- .github/actions/setup-fern/action.yml | 4 ++-- scripts/fern/README.md | 13 +++++++++++-- scripts/fern/example-cache.test.cjs | 2 ++ scripts/fern/run.cjs | 4 ++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup-fern/action.yml b/.github/actions/setup-fern/action.yml index 75c9cb3b4..1f1ba7aa8 100644 --- a/.github/actions/setup-fern/action.yml +++ b/.github/actions/setup-fern/action.yml @@ -17,6 +17,6 @@ runs: - uses: actions/cache@v4 with: path: .cache/fern-examples - key: fern-examples-v1-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('fern/apis/**', 'fern/fern.config.json', 'scripts/fern/**') }}-${{ github.run_id }}-${{ github.run_attempt }} + key: fern-examples-v1-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('fern/apis/**', 'fern/fern.config.json', 'scripts/fern/run.cjs', 'scripts/fern/example-cache.cjs', 'scripts/fern/register.cjs') }}-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | - fern-examples-v1-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('fern/apis/**', 'fern/fern.config.json', 'scripts/fern/**') }}- + fern-examples-v1-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('fern/apis/**', 'fern/fern.config.json', 'scripts/fern/run.cjs', 'scripts/fern/example-cache.cjs', 'scripts/fern/register.cjs') }}- diff --git a/scripts/fern/README.md b/scripts/fern/README.md index d025798db..8a2fffded 100644 --- a/scripts/fern/README.md +++ b/scripts/fern/README.md @@ -19,11 +19,13 @@ snippets, or change the API playground. Changes are confined to this docs repo. - Any call that reports a diagnostic through Fern's error collector is not cached. Exceptions still fail the build. Unreadable or corrupt entries are regenerated normally. -- The cache namespace includes every file under `fern/apis`, the Fern config, +- The cache namespace includes API source files under `fern/apis`, the Fern config, adapter implementation, exact CLI checksum, Node/V8 versions, OS, and CPU architecture. Each entry also includes the resolved API spec, parser settings, example settings, schema, property ID, and breadcrumbs. Markdown changes do - not invalidate API examples. + not invalidate API examples. Fern's generated `.definition` directories and + `ai_examples_override.yml` files do not invalidate the source fingerprint. + Actual resolved examples and overrides remain part of each entry key. - GitHub Actions restores only caches with the same API/tooling fingerprint. Each successful run saves a new snapshot, so additional examples generated during publishing can extend the validation cache. It never falls back to @@ -62,3 +64,10 @@ in docs validation setup. A fresh process using the cache took 6.9 seconds, with 2,266 hits and zero misses. Both complete local checks reported zero errors and 11 warnings, with identical warning text. The cached full check took about 15 seconds. This measures validation, not end-to-end publication. + +Two complete preview publications took approximately 47 and 49 seconds, with +4,532 cache hits and zero misses each. Both used the normal publishing flags, +including dynamic SDK snippets. Browser checks covered the guide, API reference, +request examples, and API Explorer form. These are local timings, not CI timings. +A first build after API or tooling changes still pays the original generation +cost before populating the cache. diff --git a/scripts/fern/example-cache.test.cjs b/scripts/fern/example-cache.test.cjs index eb01e68c7..42af2484c 100644 --- a/scripts/fern/example-cache.test.cjs +++ b/scripts/fern/example-cache.test.cjs @@ -99,6 +99,8 @@ test('all local API inputs invalidate the cache but prose does not', (t) => { const before = inputDigest(directory); fs.writeFileSync(path.join(directory, 'fern/overview.mdx'), 'new prose'); assert.equal(inputDigest(directory), before); + fs.writeFileSync(path.join(directory, 'fern/apis/api/ai_examples_override.yml'), 'generated output'); + assert.equal(inputDigest(directory), before); fs.writeFileSync(path.join(directory, 'fern/apis/api/overrides.yml'), 'new schema override'); assert.notEqual(inputDigest(directory), before); }); diff --git a/scripts/fern/run.cjs b/scripts/fern/run.cjs index 7f242c984..aadd1e006 100644 --- a/scripts/fern/run.cjs +++ b/scripts/fern/run.cjs @@ -14,6 +14,9 @@ function inputDigest(root) { function visit(relative) { const absolute = path.join(root, relative); for (const entry of fs.readdirSync(absolute, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + // Fern writes these build outputs itself. The resolved spec is also in + // each entry key, including any examples actually applied as overrides. + if (entry.name === '.definition' || entry.name === 'ai_examples_override.yml') continue; const child = path.join(relative, entry.name); if (entry.isDirectory()) visit(child); else if (entry.isFile()) inputs.push([child, digest(fs.readFileSync(path.join(root, child)))]); @@ -24,6 +27,7 @@ function inputDigest(root) { inputs.push(['fern/fern.config.json', digest(fs.readFileSync(path.join(root, 'fern/fern.config.json')))]); inputs.push(['runtime', VERSION, CLI_SHA256, process.versions.node, process.versions.v8, process.platform, process.arch]); inputs.push(['cache-code', digest(fs.readFileSync(path.join(__dirname, 'example-cache.cjs')))]); + inputs.push(['adapter-code', digest(fs.readFileSync(__filename))]); return digest(JSON.stringify(inputs)); }