From 6ba8e79b5524c026c5acc2aa0198c2c8be8a7b9a Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 09:47:25 +0200 Subject: [PATCH 01/20] feat(flags-core): preserve datafile fetch timestamps --- .changeset/embedded-flags-fetch-time.md | 8 ++ .../src/index.test.ts | 58 +++++++++++-- .../prepare-flags-definitions/src/index.ts | 3 +- packages/vercel-flags-core/CLAUDE.md | 5 +- .../src/controller/datafile-cache.test.ts | 44 +++++++--- .../src/controller/tagged-data.test.ts | 84 +++++++++++++++++++ .../src/controller/tagged-data.ts | 15 +++- packages/vercel-flags-core/src/types.ts | 6 ++ 8 files changed, 198 insertions(+), 25 deletions(-) create mode 100644 .changeset/embedded-flags-fetch-time.md create mode 100644 packages/vercel-flags-core/src/controller/tagged-data.test.ts diff --git a/.changeset/embedded-flags-fetch-time.md b/.changeset/embedded-flags-fetch-time.md new file mode 100644 index 000000000..57f49a5d2 --- /dev/null +++ b/.changeset/embedded-flags-fetch-time.md @@ -0,0 +1,8 @@ +--- +"@vercel/prepare-flags-definitions": patch +"@vercel/flags-core": patch +--- + +Record `fetchedAt` when a datafile fetch completes and preserve it in generated flag definitions. Loading the bundle retains the original timestamp so the Flags SDK can determine its age. + +Expose optional `fetchedAt` metadata on datafiles. Record it for accepted live updates and preserve valid timestamps when loading provided or bundled definitions, without mutating the input. diff --git a/packages/prepare-flags-definitions/src/index.test.ts b/packages/prepare-flags-definitions/src/index.test.ts index 297d5564e..b835b4dcd 100644 --- a/packages/prepare-flags-definitions/src/index.test.ts +++ b/packages/prepare-flags-definitions/src/index.test.ts @@ -1,5 +1,8 @@ -import { readFile } from 'node:fs/promises'; -import { describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { version as pkgVersion } from '../package.json'; import { generateDefinitionsModule, @@ -8,6 +11,16 @@ import { prepareFlagsDefinitions, } from './index'; +const FETCH_TIME = 1_700_000_000_000; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'], now: FETCH_TIME }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + function createOidcToken(projectId: string): string { const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString( 'base64url', @@ -118,6 +131,35 @@ describe('generateDefinitionsModule', () => { }); describe('prepareFlagsDefinitions', () => { + it('embeds fetch completion time and preserves it when loaded later', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'flags-fetched-at-')); + try { + await prepareFlagsDefinitions({ + cwd, + env: { FLAGS: 'vf_server_timestamp' }, + fetch: vi.fn().mockResolvedValue({ + ok: true, + json: async () => { + vi.setSystemTime(FETCH_TIME + 5_000); + return { configUpdatedAt: 123, fetchedAt: 456, definitions: {} }; + }, + }), + }); + vi.setSystemTime(FETCH_TIME + 365 * 24 * 60 * 60 * 1_000); + const url = pathToFileURL( + join(cwd, 'node_modules/@vercel/flags-definitions/index.js'), + ).href; + const bundle = await import(/* @vite-ignore */ url); + expect(bundle.get(hashSdkKey('vf_server_timestamp'))).toEqual({ + configUpdatedAt: 123, + fetchedAt: FETCH_TIME + 5_000, + definitions: {}, + }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + it('returns { created: false, reason: "no-flags-entries" } when no flags auth is in env', async () => { const result = await prepareFlagsDefinitions({ cwd: '/tmp/test', @@ -148,7 +190,7 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, @@ -242,7 +284,7 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "3790790d2dc9b23c4539a9f3c49eb5820e4216daebdd7eeee9136f3ceccc31a3": _d0, @@ -298,7 +340,7 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "prj_oidc_test": _d0, @@ -338,7 +380,7 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, @@ -440,8 +482,8 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); - const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); + const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, diff --git a/packages/prepare-flags-definitions/src/index.ts b/packages/prepare-flags-definitions/src/index.ts index 90df2df56..1747eaa81 100644 --- a/packages/prepare-flags-definitions/src/index.ts +++ b/packages/prepare-flags-definitions/src/index.ts @@ -183,7 +183,8 @@ async function fetchDatafile( } if (res.ok) { - return res.json() as Promise; + const definitions = (await res.json()) as BundledDefinitions; + return { ...definitions, fetchedAt: Date.now() }; } if (res.status === 404) { diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index f8128cd2d..535805830 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -260,7 +260,10 @@ The Controller selects the origin. Initial/fallback snapshots are tagged before - `'fetched'` → `'remote'` - `'bundled'` → `'embedded'` -`tagData` mutates the input object in-place via `Object.assign` (callers always pass freshly-created data). +`tagData` returns a shallow copy. Accepted fetched/stream/poll data is stamped with +`fetchedAt`; provided and bundled data preserves valid finite nonnegative timestamps. +Missing/invalid timestamps mean unknown fetch age. Loading data never resets its age, +and equal/older source responses do not replace or retag the cache. ### Usage Tracking diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts index bd20a09e9..c85d2b386 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts @@ -439,8 +439,13 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, origin)).toBeUndefined(); - expect(cache.read()).toBe(incoming); - expect(incoming).toEqual({ ...snapshot, _origin: origin }); + expect(cache.read()).not.toBe(incoming); + expect(cache.read()).toEqual({ + ...snapshot, + _origin: origin, + fetchedAt: 2_000, + }); + expect(incoming).toEqual(snapshot); expect(vi.getTimerCount()).toBe(0); }); @@ -475,14 +480,20 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, 'poll')).toBeUndefined(); - expect(cache.read()).toBe(incoming); - expect(incoming).toEqual({ ...snapshot, _origin: 'poll' }); + const accepted = cache.read(); + expect(accepted).not.toBe(incoming); + expect(accepted).toEqual({ + ...snapshot, + _origin: 'poll', + fetchedAt: 2_000, + }); + expect(incoming).toEqual(snapshot); expect(cache.read()?.definitions).toBe(incoming.definitions); const nextError = new Error('second outage'); cache.fail(nextError); vi.setSystemTime(2_100); - expect(cache.read()).toBe(incoming); + expect(cache.read()).toBe(accepted); vi.setSystemTime(2_101); expect(() => cache.read()).toThrow(nextError); }); @@ -497,11 +508,13 @@ describe('DatafileCache', () => { const incoming = response({ ...overrides, configUpdatedAt: 2 }); cache.updateFromSource(incoming, 'stream'); - expect(cache.read()).toBe(incoming); - expect(incoming).toEqual({ - ...response({ ...overrides, configUpdatedAt: 2 }), + expect(cache.read()).not.toBe(incoming); + expect(cache.read()).toEqual({ + ...incoming, _origin: 'stream', + fetchedAt: 1_000, }); + expect(incoming).toEqual(response({ ...overrides, configUpdatedAt: 2 })); }); it.each([ @@ -646,8 +659,13 @@ describe('DatafileCache', () => { cache.seed(oldResponse); const replacement = response({ configUpdatedAt: 2 }); cache.updateFromSource(replacement, 'poll'); - expect(cache.read()).toBe(replacement); - expect(cache.read()?._origin).toBe('poll'); + const accepted = cache.read(); + expect(accepted).not.toBe(replacement); + expect(accepted).toEqual({ + ...replacement, + _origin: 'poll', + fetchedAt: 1_000, + }); const error = new Error('replacement outage'); cache.fail(error); vi.setSystemTime(1_050); @@ -656,19 +674,19 @@ describe('DatafileCache', () => { if (staleIfErrorMs === 0) { expect(() => cache.read()).toThrow(error); } else { - expect(cache.read()).toBe(replacement); + expect(cache.read()).toBe(accepted); } expect(oldResponse._origin).toBe('bundled'); vi.setSystemTime(1_100); if (staleIfErrorMs === 0) { expect(() => cache.read()).toThrow(error); } else { - expect(cache.read()).toBe(replacement); + expect(cache.read()).toBe(accepted); } vi.setSystemTime(1_101); expect(() => cache.read()).toThrow(error); expect(cache.tryConfirm(replacement)).toBe(true); - expect(cache.read()).toBe(replacement); + expect(cache.read()).toBe(accepted); }); }); }); diff --git a/packages/vercel-flags-core/src/controller/tagged-data.test.ts b/packages/vercel-flags-core/src/controller/tagged-data.test.ts new file mode 100644 index 000000000..1b14a766d --- /dev/null +++ b/packages/vercel-flags-core/src/controller/tagged-data.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DatafileInput } from '../types'; +import { tagData } from './tagged-data'; + +const NOW = 1_700_000_000_000; + +const datafile: DatafileInput = { + projectId: 'prj_test', + environment: 'production', + definitions: {}, + configUpdatedAt: NOW - 60_000, +}; + +beforeEach(() => { + vi.useFakeTimers({ now: NOW }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('tagData', () => { + it.each([ + 'fetched', + 'poll', + 'stream', + ] as const)('records each %s arrival without mutating the input', (origin) => { + const input = Object.freeze({ ...datafile, fetchedAt: NOW - 5_000 }); + const tagged = tagData(input, origin); + + expect(tagged).not.toBe(input); + expect(tagged).toEqual({ ...datafile, _origin: origin, fetchedAt: NOW }); + expect(tagged).not.toHaveProperty('_lastSeen'); + + vi.setSystemTime(NOW + 1_000); + expect(tagData(input, origin).fetchedAt).toBe(NOW + 1_000); + expect(tagged.fetchedAt).toBe(NOW); + expect(input.fetchedAt).toBe(NOW - 5_000); + expect(tagged.configUpdatedAt).toBe(datafile.configUpdatedAt); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('keeps %s freshness unknown, including after a fetch', (origin) => { + const input = { ...datafile }; + expect(tagData(input, origin).fetchedAt).toBeUndefined(); + tagData(input, 'fetched'); + vi.setSystemTime(NOW + 1_000); + const tagged = tagData(input, origin); + + expect(tagged).not.toBe(input); + expect(tagged).toEqual({ + ...datafile, + _origin: origin, + }); + expect(tagged).not.toHaveProperty('_lastSeen'); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('preserves %s timestamps, including zero, without resetting age', (origin) => { + for (const fetchedAt of [0, NOW - 30_000]) { + const input = Object.freeze({ ...datafile, fetchedAt }); + vi.setSystemTime(NOW + 10_000); + expect(tagData(input, origin)).toEqual({ ...input, _origin: origin }); + expect(input.fetchedAt).toBe(fetchedAt); + } + }); + + it.each([ + undefined, + NaN, + Infinity, + -Infinity, + -1, + '1700000000000', + ])('treats invalid or missing fetchedAt=%s as unknown', (fetchedAt) => { + const input = { ...datafile, fetchedAt } as DatafileInput; + expect(tagData(input, 'provided')).not.toHaveProperty('fetchedAt'); + expect(tagData(input, 'bundled')).not.toHaveProperty('fetchedAt'); + }); +}); diff --git a/packages/vercel-flags-core/src/controller/tagged-data.ts b/packages/vercel-flags-core/src/controller/tagged-data.ts index d86d76abe..f87824c22 100644 --- a/packages/vercel-flags-core/src/controller/tagged-data.ts +++ b/packages/vercel-flags-core/src/controller/tagged-data.ts @@ -15,10 +15,21 @@ export type TaggedData = DatafileInput & { }; /** - * Tags a DatafileInput with its origin. + * Tags a DatafileInput with metadata. */ export function tagData(data: DatafileInput, origin: DataOrigin): TaggedData { - return Object.assign(data, { _origin: origin }) as TaggedData; + const tagged: TaggedData = { ...data, _origin: origin }; + if (origin === 'fetched' || origin === 'poll' || origin === 'stream') { + tagged.fetchedAt = Date.now(); + } else if ( + typeof data.fetchedAt !== 'number' || + !Number.isFinite(data.fetchedAt) || + data.fetchedAt < 0 + ) { + // Legacy data without a valid timestamp has unknown freshness. + delete tagged.fetchedAt; + } + return tagged; } /** diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 6b67468db..d72bd7601 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -35,6 +35,12 @@ export type DatafileInput = Packed.Data & { * Some older responses might return a string instead of a number. Both will be timestamps. */ configUpdatedAt?: number | string; + /** + * When this datafile was successfully fetched, as Unix epoch milliseconds. + * Preserved when bundled, serialized, or supplied to another client. + * Omit when the original fetch time is unknown; loading data does not reset it. + */ + fetchedAt?: number; /** Version number of the data */ revision?: number; }; From f3e6d51692afad49e883869cba6fa4234df2566d Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 09:48:12 +0200 Subject: [PATCH 02/20] feat(flags-core): add header-driven Vercel mode on the shared cache --- .changeset/header-driven-vercel-mode.md | 5 + packages/vercel-flags-core/CLAUDE.md | 24 ++- .../src/controller/datafile-cache.ts | 10 + .../src/controller/header-source.ts | 199 ++++++++++++++++++ .../vercel-flags-core/src/controller/index.ts | 47 ++++- .../src/controller/normalized-options.ts | 31 ++- packages/vercel-flags-core/src/types.ts | 2 +- .../src/utils/usage/flags-config-read.ts | 4 +- 8 files changed, 307 insertions(+), 15 deletions(-) create mode 100644 .changeset/header-driven-vercel-mode.md create mode 100644 packages/vercel-flags-core/src/controller/header-source.ts diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md new file mode 100644 index 000000000..d71c9635c --- /dev/null +++ b/.changeset/header-driven-vercel-mode.md @@ -0,0 +1,5 @@ +--- +"@vercel/flags-core": minor +--- + +Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-versions` or `flags-config-versions` request header instead of streaming or polling. The `vercel` option defaults to `process.env.VERCEL === '1'` and can be explicitly enabled or disabled. Vercel mode loads provided or bundled definitions during initialization, uses cached data when no usable header is available, and fetches on the first read if the cache is empty. Disabling both streaming and polling preserves offline behavior. Reuse fresh definitions, refresh in the background for up to `staleWhileRevalidateMs` (10 seconds by default) since the cached configuration was last successfully fetched or confirmed by a matching request header at the highest observed version, and block for a refresh when that freshness expires or is unknown. Set `staleWhileRevalidateMs` to `0` to disable background stale serving. Refresh failures use the shared `staleIfErrorMs` allowance; set that to `0` to propagate failures immediately. Datafiles expose an optional `fetchedAt` timestamp that survives serialization and reuse. Bundled and provided definitions preserve valid timestamps; definitions without one start with unknown age. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 535805830..fb611a60c 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -18,6 +18,7 @@ src/ ├── controller/ # Controller (state machine) and I/O sources │ ├── index.ts # Controller class │ ├── stream-source.ts # StreamSource (wraps stream-connection) +│ ├── header-source.ts # Request version checks and on-demand refresh │ ├── polling-source.ts # PollingSource (wraps fetch-datafile) │ ├── bundled-source.ts # BundledSource (wraps read-bundled-definitions) │ ├── stream-connection.ts # Low-level NDJSON stream connection @@ -43,7 +44,7 @@ src/ ``` createClient(sdkKey, options) → Controller (state machine, selects data origin and coordinates sources/cache) - → StreamSource / PollingSource / BundledSource (emit raw DatafileInput) + → StreamSource / PollingSource / HeaderSource / BundledSource (raw DatafileInput) → create-raw-client (ID-based indirection for 'use cache' support) → controller-fns (lookup by ID, evaluate, report) → FlagsClient (public API) @@ -88,7 +89,9 @@ type ControllerOptions = { datafile?: Datafile; // Initial datafile for immediate reads stream?: boolean | { initTimeoutMs: number }; // default: true (3000ms) polling?: boolean | { intervalMs: number; initTimeoutMs: number }; // default: true (30s interval, 3s timeout) - staleIfError?: number; // Seconds of fallback after stream/poll failure; default: Infinity + vercel?: boolean; // default: process.env.VERCEL === '1'; replaces stream/poll at runtime + staleWhileRevalidateMs?: number; // header refresh only; default: 10_000 + staleIfError?: number; // Seconds of fallback after update failure; default: Infinity buildStep?: boolean; // Override build step auto-detection metricEnvironment?: string; // Environment attached to ingested evaluation metrics waitUntil?: (promise: Promise) => void; // default: @vercel/functions waitUntil @@ -112,7 +115,20 @@ Behavior differs based on environment: Build-step reads are deduplicated: data is loaded once via a shared promise (`buildDataPromise`) and all concurrent `evaluate()` calls share the result. The entire build counts as a single tracked read event (`buildReadTracked` flag in Controller). -**Runtime** (default, or `buildStep: false`): +**Vercel runtime** (`vercel: true`, default when `VERCEL=1`): +- Load provided or bundled definitions during initialization, then select Vercel mode. +- Do not start stream/poll; the first read fetches if the cache is empty. +- HeaderSource parses the request's project version and owns `highestObserved` and `lastSeen`. +- A matching header confirms freshness only when no newer version has been observed. +- A newer header refreshes in the background within `staleWhileRevalidateMs` of the latest + accepted fetch or matching header; unknown/expired freshness requires a blocking refresh. +- Every returned entry passes through `DatafileCache.read()`. Refresh errors use its + `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. +- Missing/malformed headers use cached data without fetching, subject to stale-if-error. +- `getDatafile()` remains a snapshot read: it enforces the same failure policy but does + not inspect request headers. Disabling both stream and polling selects offline mode. + +**Other runtime** (default outside Vercel, or `vercel: false`): 1. **Stream** - Real-time updates via NDJSON streaming, wait up to `initTimeoutMs` 2. **Polling** - Interval-based HTTP requests, wait up to `initTimeoutMs` 3. **Provided datafile** - Use `options.datafile` if provided @@ -299,7 +315,7 @@ The DatafileCache rejects incoming data (from stream or poll) if its `configUpda `DatafileCache.read()` is the only full-entry read. The cache is configured once with the internal `staleIfErrorMs`, normalized from the public `staleIfError` option in seconds. Evaluations and `getDatafile()` share the same serving -boundary. `hasData` and `revision` expose coordination metadata even after expiry, +boundary. `hasData`, `revision`, and `metadata` expose coordination metadata even after expiry, so retained data is not replaced by fallback and stream reconnects can still send `X-Revision`. `seed()` never clears failure. Accepted source updates or valid version/revision confirmations clear it; repeated errors/disconnects do not renew diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 97752a79a..37b32580b 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -6,6 +6,8 @@ type Confirmation = Pick< 'configUpdatedAt' | 'revision' | 'projectId' | 'environment' >; +export type CacheMetadata = Confirmation & Pick; + /** * Parses a configUpdatedAt value (number or string) into a numeric timestamp. * Returns undefined if the value is missing or cannot be parsed. @@ -35,6 +37,14 @@ export class DatafileCache { return this.data?.revision; } + /** Freshness checks can inspect retained metadata even after serving expires. */ + get metadata(): CacheMetadata | undefined { + if (!this.data) return undefined; + const { projectId, environment, configUpdatedAt, revision, fetchedAt } = + this.data; + return { projectId, environment, configUpdatedAt, revision, fetchedAt }; + } + /** Stores initial or fallback data without confirming recovery from a failure. */ seed(data: TaggedData): void { this.data = data; diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts new file mode 100644 index 000000000..b1be3d5c9 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -0,0 +1,199 @@ +import type { BundledDefinitions, DatafileInput, Metrics } from '../types'; +import { getRequestContext } from '../utils/request-context'; +import type { CacheMetadata, DatafileCache } from './datafile-cache'; +import { fetchDatafile } from './fetch-datafile'; +import type { NormalizedOptions } from './normalized-options'; +import type { TaggedData } from './tagged-data'; +import { TypedEmitter } from './typed-emitter'; + +export type HeaderSourceEvents = { + data: (data: DatafileInput) => void; + error: (error: Error) => void; +}; + +/** + * Manages a lazy pulling of flag data from the flags service using the version header. + */ +export class HeaderSource extends TypedEmitter { + private options: NormalizedOptions; + private abortController: AbortController | undefined; + private promise: Promise | undefined; + private highestObserved = 0; + private lastSeen: { version: number; at: number } | undefined; + + constructor(options: NormalizedOptions) { + super(); + + this.options = options; + } + + private fetchDatafile(): Promise { + // Share only the transport work, not request-specific freshness decisions. + if (this.promise) return this.promise; + + const abortController = new AbortController(); + this.abortController = abortController; + this.promise = fetchDatafile({ + ...this.options, + signal: abortController.signal, + }) + .then((data) => { + // A transport may finish after stop() even if it ignores cancellation. + abortController.signal.throwIfAborted(); + this.emit('data', data); + return data; + }) + .catch((error) => { + if (!abortController.signal.aborted) this.emit('error', error); + throw error; + }) + .finally(() => { + // An older, aborted fetch must not clear a newer request's work. + if (this.abortController === abortController) { + this.promise = undefined; + this.abortController = undefined; + } + }); + + return this.promise; + } + + private getUpdatedAtHeader(projectId: string, header: string | undefined) { + if (!header) { + return; + } + + const prefix = `flags_${projectId}=`; + const value = header + .split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)) + ?.slice(prefix.length); + const timestamp = Number(value); + + return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; + } + + private async refresh( + cache: DatafileCache, + ): Promise<[TaggedData, Metrics['cacheStatus']]> { + const pending = this.fetchDatafile(); + const signal = this.abortController!.signal; + try { + await pending; + signal.throwIfAborted(); + } catch (error) { + if (signal.aborted) throw error; + // The controller recorded the failure; the cache decides whether to serve it. + const stale = cache.read(); + if (!stale) throw error; + return [stale, 'STALE']; + } + return [cache.read()!, 'MISS']; + } + + private revalidate(): void { + const pending = this.fetchDatafile(); + const signal = this.abortController?.signal; + const background = pending.catch((error) => { + if (!signal?.aborted) { + console.error('@vercel/flags-core: Header refresh failed:', error); + } + }); + + try { + this.options.waitUntil(background); + } catch { + // Registration is best-effort; the handled refresh continues regardless. + } + } + + private async resolveData( + cache: DatafileCache, + current: CacheMetadata, + updatedAtHeader: number | undefined, + ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { + if (!current.configUpdatedAt || !updatedAtHeader) return; + + const currentUpdatedAt = Number(current.configUpdatedAt); + if (updatedAtHeader <= currentUpdatedAt) { + return [cache.read()!, 'HIT']; + } + + const freshAt = Math.max( + current.fetchedAt ?? -Infinity, + this.lastSeen?.version === currentUpdatedAt + ? this.lastSeen.at + : -Infinity, + ); + const { staleWhileRevalidateMs } = this.options; + if ( + staleWhileRevalidateMs > 0 && + Date.now() - freshAt <= staleWhileRevalidateMs + ) { + let stale: TaggedData | undefined; + try { + stale = cache.read(); + } catch { + // Expired stale-if-error requires a blocking recovery attempt below. + } + if (stale) { + this.revalidate(); + return [stale, 'STALE']; + } + } + + return this.refresh(cache); + } + + private observe(version: number, currentVersion: number): boolean { + this.highestObserved = Math.max(this.highestObserved, version); + // Once invalidated, an older matching header cannot renew freshness. + if (version !== currentVersion || version !== this.highestObserved) + return false; + this.lastSeen = { version, at: Date.now() }; + return true; + } + + async read( + cache: DatafileCache, + ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { + // Capture the request header before a cold fetch discovers its project. + const { headers } = getRequestContext(); + const header = + headers?.['x-vercel-flags-config-versions'] ?? + headers?.['flags-config-versions']; + const current = cache.metadata; + const fetched = current ? undefined : await this.refresh(cache); + const metadata = current ?? cache.metadata!; + const updatedAtHeader = this.getUpdatedAtHeader(metadata.projectId, header); + if ( + updatedAtHeader && + this.observe(updatedAtHeader, Number(metadata.configUpdatedAt)) + ) { + cache.tryConfirm(metadata); + } + + if (fetched) return fetched; + return this.resolveData(cache, metadata, updatedAtHeader); + } + + isAvailable(): boolean { + // Explicit offline mode disables header-driven refreshes too. + return ( + this.options.vercel && + (this.options.stream.enabled || this.options.polling.enabled) + ); + } + + /** + * Abort the current header-driven fetch and discard its pending work. + */ + stop(): void { + this.abortController?.abort(); + this.abortController = undefined; + this.promise = undefined; + this.lastSeen = undefined; + this.highestObserved = 0; + } +} diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 6a48ca6d4..20faf4071 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -12,6 +12,7 @@ import { UsageTracker } from '../utils/usage-tracker'; import { BundledSource } from './bundled-source'; import { DatafileCache } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; +import { HeaderSource } from './header-source'; import { type ControllerOptions, type NormalizedOptions, @@ -41,6 +42,7 @@ type State = | 'initializing:fallback' | 'streaming' | 'polling' + | 'vercel' | 'degraded' | 'build:loading' | 'build:ready' @@ -69,6 +71,11 @@ type State = * - Uses polling exclusively * - Same fallback chains as streaming mode * + * **Runtime — Vercel mode** (vercel enabled, with stream or polling enabled): + * - Loads provided/bundled data before selecting the mode; no startup network + * - HeaderSource checks request versions and refreshes when needed + * - Cache applies version acceptance and stale-if-error to all served data + * * **Runtime — offline mode** (neither stream nor polling): * - Init fallback: constructor datafile → bundled → one-time fetch → throw * - Read fallback: in-memory value → constructor datafile → bundled → one-time fetch → throw @@ -92,6 +99,7 @@ export class Controller implements ControllerInterface { private streamSource: StreamSource; private pollingSource: PollingSource; private bundledSource: BundledSource; + private headerSource: HeaderSource; // Usage tracking private usageTracker: UsageTracker; @@ -115,6 +123,7 @@ export class Controller implements ControllerInterface { ); this.pollingSource = new PollingSource(this.options); + this.headerSource = new HeaderSource(this.options); this.bundledSource = new BundledSource({ auth: this.options.auth, @@ -155,12 +164,15 @@ export class Controller implements ControllerInterface { this.transition('degraded'); } }; - private onStreamError = (error: Error) => { + private onSourceError = (error: Error) => { this.cache.fail(error); }; private onPollData = (data: DatafileInput) => { this.cache.updateFromSource(data, 'poll'); }; + private onHeaderData = (data: DatafileInput) => { + this.cache.updateFromSource(data, 'fetched'); + }; private onPollError = (error: Error) => { this.cache.fail(error); console.error('@vercel/flags-core: Poll failed:', error); @@ -175,9 +187,11 @@ export class Controller implements ControllerInterface { this.streamSource.on('primed', this.onStreamPrimed); this.streamSource.on('connected', this.onStreamConnected); this.streamSource.on('disconnected', this.onStreamDisconnected); - this.streamSource.on('error', this.onStreamError); + this.streamSource.on('error', this.onSourceError); this.pollingSource.on('data', this.onPollData); this.pollingSource.on('error', this.onPollError); + this.headerSource.on('data', this.onHeaderData); + this.headerSource.on('error', this.onSourceError); } private unwireSourceEvents(): void { @@ -185,9 +199,11 @@ export class Controller implements ControllerInterface { this.streamSource.off('primed', this.onStreamPrimed); this.streamSource.off('connected', this.onStreamConnected); this.streamSource.off('disconnected', this.onStreamDisconnected); - this.streamSource.off('error', this.onStreamError); + this.streamSource.off('error', this.onSourceError); this.pollingSource.off('data', this.onPollData); this.pollingSource.off('error', this.onPollError); + this.headerSource.off('data', this.onHeaderData); + this.headerSource.off('error', this.onSourceError); } // --------------------------------------------------------------------------- @@ -209,6 +225,8 @@ export class Controller implements ControllerInterface { return 'streaming'; case 'polling': return 'polling'; + case 'vercel': + return 'vercel'; default: return 'offline'; } @@ -224,6 +242,7 @@ export class Controller implements ControllerInterface { * Build step: datafile → bundled → one-time fetch * Streaming mode: stream → datafile → bundled * Polling mode (no stream): poll → datafile → bundled + * Vercel mode: datafile → bundled; fetch only on a read * Offline mode (neither): datafile → bundled → one-time fetch */ async initialize(): Promise { @@ -243,14 +262,22 @@ export class Controller implements ControllerInterface { // send the revision to the stream and potentially get a lightweight // "primed" response instead of a full datafile. if (!this.cache.hasData) { + this.transition('initializing:fallback'); + let bundled: DatafileInput | undefined; try { - const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - this.cache.seed(tagData(bundled, 'bundled')); - } + bundled = await this.bundledSource.tryLoad(); } catch { // Bundled definitions not available — proceed without revision } + if (this.state === 'shutdown') { + throw new Error('@vercel/flags-core: Client is shut down'); + } + if (bundled) this.cache.seed(tagData(bundled, 'bundled')); + } + + if (this.headerSource.isAvailable()) { + this.transition('vercel'); + return; } // If we already have data (from provided datafile or bundled definitions), @@ -332,6 +359,7 @@ export class Controller implements ControllerInterface { this.unwireSourceEvents(); this.streamSource.stop(); this.pollingSource.stop(); + this.headerSource.stop(); this.cache.clear(); if (this.options.datafile) { this.cache.seed(tagData(this.options.datafile, 'provided')); @@ -424,6 +452,11 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } + if (this.state === 'vercel') { + const result = await this.headerSource.read(this.cache); + if (result) return result; + } + const data = this.cache.read(); if (data) { const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 78a77a002..288bc50f8 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -12,6 +12,7 @@ const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; const DEFAULT_POLLING_INTERVAL_MS = 30_000; const MIN_POLLING_INTERVAL_MS = 30_000; const DEFAULT_POLLING_INIT_TIMEOUT_MS = 3_000; +const DEFAULT_STALE_WHILE_REVALIDATE_MS = 10_000; /** * Configuration options for Controller @@ -45,9 +46,25 @@ export type ControllerOptions = { */ polling?: boolean | PollingOptions; + /** + * Use request version headers instead of streaming or polling at runtime. + * Initialization starts no network activity; reads fetch only when needed. + * Disabling both stream and polling still selects offline mode. + * @default process.env.VERCEL === '1' + */ + vercel?: boolean; + + /** + * How long header-driven reads may serve cached data while refreshing in the + * background, measured from its last fetch or matching version header. + * Must be a finite, non-negative number. Set to 0 to always block on refresh. + * @default 10000 + */ + staleWhileRevalidateMs?: number; + /** * How long runtime reads may use cached data after the first consecutive - * stream/poll failure or stream disconnect. Accepts nonnegative seconds or Infinity. + * stream/poll/header failure or stream disconnect. Accepts nonnegative seconds or Infinity. * Fractional seconds are supported. * Zero disables fallback immediately; positive windows include the deadline. * Accepted updates, matching versions, or matching stream primed revisions @@ -103,6 +120,8 @@ export type NormalizedOptions = { datafile: DatafileInput | undefined; stream: { enabled: boolean; initTimeoutMs: number }; polling: { enabled: boolean; intervalMs: number; initTimeoutMs: number }; + vercel: boolean; + staleWhileRevalidateMs: number; staleIfErrorMs: number; buildStep: boolean; fetch: typeof globalThis.fetch; @@ -159,11 +178,21 @@ export function normalizeOptions( }; } + const staleWhileRevalidateMs = + options.staleWhileRevalidateMs ?? DEFAULT_STALE_WHILE_REVALIDATE_MS; + if (!Number.isFinite(staleWhileRevalidateMs) || staleWhileRevalidateMs < 0) { + throw new Error( + '@vercel/flags-core: staleWhileRevalidateMs must be a finite, non-negative number.', + ); + } + return { auth: options.auth, datafile: options.datafile, stream, polling, + vercel: options.vercel ?? process.env.VERCEL === '1', + staleWhileRevalidateMs, staleIfErrorMs: staleIfError * 1000, buildStep, fetch: options.fetch ?? globalThis.fetch, diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index d72bd7601..5e1b6eb52 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -79,7 +79,7 @@ export type Metrics = { /** Whether the stream is currently connected */ connectionState: 'connected' | 'disconnected'; /** The current operating mode of the client */ - mode: 'streaming' | 'polling' | 'build' | 'offline'; + mode: 'streaming' | 'polling' | 'build' | 'vercel' | 'offline'; /** Time in ms for the pure flag evaluation logic (only present on EvaluationResult) */ evaluationMs?: number; }; diff --git a/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts index c96573431..9b9930f6d 100644 --- a/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts +++ b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts @@ -16,7 +16,7 @@ export interface TrackReadOptions { /** Timestamp when the config was last updated */ configUpdatedAt?: number; /** The mode the SDK is operating in */ - mode?: 'poll' | 'stream' | 'build' | 'offline'; + mode?: 'poll' | 'stream' | 'build' | 'vercel' | 'offline'; /** Revision of the config */ revision?: number; } @@ -36,7 +36,7 @@ export class FlagsConfigReadEvent implements UsageEvent { duration?: number; configUpdatedAt?: number; configOrigin?: 'in-memory' | 'embedded' | 'poll' | 'stream' | 'constructor'; - mode?: 'poll' | 'stream' | 'build' | 'offline'; + mode?: TrackReadOptions['mode']; revision?: string; environment?: string; }; From dda9c53af8af48185876724f238d7d55d63a2995 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 09:48:35 +0200 Subject: [PATCH 03/20] test(flags-core): cover Vercel mode selection and refreshes --- .../vercel-flags-core/src/black-box.test.ts | 82 ++- .../src/vercel-mode.black-box.test.ts | 635 ++++++++++++++++++ 2 files changed, 704 insertions(+), 13 deletions(-) create mode 100644 packages/vercel-flags-core/src/vercel-mode.black-box.test.ts diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 7b24e2340..5e274d99a 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -174,6 +174,61 @@ describe('Controller (black-box)', () => { delete process.env.NEXT_PHASE; }); + it.each([ + ['poll', 3, false, 3], + ['poll', 2, true, 2], + ['poll', 1, true, 2], + ['stream', 3, false, 3], + ['stream', 2, true, 2], + ['stream', 1, true, 2], + ] as const)('applies the version guard to %s version %i', async (source, configUpdatedAt, expectedValue, expectedVersion) => { + const stream = createMockStream(); + const incoming = makeBundled({ + configUpdatedAt, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + const dataFetch = vi.fn(async () => Response.json(incoming)); + fetchMock.mockImplementation((input, init) => { + const url = String(input); + if (url.endsWith('/v1/stream')) return stream.response; + if (url.endsWith('/v1/datafile')) return dataFetch(input, init); + if (url.endsWith('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + const client = createClient(sdkKey, { + datafile: makeBundled({ configUpdatedAt: 2 }), + fetch: fetchMock, + buildStep: false, + stream: source === 'stream', + polling: source === 'poll', + }); + const cleanupContext = setRequestContext({}); + try { + const initial = client.evaluate('flagA'); + if (source === 'stream') { + stream.push({ type: 'datafile', data: incoming }); + } + expect((await initial).value).toBe(expectedValue); + expect((await client.getDatafile()).configUpdatedAt).toBe( + expectedVersion, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(dataFetch).toHaveBeenCalledTimes(source === 'poll' ? 1 : 0); + } finally { + cleanupContext(); + try { + await client.shutdown(); + } finally { + stream.close(); + } + } + }); + afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); @@ -2192,10 +2247,11 @@ describe('Controller (black-box)', () => { streams[1]!.push({ type: 'datafile', data: olderData }); await vi.advanceTimersByTimeAsync(0); - // Should still have newer data (configUpdatedAt guard rejected older) + // The version guard rejects the older response after reconnection. const result2 = await client.evaluate('flagA'); expect(result2.value).toBe(true); // still variant 1 expect(result2.metrics?.connectionState).toBe('connected'); + expect((await client.getDatafile()).configUpdatedAt).toBe(2000); await client.shutdown(); }); @@ -2613,9 +2669,10 @@ describe('Controller (black-box)', () => { stream.push({ type: 'datafile', data: olderDatafile }); await vi.advanceTimersByTimeAsync(50); - // Should still have newer data (older message was rejected) + // Keep the newer data; the older message was rejected. const result = await client.evaluate('flagA', undefined, undefined); expect(result.value).toBe(true); // variant 1 = newer + expect((await client.getDatafile()).configUpdatedAt).toBe(2000); stream.close(); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -2670,8 +2727,6 @@ describe('Controller (black-box)', () => { }); it('should skip stream data with equal configUpdatedAt', async () => { - vi.useRealTimers(); - const data1 = makeBundled({ configUpdatedAt: 1000, definitions: { @@ -2709,15 +2764,17 @@ describe('Controller (black-box)', () => { const initPromise = client.initialize(); stream.push({ type: 'datafile', data: data1 }); - await new Promise((r) => setTimeout(r, 10)); + await vi.advanceTimersByTimeAsync(0); await initPromise; + expect((await client.evaluate('flagA')).value).toBe(false); stream.push({ type: 'datafile', data: data2 }); - await new Promise((r) => setTimeout(r, 50)); + await vi.advanceTimersByTimeAsync(0); - // Should have kept first data (equal configUpdatedAt is not newer) + // Keep the first data; equal configUpdatedAt is not newer. const result = await client.evaluate('flagA'); expect(result.value).toBe(false); // variant 0 = data1 + expect((await client.getDatafile()).configUpdatedAt).toBe(1000); stream.close(); await client.shutdown(); @@ -2776,9 +2833,7 @@ describe('Controller (black-box)', () => { await client.shutdown(); }); - it('should handle configUpdatedAt as string', async () => { - vi.useRealTimers(); - + it('should reject older stream responses with string configUpdatedAt', async () => { const newerDatafile = { ...makeBundled({ definitions: { @@ -2820,15 +2875,16 @@ describe('Controller (black-box)', () => { const initPromise = client.initialize(); stream.push({ type: 'datafile', data: newerDatafile }); - await new Promise((r) => setTimeout(r, 10)); + await vi.advanceTimersByTimeAsync(0); await initPromise; + expect((await client.evaluate('flagA')).value).toBe(true); stream.push({ type: 'datafile', data: olderDatafile }); - await new Promise((r) => setTimeout(r, 50)); + await vi.advanceTimersByTimeAsync(0); - // Should still have newer data const result = await client.evaluate('flagA'); expect(result.value).toBe(true); // variant 1 = newer + expect((await client.getDatafile()).configUpdatedAt).toBe('2000'); stream.close(); await client.shutdown(); diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts new file mode 100644 index 000000000..1751d279b --- /dev/null +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -0,0 +1,635 @@ +/** Public-API coverage: real client, controller, header source and fetch helper. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type BundledDefinitions, + createClient, + type FlagsClient, +} from './index.default'; +import { setRequestContext } from './test-utils'; +import { readBundledDefinitions } from './utils/read-bundled-definitions'; + +// Only the filesystem boundary is replaced; no controller/source is mocked. +vi.mock('./utils/read-bundled-definitions', () => ({ + readBundledDefinitions: vi.fn(), +})); + +const TIMESTAMP = 1_700_000_000_000; +const PROJECT_ID = 'prj_header_test'; +const HEADER = 'x-vercel-flags-config-versions'; +const SDK_KEY = 'vf_server_header_test'; + +function datafile(timestamp = TIMESTAMP, enabled = false): BundledDefinitions { + return { + definitions: { + feature: { + environments: { production: enabled ? 1 : 0 }, + variants: [false, true], + }, + }, + segments: {}, + projectId: PROJECT_ID, + environment: 'production', + configUpdatedAt: timestamp, + digest: `digest-${timestamp}`, + revision: timestamp - TIMESTAMP + 1, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const clients = new Set(); +const dataFetch = vi.fn(); +const transport = vi.fn(); +let cleanupContext = () => {}; + +function mockDatafileResponse(timestamp: number, enabled = false) { + dataFetch.mockResolvedValueOnce(Response.json(datafile(timestamp, enabled))); +} + +function setVersion(timestamp?: number | string) { + cleanupContext(); + cleanupContext = setRequestContext( + timestamp === undefined + ? {} + : { [HEADER]: `flags_other=1;flags_${PROJECT_ID}=${timestamp}` }, + ); +} + +function client(options: Parameters[1] = {}) { + const instance = createClient(SDK_KEY, { + datafile: datafile(), + buildStep: false, + fetch: transport, + ...options, + }); + clients.add(instance); + return instance; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(TIMESTAMP); + vi.stubEnv('VERCEL_ENV', 'production'); + vi.stubEnv('VERCEL', '1'); + vi.mocked(readBundledDefinitions).mockReset(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: null, + state: 'missing-file', + }); + dataFetch.mockReset(); + dataFetch.mockRejectedValue(new Error('Unexpected datafile fetch')); + transport.mockReset(); + transport.mockImplementation((input, init) => { + const url = String(input); + if (url === 'https://flags.vercel.com/v1/datafile') { + return dataFetch(input, init); + } + if (url === 'https://flags.vercel.com/v1/ingest') { + return Promise.resolve(new Response()); + } + return Promise.reject(new Error(`Unexpected request: ${url}`)); + }); + setVersion(TIMESTAMP); +}); + +afterEach(async () => { + try { + await Promise.all([...clients].map((instance) => instance.shutdown())); + } finally { + clients.clear(); + cleanupContext(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + } +}); + +describe('Vercel mode (black-box)', () => { + it.each([ + [undefined, undefined, 'polling'], + ['0', undefined, 'polling'], + ['true', undefined, 'polling'], + ['1', undefined, 'vercel'], + ['1', false, 'polling'], + [undefined, true, 'vercel'], + ] as const)('uses %s with vercel=%s to select %s mode', async (env, vercel, mode) => { + vi.stubEnv('VERCEL', env); + mockDatafileResponse(TIMESTAMP, true); + const instance = client({ vercel, stream: false, datafile: undefined }); + await instance.initialize(); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('checks the bundle during initialization without request context, then shares the first read fetch', async () => { + setVersion(undefined); + const instance = client({ datafile: undefined }); + await instance.initialize(); + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + expect(transport).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const reads = [instance.evaluate('feature'), instance.evaluate('feature')]; + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + for (const result of await Promise.all(reads)) { + expect(result).toMatchObject({ + value: true, + metrics: { mode: 'vercel', source: 'remote', cacheStatus: 'MISS' }, + }); + } + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + await vi.advanceTimersByTimeAsync(60_000); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect( + transport.mock.calls.some(([url]) => String(url).includes('/stream')), + ).toBe(false); + }); + + it.each([ + 'provided', + 'bundled', + 'empty', + ] as const)('uses the %s cache without a header, fetching only when empty', async (cache) => { + setVersion(undefined); + if (cache === 'bundled') { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: datafile(), + state: 'ok', + }); + } + mockDatafileResponse(TIMESTAMP, true); + const instance = client({ + datafile: cache === 'provided' ? datafile() : undefined, + }); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: cache === 'empty', + metrics: { + mode: 'vercel', + cacheStatus: cache === 'empty' ? 'MISS' : 'STALE', + }, + }); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(dataFetch).toHaveBeenCalledTimes(cache === 'empty' ? 1 : 0); + }); + + it('recovers on a later read when the cold-cache fetch fails', async () => { + const instance = client({ datafile: undefined }); + dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); + await expect(instance.evaluate('feature')).rejects.toThrow( + 'Failed to fetch data', + ); + mockDatafileResponse(TIMESTAMP, true); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'vercel', cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('does not finish initialization after shutdown while bundled data is loading', async () => { + const pending = + deferred>>(); + vi.mocked(readBundledDefinitions).mockReturnValueOnce(pending.promise); + const instance = client({ datafile: undefined }); + const initializing = instance.initialize(); + const outcome = expect(initializing).rejects.toThrow('Client is shut down'); + await vi.advanceTimersByTimeAsync(0); + await instance.shutdown(); + clients.delete(instance); + pending.resolve({ definitions: datafile(), state: 'ok' }); + + await outcome; + expect(transport).not.toHaveBeenCalled(); + }); + + it.each([ + HEADER, + 'flags-config-versions', + ])('initializes and refreshes using %s', async (headerName) => { + cleanupContext(); + cleanupContext = setRequestContext({ + [headerName]: `flags_${PROJECT_ID}=${TIMESTAMP}`, + }); + const instance = client(); + + const initial = await instance.evaluate('feature'); + expect(initial.value).toBe(false); + expect(initial.metrics).toMatchObject({ + mode: 'vercel', + cacheStatus: 'HIT', + }); + expect(dataFetch).not.toHaveBeenCalled(); + + cleanupContext(); + cleanupContext = setRequestContext({ + [headerName]: `flags_${PROJECT_ID}=${TIMESTAMP + 20_000}`, + }); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 20_000, true)), + ); + + vi.setSystemTime(TIMESTAMP + 10_001); + const refreshed = await instance.evaluate('feature'); + expect(refreshed.value).toBe(true); + expect(refreshed.metrics).toMatchObject({ + mode: 'vercel', + source: 'remote', + cacheStatus: 'MISS', + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('uses fresh %s definitions without opening a stream or polling', async (origin) => { + setVersion(undefined); + const bundled = datafile(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: bundled, + state: 'ok', + }); + const instance = client({ + datafile: origin === 'provided' ? bundled : undefined, + }); + await instance.initialize(); + expect(readBundledDefinitions).toHaveBeenCalledTimes( + origin === 'bundled' ? 1 : 0, + ); + expect(transport).not.toHaveBeenCalled(); + expect(await instance.getDatafile()).toEqual({ + ...bundled, + metrics: expect.objectContaining({ + mode: 'vercel', + source: origin === 'provided' ? 'in-memory' : 'embedded', + cacheStatus: 'STALE', + }), + }); + setVersion(TIMESTAMP); + + const result = await instance.evaluate('feature'); + + expect(result.value).toBe(false); + expect(result.metrics).toMatchObject({ + mode: 'vercel', + source: origin === 'provided' ? 'in-memory' : 'embedded', + cacheStatus: 'HIT', + connectionState: 'disconnected', + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(dataFetch).not.toHaveBeenCalled(); + expect( + transport.mock.calls.every(([url]) => String(url).endsWith('/v1/ingest')), + ).toBe(true); + }); + + it.each([ + undefined, + 'flags_other=1700000000000', + `flags_${PROJECT_ID}=invalid`, + ])('keeps the configured offline fallback when the matching header is unavailable: %s', async (header) => { + cleanupContext(); + cleanupContext = setRequestContext(header ? { [HEADER]: header } : {}); + const instance = client({ stream: false, polling: false }); + + const result = await instance.evaluate('feature'); + + expect(result.value).toBe(false); + expect(result.metrics).toMatchObject({ + mode: 'offline', + cacheStatus: 'STALE', + }); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it.each([ + [true, false, 'vercel'], + [false, true, 'vercel'], + [false, false, 'offline'], + ] as const)('version headers with stream=%s and polling=%s use %s mode', async (stream, polling, mode) => { + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + const instance = client({ stream, polling }); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: mode === 'vercel', + metrics: { mode }, + }); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'vercel' ? 1 : 0); + }); + + it('does not enable runtime header refresh during a build', async () => { + setVersion(TIMESTAMP + 20_000); + const instance = client({ buildStep: true }); + + const result = await instance.evaluate('feature'); + + expect(result.value).toBe(false); + expect(result.metrics?.mode).toBe('build'); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it.each([ + 1, 10_000, + ])('serves stale data immediately at delta %i ms, then exposes the background update', async (delta) => { + setVersion(TIMESTAMP + delta); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + setVersion(TIMESTAMP); + await instance.evaluate('feature'); + setVersion(TIMESTAMP + delta); + + const first = await instance.evaluate('feature'); + expect(first.value).toBe(false); + expect(first.metrics).toMatchObject({ + mode: 'vercel', + cacheStatus: 'STALE', + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect((await instance.evaluate('feature')).value).toBe(false); + expect(dataFetch).toHaveBeenCalledTimes(1); + + pending.resolve(Response.json(datafile(TIMESTAMP + delta, true))); + await vi.advanceTimersByTimeAsync(0); + const second = await instance.evaluate('feature'); + + expect(second.value).toBe(true); + expect(second.metrics).toMatchObject({ + source: 'remote', + cacheStatus: 'HIT', + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe( + TIMESTAMP + delta, + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('blocks for unknown freshness and shares one fetch across evaluate and bulkEvaluate', async () => { + setVersion(TIMESTAMP + 10_001); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + const settled = vi.fn(); + const single = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + const bulk = instance.bulkEvaluate([ + { key: 'feature', defaultValue: false }, + ]); + await vi.advanceTimersByTimeAsync(0); + + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + headers: expect.objectContaining({ + Authorization: `Bearer ${SDK_KEY}`, + 'X-Vercel-Env': 'production', + }), + signal: expect.any(AbortSignal), + }, + ); + pending.resolve(Response.json(datafile(TIMESTAMP + 10_001, true))); + const [result, results] = await Promise.all([single, bulk]); + + for (const evaluation of [result, results.feature]) { + expect(evaluation?.value).toBe(true); + expect(evaluation?.metrics).toMatchObject({ + mode: 'vercel', + source: 'remote', + cacheStatus: 'MISS', + }); + } + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('rechecks new request versions instead of caching the first HIT forever', async () => { + const instance = client(); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + + setVersion(TIMESTAMP + 20_000); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 20_000, true)), + ); + vi.setSystemTime(TIMESTAMP + 10_001); + const second = await instance.evaluate('feature'); + expect(second.value).toBe(true); + expect(second.metrics?.cacheStatus).toBe('MISS'); + + setVersion(TIMESTAMP + 40_000); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 40_000, false)), + ); + vi.setSystemTime(TIMESTAMP + 20_002); + const third = await instance.evaluate('feature'); + expect(third.value).toBe(false); + expect(third.metrics?.cacheStatus).toBe('MISS'); + expect(dataFetch).toHaveBeenCalledTimes(2); + + setVersion(); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('does not make a fresh request wait on another requests blocking refresh', async () => { + const instance = client(); + await instance.initialize(); + setVersion(TIMESTAMP + 20_000); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const blocking = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + + setVersion(TIMESTAMP); + const hitSettled = vi.fn(); + const hit = instance.evaluate('feature').then((result) => { + hitSettled(result); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + // Settle the transport even when the independence assertion fails. + const completedBeforeFetch = hitSettled.mock.calls.length; + pending.resolve(Response.json(datafile(TIMESTAMP + 20_000, true))); + const [blockingResult, hitResult] = await Promise.all([blocking, hit]); + + expect(completedBeforeFetch).toBe(1); + expect(hitResult.value).toBe(false); + expect(hitResult.metrics?.cacheStatus).toBe('HIT'); + expect(blockingResult.value).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('retries a failed blocking refresh instead of poisoning subsequent evaluations', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setVersion(TIMESTAMP + 20_000); + dataFetch.mockResolvedValueOnce( + new Response(null, { status: 503, statusText: 'Service Unavailable' }), + ); + const instance = client({ staleIfErrorMs: 0 }); + + const failed = await instance.evaluate('feature', false); + expect(failed.value).toBe(false); + expect(failed.reason).toBe('error'); + expect(failed.errorMessage).toContain('Service Unavailable'); + expect(errorSpy).not.toHaveBeenCalled(); + + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 20_000, true)), + ); + const recovered = await instance.evaluate('feature'); + expect(recovered.value).toBe(true); + expect(recovered.metrics?.cacheStatus).toBe('MISS'); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('contains background fetch errors and retries without losing cached data', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + setVersion(TIMESTAMP); + await instance.evaluate('feature'); + setVersion(TIMESTAMP + 1); + + expect((await instance.evaluate('feature')).value).toBe(false); + + const failure = new Error('Network unavailable'); + pending.reject(failure); + await vi.advanceTimersByTimeAsync(0); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Header refresh failed:', + failure, + ); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 1, true)), + ); + expect((await instance.evaluate('feature')).value).toBe(false); + await vi.advanceTimersByTimeAsync(0); + + expect((await instance.evaluate('feature')).value).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('aborts an in-flight header refresh on shutdown', async () => { + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + setVersion(TIMESTAMP); + await instance.evaluate('feature'); + setVersion(TIMESTAMP + 1); + + await instance.evaluate('feature'); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + + await instance.shutdown(); + clients.delete(instance); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await vi.advanceTimersByTimeAsync(0); + + expect(signal?.aborted).toBe(true); + }); + + it.each([ + undefined, + 100, + 20_000, + ])('honors staleWhileRevalidateMs=%s at the boundary and on expiry', async (staleWhileRevalidateMs) => { + const waitUntil = vi.fn(); + const instance = client({ staleWhileRevalidateMs, waitUntil }); + await instance.evaluate('feature'); + waitUntil.mockClear(); + + const windowMs = staleWhileRevalidateMs ?? 10_000; + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + setVersion(TIMESTAMP + 100_000); + vi.setSystemTime(TIMESTAMP + windowMs); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + const lifetime = waitUntil.mock.calls[0]?.[0] as Promise; + const lifetimeSettled = vi.fn(); + void lifetime.then(lifetimeSettled); + + vi.setSystemTime(TIMESTAMP + windowMs + 1); + const settled = vi.fn(); + const blocking = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(lifetimeSettled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 100_000, true))); + expect((await blocking).metrics?.cacheStatus).toBe('MISS'); + await lifetime; + expect(lifetimeSettled).toHaveBeenCalledTimes(1); + }); + + it('disables stale serving with a zero window, even immediately after a HIT', async () => { + const instance = client({ staleWhileRevalidateMs: 0 }); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + expect(dataFetch).not.toHaveBeenCalled(); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + -1, + NaN, + Infinity, + -Infinity, + ])('rejects invalid staleWhileRevalidateMs=%s', (staleWhileRevalidateMs) => { + expect(() => client({ staleWhileRevalidateMs })).toThrow( + 'staleWhileRevalidateMs must be a finite, non-negative number', + ); + }); + +}); From 6b488bd1d657db7633f436e7dfac23f629ff2809 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 09:48:45 +0200 Subject: [PATCH 04/20] test(flags-core): cover persisted freshness and header error recovery --- .../src/vercel-mode.black-box.test.ts | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 1751d279b..2de618d23 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -632,4 +632,532 @@ describe('Vercel mode (black-box)', () => { ); }); + it.each([ + 'provided', + 'bundled', + ] as const)('blocks on the first invalidation of unknown-age %s data', async (origin) => { + const input = datafile(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: input, + state: 'ok', + }); + const instance = client({ + datafile: origin === 'provided' ? input : undefined, + }); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(await instance.getDatafile()).toEqual({ + ...datafile(TIMESTAMP + 1, true), + fetchedAt: TIMESTAMP, + metrics: expect.any(Object), + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('uses persisted %s fetchedAt until its original freshness expires', async (origin) => { + const input = Object.freeze({ + ...datafile(TIMESTAMP - 365 * 24 * 60 * 60 * 1_000), + fetchedAt: TIMESTAMP - 9_000, + }); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: input, + state: 'ok', + }); + const instance = client({ + datafile: origin === 'provided' ? input : undefined, + }); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + + // A recent fetch, not the config's age or a matching header, permits SWR. + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + vi.setSystemTime(TIMESTAMP + 1_000); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect((await instance.getDatafile()).fetchedAt).toBe(TIMESTAMP - 9_000); + + vi.setSystemTime(TIMESTAMP + 1_001); + const settled = vi.fn(); + const blocking = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP, true))); + expect(await blocking).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(await instance.getDatafile()).toEqual({ + ...datafile(TIMESTAMP, true), + fetchedAt: TIMESTAMP + 1_001, + metrics: expect.any(Object), + }); + expect(input.fetchedAt).toBe(TIMESTAMP - 9_000); + expect(input).not.toHaveProperty('_origin'); + }); + + it('preserves serialized fetchedAt in another client without making old data fresh', async () => { + const first = client({ + datafile: undefined, + stream: false, + polling: false, + }); + mockDatafileResponse(TIMESTAMP, true); + const fetched = await first.getDatafile(); + expect(fetched.fetchedAt).toBe(TIMESTAMP); + expect(fetched).not.toHaveProperty('_fetchedAt'); + expect(fetched).not.toHaveProperty('_origin'); + const later = TIMESTAMP + 365 * 24 * 60 * 60 * 1_000; + vi.setSystemTime(later); + const second = client({ datafile: JSON.parse(JSON.stringify(fetched)) }); + expect((await second.getDatafile()).fetchedAt).toBe(TIMESTAMP); + + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const settled = vi.fn(); + const blocking = second.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, false))); + expect(await blocking).toMatchObject({ + value: false, + metrics: { cacheStatus: 'MISS' }, + }); + expect((await second.getDatafile()).fetchedAt).toBe(later); + expect(fetched.fetchedAt).toBe(TIMESTAMP); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['older', TIMESTAMP - 1], + ['missing', undefined], + ['malformed', 'invalid'], + ['newer', TIMESTAMP + 1], + ] as const)('does not renew freshness for %s headers', async (_kind, version) => { + const instance = client(); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 9_000); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + setVersion(version); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); + const settled = vi.fn(); + const blocking = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + expect((await blocking).metrics?.cacheStatus).toBe('MISS'); + }); + + it('does not renew freshness from an older matching header after observing an invalidation', async () => { + const instance = client(); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + + // An overlapping request still has the old header, but cannot undo invalidation. + setVersion(TIMESTAMP); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); + const settled = vi.fn(); + const blocking = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + expect(await blocking).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + }); + + it('uses the later of the matching-header and fetched timestamps', async () => { + const instance = client(); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'MISS', + ); + vi.setSystemTime(TIMESTAMP + 9_000); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + + // The matching header extends freshness beyond the original fetch time. + vi.setSystemTime(TIMESTAMP + 19_000); + setVersion(TIMESTAMP + 100_000); + mockDatafileResponse(TIMESTAMP + 2, false); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + + // The accepted response renews fetched freshness beyond the confirmation. + vi.setSystemTime(TIMESTAMP + 29_000); + mockDatafileResponse(TIMESTAMP + 100_000, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe( + TIMESTAMP + 100_000, + ); + expect(dataFetch).toHaveBeenCalledTimes(3); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('does not share confirmation across clients using the same %s object', async (origin) => { + const input = datafile(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: input, + state: 'ok', + }); + const options = { datafile: origin === 'provided' ? input : undefined }; + const first = client(options); + const second = client(options); + await first.evaluate('feature'); + setVersion(TIMESTAMP + 1); + + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await second.evaluate('feature')).metrics?.cacheStatus).toBe( + 'MISS', + ); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await first.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(2); + expect(input).not.toHaveProperty('_lastSeen'); + }); + + it.each([ + 0, -1, + ])('ignores a background response with version delta %i without extending freshness', async (delta) => { + const instance = client(); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + delta, true); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.getDatafile()).toEqual({ + ...datafile(), + metrics: expect.any(Object), + }); + + vi.setSystemTime(TIMESTAMP + 10_001); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('does not extend freshness after a failed background response', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const instance = client(); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP + 1); + dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + await vi.advanceTimersByTimeAsync(0); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Header refresh failed:', + expect.any(Error), + ); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); + vi.setSystemTime(TIMESTAMP + 10_001); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'MISS', + ); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it.each([ + 0, -1, + ])('keeps the cache unchanged after a blocking response with version delta %i', async (delta) => { + const instance = client(); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + await instance.evaluate('feature'); + + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 2); + mockDatafileResponse(TIMESTAMP + 1 + delta, false); + // Both the blocking read and later snapshots use the cache version guard. + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(await instance.getDatafile()).toEqual({ + ...datafile(TIMESTAMP + 1, true), + fetchedAt: TIMESTAMP, + metrics: expect.any(Object), + }); + + mockDatafileResponse(TIMESTAMP + 2, true); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'MISS', + ); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + expect(dataFetch).toHaveBeenCalledTimes(3); + }); + + it('finishes the background refresh even when waitUntil registration throws', async () => { + const waitUntil = vi.fn(() => { + throw new Error('No request lifetime available'); + }); + const instance = client({ waitUntil }); + await instance.evaluate('feature'); + waitUntil.mockClear(); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('uses the default unlimited stale-if-error allowance after blocking failures', async () => { + setVersion(TIMESTAMP + 1); + const instance = client(); + dataFetch.mockRejectedValue(new Error('service unavailable')); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + vi.setSystemTime(TIMESTAMP + 365 * 24 * 60 * 60 * 1_000); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('shares the first-error deadline with snapshots and recovers after expiry', async () => { + const instance = client({ staleIfErrorMs: 1_000 }); + setVersion(TIMESTAMP + 1); + const firstError = new Error('first failure'); + dataFetch.mockRejectedValueOnce(firstError); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + + vi.setSystemTime(TIMESTAMP + 1_000); + dataFetch.mockRejectedValueOnce(new Error('second failure')); + expect((await instance.evaluate('feature')).value).toBe(false); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); + + vi.setSystemTime(TIMESTAMP + 1_001); + // Older/missing evidence must not reset the first-error deadline or fetch. + for (const version of [undefined, 'invalid', TIMESTAMP - 1, TIMESTAMP]) { + setVersion(version); + await expect(instance.evaluate('feature')).rejects.toBe(firstError); + await expect(instance.getDatafile()).rejects.toBe(firstError); + } + expect(dataFetch).toHaveBeenCalledTimes(2); + + setVersion(TIMESTAMP + 1); + dataFetch.mockRejectedValueOnce(new Error('third failure')); + await expect(instance.evaluate('feature')).rejects.toBe(firstError); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); + expect(dataFetch).toHaveBeenCalledTimes(4); + }); + + it.each([ + -1, 0, 1, + ])('blocks after a background failure and applies recovery for response delta %i', async (delta) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const instance = client({ + datafile: { ...datafile(), fetchedAt: TIMESTAMP }, + staleIfErrorMs: 0, + }); + setVersion(TIMESTAMP + 1); + const failure = new Error('background failure'); + dataFetch.mockRejectedValueOnce(failure); + expect((await instance.evaluate('feature')).value).toBe(false); + await vi.advanceTimersByTimeAsync(0); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Header refresh failed:', + failure, + ); + await expect(instance.getDatafile()).rejects.toBe(failure); + + vi.setSystemTime(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const settled = vi.fn(); + const read = instance.evaluate('feature').finally(settled); + const outcome = + delta < 0 + ? expect(read).rejects.toBe(failure) + : expect(read).resolves.toMatchObject({ + value: delta > 0, + metrics: { cacheStatus: 'MISS' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(2); + + pending.resolve(Response.json(datafile(TIMESTAMP + delta, true))); + await outcome; + if (delta < 0) { + await expect(instance.getDatafile()).rejects.toBe(failure); + return; + } + // A matching response confirms recovery without replacing or retagging data. + expect(await instance.getDatafile()).toMatchObject({ + configUpdatedAt: TIMESTAMP + delta, + fetchedAt: TIMESTAMP + delta, + }); + }); + + it.each([ + '', + 'flags_other=123', + `flags_${PROJECT_ID}=0`, + `flags_${PROJECT_ID}=-1`, + `flags_${PROJECT_ID}=NaN`, + `flags_${PROJECT_ID}=Infinity`, + `flags_${PROJECT_ID}=`, + ])('ignores unusable header %s without starting sources', async (header) => { + cleanupContext(); + cleanupContext = setRequestContext({ [HEADER]: header }); + const instance = client(); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE', mode: 'vercel' }, + }); + expect(transport).not.toHaveBeenCalled(); + }); + + it('prefers the Vercel header and parses spaced project entries with a legacy cached version', async () => { + cleanupContext(); + cleanupContext = setRequestContext({ + [HEADER]: ` flags_other=1 ; flags_${PROJECT_ID}=${TIMESTAMP} ; flags_other2=3 `, + 'flags-config-versions': `flags_${PROJECT_ID}=${TIMESTAMP + 1}`, + }); + const instance = client({ + datafile: { ...datafile(), configUpdatedAt: String(TIMESTAMP) }, + }); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'HIT' }, + }); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it('keeps cached definitions without a config version', async () => { + setVersion(TIMESTAMP + 1); + const instance = client({ + datafile: { ...datafile(), configUpdatedAt: undefined }, + }); + expect((await instance.evaluate('feature')).value).toBe(false); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it('rejects a blocking read on shutdown even if the transport ignores cancellation', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + const reading = instance.evaluate('feature'); + const outcome = expect(reading).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(0); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + await instance.shutdown(); + clients.delete(instance); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await outcome; + expect(signal?.aborted).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('reports vercel mode in config-read telemetry', async () => { + const instance = client(); + await instance.evaluate('feature'); + await instance.shutdown(); + clients.delete(instance); + + const events = transport.mock.calls + .filter(([url]) => String(url).endsWith('/v1/ingest')) + .flatMap(([, init]) => JSON.parse(String(init?.body))); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'FLAGS_CONFIG_READ', + payload: expect.objectContaining({ + mode: 'vercel', + configUpdatedAt: TIMESTAMP, + cacheAction: 'NONE', + }), + }), + ]), + ); + }); }); From 48e6827711a2ca58530775c1c54b4b2e7f0860f4 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 10:19:53 +0200 Subject: [PATCH 05/20] feat(flags-core): accept stale-while-revalidate in seconds --- .changeset/header-driven-vercel-mode.md | 2 +- packages/vercel-flags-core/CLAUDE.md | 4 ++-- .../src/controller/normalized-options.ts | 19 ++++++++++--------- .../src/vercel-mode.black-box.test.ts | 18 +++++++++--------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index d71c9635c..0bdf79080 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -2,4 +2,4 @@ "@vercel/flags-core": minor --- -Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-versions` or `flags-config-versions` request header instead of streaming or polling. The `vercel` option defaults to `process.env.VERCEL === '1'` and can be explicitly enabled or disabled. Vercel mode loads provided or bundled definitions during initialization, uses cached data when no usable header is available, and fetches on the first read if the cache is empty. Disabling both streaming and polling preserves offline behavior. Reuse fresh definitions, refresh in the background for up to `staleWhileRevalidateMs` (10 seconds by default) since the cached configuration was last successfully fetched or confirmed by a matching request header at the highest observed version, and block for a refresh when that freshness expires or is unknown. Set `staleWhileRevalidateMs` to `0` to disable background stale serving. Refresh failures use the shared `staleIfErrorMs` allowance; set that to `0` to propagate failures immediately. Datafiles expose an optional `fetchedAt` timestamp that survives serialization and reuse. Bundled and provided definitions preserve valid timestamps; definitions without one start with unknown age. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. +Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-versions` or `flags-config-versions` request header instead of streaming or polling. The `vercel` option defaults to `process.env.VERCEL === '1'` and can be explicitly enabled or disabled. Vercel mode loads provided or bundled definitions during initialization, uses cached data when no usable header is available, and fetches on the first read if the cache is empty. Disabling both streaming and polling preserves offline behavior. Reuse fresh definitions, refresh in the background for up to `staleWhileRevalidate` (10 seconds by default) since the cached configuration was last successfully fetched or confirmed by a matching request header at the highest observed version, and block for a refresh when that freshness expires or is unknown. Set `staleWhileRevalidate` to `0` to disable background stale serving. Refresh failures use the shared `staleIfError` allowance; set that to `0` to propagate failures immediately. Datafiles expose an optional `fetchedAt` timestamp that survives serialization and reuse. Bundled and provided definitions preserve valid timestamps; definitions without one start with unknown age. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index fb611a60c..ef7493a2e 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -90,7 +90,7 @@ type ControllerOptions = { stream?: boolean | { initTimeoutMs: number }; // default: true (3000ms) polling?: boolean | { intervalMs: number; initTimeoutMs: number }; // default: true (30s interval, 3s timeout) vercel?: boolean; // default: process.env.VERCEL === '1'; replaces stream/poll at runtime - staleWhileRevalidateMs?: number; // header refresh only; default: 10_000 + staleWhileRevalidate?: number; // Seconds of header refresh grace; default: 10 staleIfError?: number; // Seconds of fallback after update failure; default: Infinity buildStep?: boolean; // Override build step auto-detection metricEnvironment?: string; // Environment attached to ingested evaluation metrics @@ -120,7 +120,7 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu - Do not start stream/poll; the first read fetches if the cache is empty. - HeaderSource parses the request's project version and owns `highestObserved` and `lastSeen`. - A matching header confirms freshness only when no newer version has been observed. -- A newer header refreshes in the background within `staleWhileRevalidateMs` of the latest +- A newer header refreshes in the background within `staleWhileRevalidate` of the latest accepted fetch or matching header; unknown/expired freshness requires a blocking refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 288bc50f8..feb7cf38e 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -12,7 +12,7 @@ const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; const DEFAULT_POLLING_INTERVAL_MS = 30_000; const MIN_POLLING_INTERVAL_MS = 30_000; const DEFAULT_POLLING_INIT_TIMEOUT_MS = 3_000; -const DEFAULT_STALE_WHILE_REVALIDATE_MS = 10_000; +const DEFAULT_STALE_WHILE_REVALIDATE = 10; /** * Configuration options for Controller @@ -57,10 +57,11 @@ export type ControllerOptions = { /** * How long header-driven reads may serve cached data while refreshing in the * background, measured from its last fetch or matching version header. - * Must be a finite, non-negative number. Set to 0 to always block on refresh. - * @default 10000 + * Accepts finite, non-negative seconds, including fractional seconds. + * Set to 0 to always block on refresh. + * @default 10 */ - staleWhileRevalidateMs?: number; + staleWhileRevalidate?: number; /** * How long runtime reads may use cached data after the first consecutive @@ -178,11 +179,11 @@ export function normalizeOptions( }; } - const staleWhileRevalidateMs = - options.staleWhileRevalidateMs ?? DEFAULT_STALE_WHILE_REVALIDATE_MS; - if (!Number.isFinite(staleWhileRevalidateMs) || staleWhileRevalidateMs < 0) { + const staleWhileRevalidate = + options.staleWhileRevalidate ?? DEFAULT_STALE_WHILE_REVALIDATE; + if (!Number.isFinite(staleWhileRevalidate) || staleWhileRevalidate < 0) { throw new Error( - '@vercel/flags-core: staleWhileRevalidateMs must be a finite, non-negative number.', + '@vercel/flags-core: staleWhileRevalidate must be a finite, non-negative number of seconds.', ); } @@ -192,7 +193,7 @@ export function normalizeOptions( stream, polling, vercel: options.vercel ?? process.env.VERCEL === '1', - staleWhileRevalidateMs, + staleWhileRevalidateMs: staleWhileRevalidate * 1000, staleIfErrorMs: staleIfError * 1000, buildStep, fetch: options.fetch ?? globalThis.fetch, diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 2de618d23..5c1ad7248 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -568,15 +568,15 @@ describe('Vercel mode (black-box)', () => { it.each([ undefined, - 100, - 20_000, - ])('honors staleWhileRevalidateMs=%s at the boundary and on expiry', async (staleWhileRevalidateMs) => { + 0.1, + 20, + ])('honors staleWhileRevalidate=%s at the boundary and on expiry', async (staleWhileRevalidate) => { const waitUntil = vi.fn(); - const instance = client({ staleWhileRevalidateMs, waitUntil }); + const instance = client({ staleWhileRevalidate, waitUntil }); await instance.evaluate('feature'); waitUntil.mockClear(); - const windowMs = staleWhileRevalidateMs ?? 10_000; + const windowMs = (staleWhileRevalidate ?? 10) * 1000; const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); setVersion(TIMESTAMP + 100_000); @@ -606,7 +606,7 @@ describe('Vercel mode (black-box)', () => { }); it('disables stale serving with a zero window, even immediately after a HIT', async () => { - const instance = client({ staleWhileRevalidateMs: 0 }); + const instance = client({ staleWhileRevalidate: 0 }); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( 'HIT', ); @@ -626,9 +626,9 @@ describe('Vercel mode (black-box)', () => { NaN, Infinity, -Infinity, - ])('rejects invalid staleWhileRevalidateMs=%s', (staleWhileRevalidateMs) => { - expect(() => client({ staleWhileRevalidateMs })).toThrow( - 'staleWhileRevalidateMs must be a finite, non-negative number', + ])('rejects invalid staleWhileRevalidate=%s', (staleWhileRevalidate) => { + expect(() => client({ staleWhileRevalidate })).toThrow( + 'staleWhileRevalidate must be a finite, non-negative number of seconds', ); }); From cca6e706086147c17af46990e6cc86ba7727fe2a Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 10:25:53 +0200 Subject: [PATCH 06/20] refactor(flags-core): drive cache reads with source callbacks --- .changeset/header-driven-vercel-mode.md | 6 +- packages/vercel-flags-core/CLAUDE.md | 18 +- packages/vercel-flags-core/README.md | 34 +++- .../src/controller/datafile-cache.ts | 123 ++++++++++-- .../src/controller/header-source.ts | 184 ++++-------------- .../vercel-flags-core/src/controller/index.ts | 43 ++-- .../src/vercel-mode.black-box.test.ts | 6 +- 7 files changed, 235 insertions(+), 179 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 0bdf79080..2c3975dac 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -2,4 +2,8 @@ "@vercel/flags-core": minor --- -Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-versions` or `flags-config-versions` request header instead of streaming or polling. The `vercel` option defaults to `process.env.VERCEL === '1'` and can be explicitly enabled or disabled. Vercel mode loads provided or bundled definitions during initialization, uses cached data when no usable header is available, and fetches on the first read if the cache is empty. Disabling both streaming and polling preserves offline behavior. Reuse fresh definitions, refresh in the background for up to `staleWhileRevalidate` (10 seconds by default) since the cached configuration was last successfully fetched or confirmed by a matching request header at the highest observed version, and block for a refresh when that freshness expires or is unknown. Set `staleWhileRevalidate` to `0` to disable background stale serving. Refresh failures use the shared `staleIfError` allowance; set that to `0` to propagate failures immediately. Datafiles expose an optional `fetchedAt` timestamp that survives serialization and reuse. Bundled and provided definitions preserve valid timestamps; definitions without one start with unknown age. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. +Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved. + +The controller supplies source freshness, staleness, and revalidation callbacks to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. Streaming/polling retain their existing schedules. + +Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index ef7493a2e..45707bb61 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -120,8 +120,10 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu - Do not start stream/poll; the first read fetches if the cache is empty. - HeaderSource parses the request's project version and owns `highestObserved` and `lastSeen`. - A matching header confirms freshness only when no newer version has been observed. -- A newer header refreshes in the background within `staleWhileRevalidate` of the latest - accepted fetch or matching header; unknown/expired freshness requires a blocking refresh. +- The controller passes `isFresh`, `isStale`, and `revalidate` callbacks to `cache.resolve()`. + The cache selects cached/background/blocking behavior and shares refresh work. +- A newer header permits background refresh within `staleWhileRevalidate` seconds of the + latest accepted fetch or matching header; unknown/expired freshness blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. - Missing/malformed headers use cached data without fetching, subject to stale-if-error. @@ -312,16 +314,24 @@ The DatafileCache rejects incoming data (from stream or poll) if its `configUpda ### Cache read policy -`DatafileCache.read()` is the only full-entry read. The cache is configured once +Every served entry passes through `DatafileCache.read()`, including `resolve(policy)` results. The cache is configured once with the internal `staleIfErrorMs`, normalized from the public `staleIfError` option in seconds. Evaluations and `getDatafile()` share the same serving -boundary. `hasData`, `revision`, and `metadata` expose coordination metadata even after expiry, +boundary. `hasData` and `revision` expose coordination metadata even after expiry, so retained data is not replaced by fallback and stream reconnects can still send `X-Revision`. `seed()` never clears failure. Accepted source updates or valid version/revision confirmations clear it; repeated errors/disconnects do not renew the first-error deadline. Stream opening/pings and initialization timeout alone are not recovery/failure evidence respectively. +`cache.resolve(policy)` receives mode-specific `isFresh`, `isStale`, and optional +`revalidate` functions. It owns background/blocking decisions, `waitUntil`, shared +revalidation, and cancellation on clear. HeaderSource supplies small version/age +checks and a fetch callback; it does not read the cache. Header confirmations are +forwarded through controller event wiring. Stream/poll modes omit on-read revalidation +and retain their existing schedules. New public time windows use seconds; internal +normalized durations and `fetchedAt` use milliseconds. + ### Evaluation Safety - Regex comparators (`REGEX`, `NOT_REGEX`) limit input string length to 10,000 characters to prevent ReDoS diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index e51e4eb3a..9389bcda3 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -33,10 +33,36 @@ export default app; Outside Vercel, pass an SDK key explicitly: `createClient(process.env.FLAGS)`. -## Cached stream and polling reads +## Header-driven reads on Vercel + +When `VERCEL=1`, the client defaults to `vercel: true`. Initialization loads provided +or bundled definitions without starting a stream or polling. Request version headers +indicate when cached definitions need refreshing; reads without a usable header keep +cached definitions, fetching only when the cache is empty. + +```ts +const client = createClient(process.env.FLAGS!, { + vercel: true, + staleWhileRevalidate: 10, // Seconds of background-refresh grace. + staleIfError: 60, // Seconds of cached fallback after a refresh failure. +}); +``` + +`staleWhileRevalidate` defaults to 10 seconds and accepts finite, nonnegative values, +including fractions. `0` makes refreshes block. The window starts at the latest +accepted fetch or matching request-header confirmation. Bundled/provided definitions +preserve their original `fetchedAt`; unknown or expired freshness requires a blocking +refresh when a newer request version arrives. Refresh failures use `staleIfError`. +A newer-header read attempts blocking recovery after that failure allowance expires. + +`getDatafile()` remains a snapshot read: it applies stale-if-error but does not inspect +headers. Use `vercel: false` to select the existing stream/poll behavior. Disabling both +stream and polling still selects offline mode, and builds retain their existing loading. + +## Cached reads after errors `staleIfError` controls how many seconds evaluations and `getDatafile()` may use -cached flag definitions after a stream/poll failure or stream disconnect: +cached flag definitions after a stream/poll/header-refresh failure or stream disconnect: ```ts const client = createClient(process.env.FLAGS!, { @@ -66,8 +92,8 @@ retained for recovery, including its revision for stream reconnection. A clean stream close or ping timeout records `stream: disconnected` if no earlier failure exists. `getFallbackDatafile()` remains an independent bundled-data export. -There is no age-based expiry while the source is healthy, and reads do not trigger -an extra refresh after expiry. Build/offline behavior, source scheduling, retries, +Stream/poll modes have no age-based expiry while the source is healthy, and reads +do not trigger an extra refresh after expiry. Build/offline behavior, source scheduling, retries, timeouts, metrics categories, and logging are unchanged. An initialization timeout alone does not start the allowance. Existing startup limitations remain: when initial polling times out, no recurring interval is started, even if that in-flight diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 37b32580b..f6840f2ab 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -1,4 +1,4 @@ -import type { DatafileInput } from '../types'; +import type { DatafileInput, Metrics, WaitUntil } from '../types'; import { type DataOrigin, type TaggedData, tagData } from './tagged-data'; type Confirmation = Pick< @@ -8,6 +8,18 @@ type Confirmation = Pick< export type CacheMetadata = Confirmation & Pick; +type Revalidate = (signal: AbortSignal) => Promise; +type CacheResult = [TaggedData, Metrics['cacheStatus']]; + +export type CacheReadPolicy = { + /** Undefined adds no freshness evidence and keeps cached-read behavior. */ + isFresh: (data: CacheMetadata) => boolean | undefined; + /** Whether stale data may be served while revalidation runs. */ + isStale: (data: CacheMetadata) => boolean; + /** Omit for modes whose stream/poll loop already maintains the cache. */ + revalidate?: Revalidate; +}; + /** * Parses a configUpdatedAt value (number or string) into a numeric timestamp. * Returns undefined if the value is missing or cannot be parsed. @@ -21,12 +33,18 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { return undefined; } -/** Storage and failure-relative read policy; callers provide source evidence. */ +/** Storage, serving policy, and revalidation driven by source callbacks. */ export class DatafileCache { private data: TaggedData | undefined; private failure: { error: Error; startedAt: number } | undefined; - constructor(private readonly staleIfErrorMs = Infinity) {} + private abortController = new AbortController(); + private revalidation: Promise | undefined; + + constructor( + private readonly staleIfErrorMs = Infinity, + private readonly waitUntil: WaitUntil = () => {}, + ) {} get hasData(): boolean { return this.data !== undefined; @@ -38,7 +56,7 @@ export class DatafileCache { } /** Freshness checks can inspect retained metadata even after serving expires. */ - get metadata(): CacheMetadata | undefined { + private get metadata(): CacheMetadata | undefined { if (!this.data) return undefined; const { projectId, environment, configUpdatedAt, revision, fetchedAt } = this.data; @@ -107,21 +125,104 @@ export class DatafileCache { this.failure ??= { error, startedAt: Date.now() }; } + private canServe(): boolean { + if (!this.failure || this.staleIfErrorMs === Infinity) return true; + return ( + this.staleIfErrorMs > 0 && + Date.now() - this.failure.startedAt <= this.staleIfErrorMs + ); + } + + /** The serving boundary for both snapshot and policy-driven reads. */ read(): TaggedData | undefined { if (!this.data) return undefined; + if (!this.canServe()) throw this.failure!.error; + return this.data; + } - if (!this.failure || this.staleIfErrorMs === Infinity) return this.data; + async resolve(policy: CacheReadPolicy): Promise { + const metadata = this.metadata; + if (metadata) { + const fresh = policy.isFresh(metadata); + if (fresh !== false || !policy.revalidate) { + return [this.read()!, fresh ? 'HIT' : 'STALE']; + } + if (policy.isStale(metadata) && this.canServe()) { + const stale = this.read()!; + this.revalidateInBackground(policy.revalidate); + return [stale, 'STALE']; + } + } - const withinAllowance = - this.staleIfErrorMs > 0 && - Date.now() - this.failure.startedAt <= this.staleIfErrorMs; - if (!withinAllowance) { - throw this.failure.error; + if (!policy.revalidate) return; + const { promise, signal } = this.startRevalidation(policy.revalidate); + try { + await promise; + signal.throwIfAborted(); + } catch (error) { + if (signal.aborted) throw error; + const stale = this.read(); + if (!stale) throw error; + return [stale, 'STALE']; + } + + // A cold fetch discovers the project; assess the original request's header. + if (!metadata && this.metadata) policy.isFresh(this.metadata); + const data = this.read(); + if (!data) + throw new Error( + '@vercel/flags-core: Revalidation returned no definitions', + ); + return [data, 'MISS']; + } + + private startRevalidation(revalidate: Revalidate) { + const { signal } = this.abortController; + if (this.revalidation) return { promise: this.revalidation, signal }; + + const promise = Promise.resolve() + .then(() => { + signal.throwIfAborted(); + return revalidate(signal); + }) + .then(() => signal.throwIfAborted()) + .catch((error) => { + if (!signal.aborted) { + this.fail( + error instanceof Error + ? error + : new Error('Unknown revalidation error'), + ); + } + throw error; + }) + .finally(() => { + // An old, aborted operation must not clear a newer one. + if (this.abortController.signal === signal) + this.revalidation = undefined; + }); + this.revalidation = promise; + return { promise, signal }; + } + + private revalidateInBackground(revalidate: Revalidate): void { + const { promise, signal } = this.startRevalidation(revalidate); + const background = promise.catch((error) => { + if (!signal.aborted) { + console.error('@vercel/flags-core: Revalidation failed:', error); + } + }); + try { + this.waitUntil(background); + } catch { + // Registration is best-effort; the handled refresh continues regardless. } - return this.data; } clear(): void { + this.abortController.abort(); + this.abortController = new AbortController(); + this.revalidation = undefined; this.data = undefined; } } diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index b1be3d5c9..4868af7d0 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,67 +1,35 @@ -import type { BundledDefinitions, DatafileInput, Metrics } from '../types'; +import type { DatafileInput } from '../types'; import { getRequestContext } from '../utils/request-context'; -import type { CacheMetadata, DatafileCache } from './datafile-cache'; +import type { CacheMetadata, CacheReadPolicy } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; -import type { TaggedData } from './tagged-data'; import { TypedEmitter } from './typed-emitter'; export type HeaderSourceEvents = { data: (data: DatafileInput) => void; - error: (error: Error) => void; + confirmed: (data: CacheMetadata) => void; }; -/** - * Manages a lazy pulling of flag data from the flags service using the version header. - */ +/** Request version evidence and fetching; the cache decides how to serve reads. */ export class HeaderSource extends TypedEmitter { - private options: NormalizedOptions; - private abortController: AbortController | undefined; - private promise: Promise | undefined; private highestObserved = 0; private lastSeen: { version: number; at: number } | undefined; - constructor(options: NormalizedOptions) { + constructor(private readonly options: NormalizedOptions) { super(); - - this.options = options; } - private fetchDatafile(): Promise { - // Share only the transport work, not request-specific freshness decisions. - if (this.promise) return this.promise; - - const abortController = new AbortController(); - this.abortController = abortController; - this.promise = fetchDatafile({ - ...this.options, - signal: abortController.signal, - }) - .then((data) => { - // A transport may finish after stop() even if it ignores cancellation. - abortController.signal.throwIfAborted(); - this.emit('data', data); - return data; - }) - .catch((error) => { - if (!abortController.signal.aborted) this.emit('error', error); - throw error; - }) - .finally(() => { - // An older, aborted fetch must not clear a newer request's work. - if (this.abortController === abortController) { - this.promise = undefined; - this.abortController = undefined; - } - }); - - return this.promise; + /** Capture this request's header before any cold-cache fetch awaits. */ + getFreshnessCheck(): CacheReadPolicy['isFresh'] { + const { headers } = getRequestContext(); + const header = + headers?.['x-vercel-flags-config-versions'] ?? + headers?.['flags-config-versions']; + return (data) => this.isFresh(data, header); } private getUpdatedAtHeader(projectId: string, header: string | undefined) { - if (!header) { - return; - } + if (!header) return; const prefix = `flags_${projectId}=`; const value = header @@ -70,113 +38,49 @@ export class HeaderSource extends TypedEmitter { .find((part) => part.startsWith(prefix)) ?.slice(prefix.length); const timestamp = Number(value); - return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; } - private async refresh( - cache: DatafileCache, - ): Promise<[TaggedData, Metrics['cacheStatus']]> { - const pending = this.fetchDatafile(); - const signal = this.abortController!.signal; - try { - await pending; - signal.throwIfAborted(); - } catch (error) { - if (signal.aborted) throw error; - // The controller recorded the failure; the cache decides whether to serve it. - const stale = cache.read(); - if (!stale) throw error; - return [stale, 'STALE']; - } - return [cache.read()!, 'MISS']; - } - - private revalidate(): void { - const pending = this.fetchDatafile(); - const signal = this.abortController?.signal; - const background = pending.catch((error) => { - if (!signal?.aborted) { - console.error('@vercel/flags-core: Header refresh failed:', error); - } - }); + private isFresh( + data: CacheMetadata, + header: string | undefined, + ): boolean | undefined { + const version = this.getUpdatedAtHeader(data.projectId, header); + if (!version) return; - try { - this.options.waitUntil(background); - } catch { - // Registration is best-effort; the handled refresh continues regardless. + const currentVersion = Number(data.configUpdatedAt); + this.highestObserved = Math.max(this.highestObserved, version); + // An older matching request cannot undo a newer request's invalidation. + if (version === currentVersion && version === this.highestObserved) { + this.lastSeen = { version, at: Date.now() }; + this.emit('confirmed', data); } - } - private async resolveData( - cache: DatafileCache, - current: CacheMetadata, - updatedAtHeader: number | undefined, - ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { - if (!current.configUpdatedAt || !updatedAtHeader) return; - - const currentUpdatedAt = Number(current.configUpdatedAt); - if (updatedAtHeader <= currentUpdatedAt) { - return [cache.read()!, 'HIT']; - } + if (!data.configUpdatedAt) return; + return version <= currentVersion; + } + /** Whether this version is still inside its background-refresh window. */ + isStale = (data: CacheMetadata): boolean => { const freshAt = Math.max( - current.fetchedAt ?? -Infinity, - this.lastSeen?.version === currentUpdatedAt + data.fetchedAt ?? -Infinity, + this.lastSeen?.version === Number(data.configUpdatedAt) ? this.lastSeen.at : -Infinity, ); const { staleWhileRevalidateMs } = this.options; - if ( + return ( staleWhileRevalidateMs > 0 && Date.now() - freshAt <= staleWhileRevalidateMs - ) { - let stale: TaggedData | undefined; - try { - stale = cache.read(); - } catch { - // Expired stale-if-error requires a blocking recovery attempt below. - } - if (stale) { - this.revalidate(); - return [stale, 'STALE']; - } - } - - return this.refresh(cache); - } - - private observe(version: number, currentVersion: number): boolean { - this.highestObserved = Math.max(this.highestObserved, version); - // Once invalidated, an older matching header cannot renew freshness. - if (version !== currentVersion || version !== this.highestObserved) - return false; - this.lastSeen = { version, at: Date.now() }; - return true; - } - - async read( - cache: DatafileCache, - ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { - // Capture the request header before a cold fetch discovers its project. - const { headers } = getRequestContext(); - const header = - headers?.['x-vercel-flags-config-versions'] ?? - headers?.['flags-config-versions']; - const current = cache.metadata; - const fetched = current ? undefined : await this.refresh(cache); - const metadata = current ?? cache.metadata!; - const updatedAtHeader = this.getUpdatedAtHeader(metadata.projectId, header); - if ( - updatedAtHeader && - this.observe(updatedAtHeader, Number(metadata.configUpdatedAt)) - ) { - cache.tryConfirm(metadata); - } + ); + }; - if (fetched) return fetched; - return this.resolveData(cache, metadata, updatedAtHeader); - } + revalidate = async (signal: AbortSignal): Promise => { + const data = await fetchDatafile({ ...this.options, signal }); + // Transports can finish after cancellation; never publish that response. + signal.throwIfAborted(); + this.emit('data', data); + }; isAvailable(): boolean { // Explicit offline mode disables header-driven refreshes too. @@ -186,13 +90,7 @@ export class HeaderSource extends TypedEmitter { ); } - /** - * Abort the current header-driven fetch and discard its pending work. - */ stop(): void { - this.abortController?.abort(); - this.abortController = undefined; - this.promise = undefined; this.lastSeen = undefined; this.highestObserved = 0; } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 20faf4071..12f81ee64 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -10,7 +10,11 @@ import type { TrackReadOptions } from '../utils/usage/flags-config-read'; import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; import { UsageTracker } from '../utils/usage-tracker'; import { BundledSource } from './bundled-source'; -import { DatafileCache } from './datafile-cache'; +import { + type CacheMetadata, + type CacheReadPolicy, + DatafileCache, +} from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; import { @@ -114,7 +118,10 @@ export class Controller implements ControllerInterface { constructor(options: ControllerOptions) { this.options = normalizeOptions(options); - this.cache = new DatafileCache(this.options.staleIfErrorMs); + this.cache = new DatafileCache( + this.options.staleIfErrorMs, + this.options.waitUntil, + ); // Create source modules this.streamSource = new StreamSource( @@ -173,6 +180,9 @@ export class Controller implements ControllerInterface { private onHeaderData = (data: DatafileInput) => { this.cache.updateFromSource(data, 'fetched'); }; + private onHeaderConfirmed = (data: CacheMetadata) => { + this.cache.tryConfirm(data); + }; private onPollError = (error: Error) => { this.cache.fail(error); console.error('@vercel/flags-core: Poll failed:', error); @@ -191,7 +201,7 @@ export class Controller implements ControllerInterface { this.pollingSource.on('data', this.onPollData); this.pollingSource.on('error', this.onPollError); this.headerSource.on('data', this.onHeaderData); - this.headerSource.on('error', this.onSourceError); + this.headerSource.on('confirmed', this.onHeaderConfirmed); } private unwireSourceEvents(): void { @@ -203,7 +213,7 @@ export class Controller implements ControllerInterface { this.pollingSource.off('data', this.onPollData); this.pollingSource.off('error', this.onPollError); this.headerSource.off('data', this.onHeaderData); - this.headerSource.off('error', this.onSourceError); + this.headerSource.off('confirmed', this.onHeaderConfirmed); } // --------------------------------------------------------------------------- @@ -452,18 +462,25 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } - if (this.state === 'vercel') { - const result = await this.headerSource.read(this.cache); - if (result) return result; - } + const result = await this.cache.resolve(this.getCacheReadPolicy()); + if (result) return result; + return this.resolveDataWithFallbacks(); + } - const data = this.cache.read(); - if (data) { - const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; - return [data, cacheStatus]; + private getCacheReadPolicy(): CacheReadPolicy { + if (this.state === 'vercel') { + return { + isFresh: this.headerSource.getFreshnessCheck(), + isStale: this.headerSource.isStale, + revalidate: this.headerSource.revalidate, + }; } - return this.resolveDataWithFallbacks(); + // Stream/poll maintain their existing schedules; reads do not trigger I/O. + return { + isFresh: () => this.isConnected, + isStale: () => true, + }; } // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 5c1ad7248..31b839a6f 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -533,7 +533,7 @@ describe('Vercel mode (black-box)', () => { pending.reject(failure); await vi.advanceTimersByTimeAsync(0); expect(errorSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Header refresh failed:', + '@vercel/flags-core: Revalidation failed:', failure, ); dataFetch.mockResolvedValueOnce( @@ -909,7 +909,7 @@ describe('Vercel mode (black-box)', () => { ); await vi.advanceTimersByTimeAsync(0); expect(errorSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Header refresh failed:', + '@vercel/flags-core: Revalidation failed:', expect.any(Error), ); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); @@ -1041,7 +1041,7 @@ describe('Vercel mode (black-box)', () => { expect((await instance.evaluate('feature')).value).toBe(false); await vi.advanceTimersByTimeAsync(0); expect(errorSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Header refresh failed:', + '@vercel/flags-core: Revalidation failed:', failure, ); await expect(instance.getDatafile()).rejects.toBe(failure); From 266132f7641b87a292d4d1fc022c75c599df2406 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 10:25:54 +0200 Subject: [PATCH 07/20] test(flags-core): cover cache callbacks and request isolation --- .../controller/datafile-cache-policy.test.ts | 198 ++++++++++++++++++ .../src/vercel-mode.black-box.test.ts | 38 ++++ 2 files changed, 236 insertions(+) create mode 100644 packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts new file mode 100644 index 000000000..d391ea9cc --- /dev/null +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DatafileInput } from '../types'; +import { type CacheReadPolicy, DatafileCache } from './datafile-cache'; +import { tagData } from './tagged-data'; + +function data(configUpdatedAt = 1): DatafileInput { + return { + projectId: 'prj_policy', + environment: 'production', + definitions: {}, + configUpdatedAt, + }; +} + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let errorSpy: ReturnType; +let warnSpy: ReturnType; +beforeEach(() => { + vi.useFakeTimers({ now: 1_000 }); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + try { + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + vi.useRealTimers(); + } +}); + +describe('cache read callbacks', () => { + it.each([ + true, + undefined, + ])('serves a %s assessment without revalidating or clearing a failure', async (fresh) => { + const cache = new DatafileCache(0); + const original = tagData(data(), 'provided'); + cache.seed(original); + const policy = { + isFresh: vi.fn(() => fresh), + isStale: vi.fn(() => true), + revalidate: vi.fn(async () => {}), + }; + + expect(await cache.resolve(policy)).toEqual([ + original, + fresh ? 'HIT' : 'STALE', + ]); + expect(policy.isFresh).toHaveBeenCalledExactlyOnceWith({ + projectId: 'prj_policy', + environment: 'production', + configUpdatedAt: 1, + revision: undefined, + fetchedAt: undefined, + }); + const failure = new Error('outage'); + cache.fail(failure); + await expect(cache.resolve(policy)).rejects.toBe(failure); + expect(policy.isStale).not.toHaveBeenCalled(); + expect(policy.revalidate).not.toHaveBeenCalled(); + }); + + it('shares a background refresh with a later blocking read', async () => { + const waitUntil = vi.fn(); + const cache = new DatafileCache(Infinity, waitUntil); + const original = tagData(data(), 'provided'); + cache.seed(original); + const pending = deferred(); + const revalidate = vi.fn(async () => { + await pending.promise; + cache.updateFromSource(data(2), 'fetched'); + }); + const policy = { isFresh: () => false, isStale: () => true, revalidate }; + expect(await cache.resolve(policy)).toEqual([original, 'STALE']); + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + const settled = vi.fn(); + const blocking = cache + .resolve({ ...policy, isStale: () => false }) + .then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(revalidate).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); + + pending.resolve(); + expect(await blocking).toEqual([cache.read(), 'MISS']); + await expect(waitUntil.mock.calls[0]?.[0]).resolves.toBeUndefined(); + expect(cache.read()?.configUpdatedAt).toBe(2); + }); + + it('uses the failure deadline even when the callback still permits stale serving', async () => { + const waitUntil = vi.fn(); + const cache = new DatafileCache(0, waitUntil); + cache.seed(tagData(data(), 'provided')); + const failure = new Error('refresh failed'); + const revalidate = vi + .fn>() + .mockRejectedValueOnce(failure); + const policy = { isFresh: () => false, isStale: () => true, revalidate }; + + expect((await cache.resolve(policy))?.[1]).toBe('STALE'); + await waitUntil.mock.calls[0]?.[0]; + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Revalidation failed:', + failure, + ); + errorSpy.mockClear(); + expect(() => cache.read()).toThrow(failure); + + revalidate.mockImplementationOnce(async () => + cache.updateFromSource(data(2), 'fetched'), + ); + expect((await cache.resolve(policy))?.[1]).toBe('MISS'); + expect(cache.read()?.configUpdatedAt).toBe(2); + expect(revalidate).toHaveBeenCalledTimes(2); + expect(waitUntil).toHaveBeenCalledTimes(1); + }); + + it('contains synchronous revalidation failures and permits a later retry', async () => { + const cache = new DatafileCache(); + const failure = new Error('synchronous failure'); + const revalidate = vi.fn>(() => { + throw failure; + }); + const policy = { isFresh: () => false, isStale: () => false, revalidate }; + await expect(cache.resolve(policy)).rejects.toBe(failure); + revalidate.mockImplementationOnce(async () => + cache.updateFromSource(data(), 'fetched'), + ); + expect((await cache.resolve(policy))?.[1]).toBe('MISS'); + expect(revalidate).toHaveBeenCalledTimes(2); + }); + + it('cancels queued revalidation without invoking the callback', async () => { + const cache = new DatafileCache(); + const revalidate = vi.fn(async () => {}); + const reading = cache.resolve({ + isFresh: () => false, + isStale: () => false, + revalidate, + }); + const outcome = expect(reading).rejects.toThrow(); + cache.clear(); + await outcome; + expect(revalidate).not.toHaveBeenCalled(); + }); + + it('does not let cancelled work fail or clear a newer revalidation', async () => { + const cache = new DatafileCache(0); + cache.seed(tagData(data(), 'provided')); + const oldPending = deferred(); + const nextPending = deferred(); + const revalidate = vi + .fn>() + .mockImplementationOnce(() => oldPending.promise) + .mockImplementationOnce(async (signal) => { + await nextPending.promise; + signal.throwIfAborted(); + cache.updateFromSource(data(2), 'fetched'); + }); + const policy = { isFresh: () => false, isStale: () => false, revalidate }; + const oldRead = cache.resolve(policy); + const cancelled = expect(oldRead).rejects.toThrow('cancelled transport'); + await vi.advanceTimersByTimeAsync(0); + const oldSignal = revalidate.mock.calls[0]?.[0]; + cache.clear(); + cache.seed(tagData(data(), 'provided')); + const nextRead = cache.resolve(policy); + await vi.advanceTimersByTimeAsync(0); + oldPending.reject(new Error('cancelled transport')); + await cancelled; + expect(oldSignal?.aborted).toBe(true); + expect(cache.read()?.configUpdatedAt).toBe(1); + + const sharedRead = cache.resolve(policy); + await vi.advanceTimersByTimeAsync(0); + expect(revalidate).toHaveBeenCalledTimes(2); + nextPending.resolve(); + expect(await Promise.all([nextRead, sharedRead])).toEqual([ + [cache.read(), 'MISS'], + [cache.read(), 'MISS'], + ]); + expect(cache.read()?.configUpdatedAt).toBe(2); + }); +}); diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 31b839a6f..31f2ca811 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -196,6 +196,44 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(cache === 'empty' ? 1 : 0); }); + it('retains the original cold request header when the request context changes during a fetch', async () => { + const firstFetch = deferred(); + dataFetch.mockReturnValueOnce(firstFetch.promise); + const instance = client({ datafile: undefined }); + const firstRead = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + setVersion(TIMESTAMP + 1); + firstFetch.resolve(Response.json(datafile())); + expect((await firstRead).metrics?.cacheStatus).toBe('MISS'); + + // The unrelated context above must not prevent a matching request confirming freshness. + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); + const nextFetch = deferred(); + dataFetch.mockReturnValueOnce(nextFetch.promise); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + const servedBeforeRefresh = settled.mock.calls.length; + nextFetch.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + expect(await reading).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + expect(servedBeforeRefresh).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(2); + expect((await instance.evaluate('feature')).value).toBe(true); + }); + it('recovers on a later read when the cold-cache fetch fails', async () => { const instance = client({ datafile: undefined }); dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); From 4821e9cdaa2958124be4a22560318dbb5440efb5 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 11:25:24 +0200 Subject: [PATCH 08/20] refactor(flags-core): preserve bundled initialization flow --- .../vercel-flags-core/src/controller/index.ts | 9 ++------- .../src/vercel-mode.black-box.test.ts | 16 ---------------- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 12f81ee64..8c1ccc104 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -272,17 +272,12 @@ export class Controller implements ControllerInterface { // send the revision to the stream and potentially get a lightweight // "primed" response instead of a full datafile. if (!this.cache.hasData) { - this.transition('initializing:fallback'); - let bundled: DatafileInput | undefined; try { - bundled = await this.bundledSource.tryLoad(); + const bundled = await this.bundledSource.tryLoad(); + if (bundled) this.cache.seed(tagData(bundled, 'bundled')); } catch { // Bundled definitions not available — proceed without revision } - if (this.state === 'shutdown') { - throw new Error('@vercel/flags-core: Client is shut down'); - } - if (bundled) this.cache.seed(tagData(bundled, 'bundled')); } if (this.headerSource.isAvailable()) { diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 31f2ca811..ea49e130a 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -249,22 +249,6 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(2); }); - it('does not finish initialization after shutdown while bundled data is loading', async () => { - const pending = - deferred>>(); - vi.mocked(readBundledDefinitions).mockReturnValueOnce(pending.promise); - const instance = client({ datafile: undefined }); - const initializing = instance.initialize(); - const outcome = expect(initializing).rejects.toThrow('Client is shut down'); - await vi.advanceTimersByTimeAsync(0); - await instance.shutdown(); - clients.delete(instance); - pending.resolve({ definitions: datafile(), state: 'ok' }); - - await outcome; - expect(transport).not.toHaveBeenCalled(); - }); - it.each([ HEADER, 'flags-config-versions', From 23f74b77966a1c30a43a07ba5adf24483e2b7665 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 11:26:09 +0200 Subject: [PATCH 09/20] refactor(flags-core): own freshness age in the cache --- .../controller/datafile-cache-policy.test.ts | 177 ++++++++++++++---- .../src/controller/datafile-cache.test.ts | 172 ++++++++++++++++- .../src/controller/datafile-cache.ts | 101 ++++++---- .../src/controller/header-source.ts | 52 +++-- .../vercel-flags-core/src/controller/index.ts | 10 +- .../src/vercel-mode.black-box.test.ts | 11 +- 6 files changed, 408 insertions(+), 115 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index d391ea9cc..3a1e8f2ee 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DatafileInput } from '../types'; -import { type CacheReadPolicy, DatafileCache } from './datafile-cache'; +import { + type CacheReadPolicy, + DatafileCache, + Freshness, +} from './datafile-cache'; import { tagData } from './tagged-data'; function data(configUpdatedAt = 1): DatafileInput { @@ -40,35 +44,133 @@ afterEach(() => { }); describe('cache read callbacks', () => { + it.each( + Object.values(Freshness), + )('serves %s without fetching when fetch is omitted, subject to SIE', async (status) => { + const cache = new DatafileCache(0); + const original = tagData(data(), 'provided'); + cache.seed(original); + const policy = { getStatus: vi.fn(() => status) }; + expect(await cache.resolve(policy)).toEqual([ + original, + status === Freshness.Fresh ? 'HIT' : 'STALE', + ]); + const error = new Error('outage'); + cache.fail(error); + await expect(cache.resolve(policy)).rejects.toBe(error); + expect(policy.getStatus).toHaveBeenCalledTimes(2); + }); + + it('returns undefined without assessing an empty cache when fetch is omitted', async () => { + const cache = new DatafileCache(); + const getStatus = vi.fn(() => Freshness.Fresh); + expect(await cache.resolve({ getStatus })).toBeUndefined(); + expect(getStatus).not.toHaveBeenCalled(); + }); + it.each([ - true, - undefined, - ])('serves a %s assessment without revalidating or clearing a failure', async (fresh) => { + Freshness.Fresh, + Freshness.Unknown, + ])('serves a %s assessment without fetching or clearing a failure', async (status) => { const cache = new DatafileCache(0); const original = tagData(data(), 'provided'); cache.seed(original); const policy = { - isFresh: vi.fn(() => fresh), - isStale: vi.fn(() => true), - revalidate: vi.fn(async () => {}), + getStatus: vi.fn(() => status), + fetch: vi.fn(async () => {}), }; expect(await cache.resolve(policy)).toEqual([ original, - fresh ? 'HIT' : 'STALE', + status === Freshness.Fresh ? 'HIT' : 'STALE', ]); - expect(policy.isFresh).toHaveBeenCalledExactlyOnceWith({ + expect(policy.getStatus).toHaveBeenCalledExactlyOnceWith({ projectId: 'prj_policy', environment: 'production', configUpdatedAt: 1, revision: undefined, - fetchedAt: undefined, + ageMs: Infinity, }); const failure = new Error('outage'); cache.fail(failure); await expect(cache.resolve(policy)).rejects.toBe(failure); - expect(policy.isStale).not.toHaveBeenCalled(); - expect(policy.revalidate).not.toHaveBeenCalled(); + expect(policy.getStatus).toHaveBeenCalledTimes(2); + expect(policy.fetch).not.toHaveBeenCalled(); + }); + + it('blocks expired reads even when no failure exists', async () => { + const waitUntil = vi.fn(); + const cache = new DatafileCache(Infinity, waitUntil); + cache.seed(tagData(data(), 'provided')); + const pending = deferred(); + const fetch = vi.fn(async () => { + await pending.promise; + cache.updateFromSource(data(2), 'fetched'); + }); + const settled = vi.fn(); + const reading = cache + .resolve({ getStatus: () => Freshness.Expired, fetch }) + .then(settled); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); + expect(waitUntil).not.toHaveBeenCalled(); + pending.resolve(); + await reading; + expect(settled).toHaveBeenCalledExactlyOnceWith([cache.read(), 'MISS']); + expect(cache.read()?.configUpdatedAt).toBe(2); + }); + + it('keeps the first fetch failure and its inclusive deadline across later attempts', async () => { + const cache = new DatafileCache(100); + const original = tagData(data(), 'provided'); + cache.seed(original); + const firstError = new Error('first outage'); + const fetch = vi + .fn>() + .mockRejectedValueOnce(firstError) + .mockRejectedValue(new Error('later outage')); + const policy = { getStatus: () => Freshness.Expired, fetch }; + expect(await cache.resolve(policy)).toEqual([original, 'STALE']); + vi.setSystemTime(1_100); + expect(await cache.resolve(policy)).toEqual([original, 'STALE']); + vi.setSystemTime(1_101); + await expect(cache.resolve(policy)).rejects.toBe(firstError); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('normalizes non-Error failures retained by the cache', async () => { + const cache = new DatafileCache(0); + cache.seed(tagData(data(), 'provided')); + const fetch = vi.fn().mockRejectedValue('transport failed'); + await expect( + cache.resolve({ getStatus: () => Freshness.Expired, fetch }), + ).rejects.toThrow('Unknown fetch error'); + expect(() => cache.read()).toThrow('Unknown fetch error'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('keeps background fetching handled when waitUntil registration throws', async () => { + const waitUntil = vi.fn<(promise: Promise) => void>(() => { + throw new Error('registration failed'); + }); + const cache = new DatafileCache(0, waitUntil); + const original = tagData(data(), 'provided'); + cache.seed(original); + const failure = new Error('fetch failed'); + const fetch = vi.fn().mockRejectedValue(failure); + expect( + await cache.resolve({ getStatus: () => Freshness.Stale, fetch }), + ).toEqual([original, 'STALE']); + await waitUntil.mock.calls[0]?.[0]; + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + expect(fetch).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Revalidation failed:', + failure, + ); + errorSpy.mockClear(); + expect(() => cache.read()).toThrow(failure); }); it('shares a background refresh with a later blocking read', async () => { @@ -77,23 +179,23 @@ describe('cache read callbacks', () => { const original = tagData(data(), 'provided'); cache.seed(original); const pending = deferred(); - const revalidate = vi.fn(async () => { + const fetch = vi.fn(async () => { await pending.promise; cache.updateFromSource(data(2), 'fetched'); }); - const policy = { isFresh: () => false, isStale: () => true, revalidate }; + const policy = { getStatus: () => Freshness.Stale, fetch }; expect(await cache.resolve(policy)).toEqual([original, 'STALE']); expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); const settled = vi.fn(); const blocking = cache - .resolve({ ...policy, isStale: () => false }) + .resolve({ ...policy, getStatus: () => Freshness.Expired }) .then((result) => { settled(); return result; }); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); - expect(revalidate).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); + expect(fetch).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); pending.resolve(); expect(await blocking).toEqual([cache.read(), 'MISS']); @@ -106,10 +208,10 @@ describe('cache read callbacks', () => { const cache = new DatafileCache(0, waitUntil); cache.seed(tagData(data(), 'provided')); const failure = new Error('refresh failed'); - const revalidate = vi - .fn>() + const fetch = vi + .fn>() .mockRejectedValueOnce(failure); - const policy = { isFresh: () => false, isStale: () => true, revalidate }; + const policy = { getStatus: () => Freshness.Stale, fetch }; expect((await cache.resolve(policy))?.[1]).toBe('STALE'); await waitUntil.mock.calls[0]?.[0]; @@ -120,62 +222,61 @@ describe('cache read callbacks', () => { errorSpy.mockClear(); expect(() => cache.read()).toThrow(failure); - revalidate.mockImplementationOnce(async () => + fetch.mockImplementationOnce(async () => cache.updateFromSource(data(2), 'fetched'), ); expect((await cache.resolve(policy))?.[1]).toBe('MISS'); expect(cache.read()?.configUpdatedAt).toBe(2); - expect(revalidate).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(2); expect(waitUntil).toHaveBeenCalledTimes(1); }); - it('contains synchronous revalidation failures and permits a later retry', async () => { + it('contains synchronous fetch failures and permits a later retry', async () => { const cache = new DatafileCache(); const failure = new Error('synchronous failure'); - const revalidate = vi.fn>(() => { + const fetch = vi.fn>(() => { throw failure; }); - const policy = { isFresh: () => false, isStale: () => false, revalidate }; + const policy = { getStatus: () => Freshness.Expired, fetch }; await expect(cache.resolve(policy)).rejects.toBe(failure); - revalidate.mockImplementationOnce(async () => + fetch.mockImplementationOnce(async () => cache.updateFromSource(data(), 'fetched'), ); expect((await cache.resolve(policy))?.[1]).toBe('MISS'); - expect(revalidate).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(2); }); - it('cancels queued revalidation without invoking the callback', async () => { + it('cancels queued fetch without invoking the callback', async () => { const cache = new DatafileCache(); - const revalidate = vi.fn(async () => {}); + const fetch = vi.fn(async () => {}); const reading = cache.resolve({ - isFresh: () => false, - isStale: () => false, - revalidate, + getStatus: () => Freshness.Expired, + fetch, }); const outcome = expect(reading).rejects.toThrow(); cache.clear(); await outcome; - expect(revalidate).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); }); - it('does not let cancelled work fail or clear a newer revalidation', async () => { + it('does not let cancelled work fail or clear a newer fetch', async () => { const cache = new DatafileCache(0); cache.seed(tagData(data(), 'provided')); const oldPending = deferred(); const nextPending = deferred(); - const revalidate = vi - .fn>() + const fetch = vi + .fn>() .mockImplementationOnce(() => oldPending.promise) .mockImplementationOnce(async (signal) => { await nextPending.promise; signal.throwIfAborted(); cache.updateFromSource(data(2), 'fetched'); }); - const policy = { isFresh: () => false, isStale: () => false, revalidate }; + const policy = { getStatus: () => Freshness.Expired, fetch }; const oldRead = cache.resolve(policy); const cancelled = expect(oldRead).rejects.toThrow('cancelled transport'); await vi.advanceTimersByTimeAsync(0); - const oldSignal = revalidate.mock.calls[0]?.[0]; + const oldSignal = fetch.mock.calls[0]?.[0]; cache.clear(); cache.seed(tagData(data(), 'provided')); const nextRead = cache.resolve(policy); @@ -187,7 +288,7 @@ describe('cache read callbacks', () => { const sharedRead = cache.resolve(policy); await vi.advanceTimersByTimeAsync(0); - expect(revalidate).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(2); nextPending.resolve(); expect(await Promise.all([nextRead, sharedRead])).toEqual([ [cache.read(), 'MISS'], diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts index c85d2b386..a4f8a6edc 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts @@ -38,6 +38,151 @@ afterEach(() => { }); describe('DatafileCache', () => { + describe('freshness age', () => { + it.each([ + undefined, + 500, + ])('resets age without changing stored fetchedAt %s or data', (fetchedAt) => { + const cache = new DatafileCache(); + const original = Object.freeze({ ...data('bundled'), fetchedAt }); + cache.seed(original); + vi.setSystemTime(2_000); + cache.resetAge(); + expect(cache.ageMs).toBe(0); + expect(cache.read()).toBe(original); + expect(original.fetchedAt).toBe(fetchedAt); + expect(original._origin).toBe('bundled'); + vi.setSystemTime(2_100); + expect(cache.ageMs).toBe(100); + }); + + it.each([ + 0, 100, + ])('preserves the first failure and deadline when resetting age with SIE %s, including after expiry', (staleIfErrorMs) => { + const cache = new DatafileCache(staleIfErrorMs); + const original = Object.freeze({ ...data(), fetchedAt: 500 }); + cache.seed(original); + const firstError = new Error('first outage'); + cache.fail(firstError); + vi.setSystemTime(1_050); + cache.resetAge(); + expect(cache.ageMs).toBe(0); + if (staleIfErrorMs === 0) { + expect(() => cache.read()).toThrow(firstError); + } else { + expect(cache.read()).toBe(original); + } + + cache.fail(new Error('later outage')); + vi.setSystemTime(1_100); + cache.resetAge(); + expect(cache.ageMs).toBe(0); + if (staleIfErrorMs === 0) { + expect(() => cache.read()).toThrow(firstError); + } else { + expect(cache.read()).toBe(original); + } + + vi.setSystemTime(1_101); + expect(() => cache.read()).toThrow(firstError); + cache.resetAge(); + expect(cache.ageMs).toBe(0); + expect(() => cache.read()).toThrow(firstError); + expect(original.fetchedAt).toBe(500); + vi.setSystemTime(1_200); + expect(cache.ageMs).toBe(99); + expect(() => cache.read()).toThrow(firstError); + }); + + it('does not establish age while empty or make a later unknown-age seed fresh', () => { + const cache = new DatafileCache(); + cache.resetAge(); + expect(cache.ageMs).toBe(Infinity); + expect(cache.read()).toBeUndefined(); + vi.setSystemTime(1_100); + const original = Object.freeze(data()); + cache.seed(original); + expect(cache.ageMs).toBe(Infinity); + expect(cache.read()).toBe(original); + }); + + it.each([ + 0, 500, 1_000, 2_000, + ])('restores age from persisted fetchedAt %s without changing storage', (fetchedAt) => { + const cache = new DatafileCache(); + const original = Object.freeze({ ...data(), fetchedAt }); + cache.seed(original); + expect(cache.ageMs).toBe(Math.max(0, 1_000 - fetchedAt)); + expect(cache.read()).toBe(original); + + vi.setSystemTime(3_000); + expect(cache.ageMs).toBe(3_000 - fetchedAt); + expect(original.fetchedAt).toBe(fetchedAt); + }); + + it.each([ + undefined, + -1, + NaN, + Infinity, + -Infinity, + '500', + null, + ])('treats invalid or missing fetchedAt %s as unknown age', (fetchedAt) => { + const cache = new DatafileCache(); + cache.updateFromSource(response(), 'fetched'); + expect(cache.ageMs).toBe(0); + const original = Object.freeze({ ...data(), fetchedAt }) as TaggedData; + cache.seed(original); + expect(cache.ageMs).toBe(Infinity); + vi.setSystemTime(2_000); + expect(cache.ageMs).toBe(Infinity); + expect(cache.read()).toBe(original); + expect(original.fetchedAt).toBe(fetchedAt); + }); + + it.each([ + 'configUpdatedAt', + 'revision', + ] as const)('resets age on valid %s confirmation while retaining fetchedAt and origin', (version) => { + const cache = new DatafileCache(); + const original = Object.freeze({ + ...data('bundled'), + revision: 42, + fetchedAt: 500, + }); + cache.seed(original); + vi.setSystemTime(2_000); + expect(cache.ageMs).toBe(1_500); + expect(cache.tryConfirm(original, version)).toBe(true); + expect(cache.ageMs).toBe(0); + expect(cache.read()).toBe(original); + expect(original.fetchedAt).toBe(500); + expect(original._origin).toBe('bundled'); + vi.setSystemTime(2_100); + expect(cache.ageMs).toBe(100); + }); + + it('clears storage and age without clearing the first failure', () => { + const cache = new DatafileCache(100); + cache.updateFromSource(response(), 'fetched'); + const error = new Error('first outage'); + cache.fail(error); + vi.setSystemTime(1_050); + expect(cache.ageMs).toBe(50); + cache.clear(); + expect(cache.ageMs).toBe(Infinity); + expect(cache.read()).toBeUndefined(); + expect(cache.tryConfirm(response())).toBe(false); + expect(cache.ageMs).toBe(Infinity); + cache.seed(Object.freeze({ ...data(), fetchedAt: 1_050 })); + expect(cache.ageMs).toBe(0); + vi.setSystemTime(1_101); + expect(cache.ageMs).toBe(51); + expect(() => cache.read()).toThrow(error); + }); + }); + it.each([ 0, 100, @@ -48,6 +193,7 @@ describe('DatafileCache', () => { }); const cache = new DatafileCache(staleIfErrorMs); expect(cache.hasData).toBe(false); + expect(cache.ageMs).toBe(Infinity); expect(cache.revision).toBeUndefined(); expect(cache.read()).toBeUndefined(); @@ -230,18 +376,20 @@ describe('DatafileCache', () => { Partial, ][])('rejects %s without changing storage or the failure deadline', (_, overrides) => { const cache = new DatafileCache(100); - const original = data(); + const original = Object.freeze({ ...data(), fetchedAt: 500 }); cache.seed(original); const error = new Error('first outage'); cache.fail(error); vi.setSystemTime(1_050); expect(cache.tryConfirm(response(overrides))).toBe(false); + expect(cache.ageMs).toBe(550); expect(cache.read()).toBe(original); vi.setSystemTime(1_100); expect(cache.read()).toBe(original); vi.setSystemTime(1_101); expect(() => cache.read()).toThrow(error); + expect(cache.ageMs).toBe(601); }); it.each([ @@ -262,6 +410,7 @@ describe('DatafileCache', () => { expect(cache.tryConfirm(response())).toBe(false); expect(cache.tryConfirm(response({ configUpdatedAt }))).toBe(false); expect(cache.tryConfirm(original)).toBe(false); + expect(cache.ageMs).toBe(Infinity); expect(cache.read()).toBe(original); vi.setSystemTime(1_101); expect(() => cache.read()).toThrow(error); @@ -337,6 +486,7 @@ describe('DatafileCache', () => { expect(cache.tryConfirm(incoming)).toBe(false); expect(() => cache.read()).toThrow(firstError); expect(cache.tryConfirm(incoming, 'revision')).toBe(true); + expect(cache.ageMs).toBe(0); expect(cache.read()).toBe(original); expect(original._origin).toBe('bundled'); expect(incoming).toEqual({ @@ -368,7 +518,11 @@ describe('DatafileCache', () => { ['negative infinite revision', { revision: -Infinity }], ])('rejects %s without replacing data or changing the failure deadline', (_, overrides) => { const cache = new DatafileCache(100); - const original = Object.freeze({ ...data('bundled'), revision: 42 }); + const original = Object.freeze({ + ...data('bundled'), + revision: 42, + fetchedAt: 500, + }); cache.seed(original); // Network payloads can contain malformed revisions despite the static type. const incoming = Object.freeze({ @@ -383,6 +537,7 @@ describe('DatafileCache', () => { vi.setSystemTime(1_050); expect(cache.tryConfirm(incoming, 'revision')).toBe(false); + expect(cache.ageMs).toBe(550); expect(cache.read()).toBe(original); vi.setSystemTime(1_100); expect(cache.read()).toBe(original); @@ -390,9 +545,12 @@ describe('DatafileCache', () => { expect(() => cache.read()).toThrow(error); expect(cache.revision).toBe(42); expect(cache.tryConfirm(incoming, 'revision')).toBe(false); + expect(cache.ageMs).toBe(601); expect(() => cache.read()).toThrow(error); expect(cache.tryConfirm(original, 'revision')).toBe(true); + expect(cache.ageMs).toBe(0); + expect(original.fetchedAt).toBe(500); expect(cache.read()).toBe(original); }); @@ -419,6 +577,7 @@ describe('DatafileCache', () => { false, ); expect(cache.tryConfirm(original, 'revision')).toBe(false); + expect(cache.ageMs).toBe(Infinity); expect(cache.read()).toBe(original); vi.setSystemTime(1_101); expect(cache.tryConfirm(original, 'revision')).toBe(false); @@ -431,6 +590,7 @@ describe('DatafileCache', () => { it.each([ 'poll', 'stream', + 'fetched', ] as const)('accepts the first %s response and clears a failure recorded while empty', (origin) => { const cache = new DatafileCache(0); cache.fail(new Error('failed before data arrived')); @@ -439,6 +599,7 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, origin)).toBeUndefined(); + expect(cache.ageMs).toBe(0); expect(cache.read()).not.toBe(incoming); expect(cache.read()).toEqual({ ...snapshot, @@ -480,6 +641,7 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, 'poll')).toBeUndefined(); + expect(cache.ageMs).toBe(0); const accepted = cache.read(); expect(accepted).not.toBe(incoming); expect(accepted).toEqual({ @@ -493,6 +655,7 @@ describe('DatafileCache', () => { const nextError = new Error('second outage'); cache.fail(nextError); vi.setSystemTime(2_100); + expect(cache.ageMs).toBe(100); expect(cache.read()).toBe(accepted); vi.setSystemTime(2_101); expect(() => cache.read()).toThrow(nextError); @@ -529,6 +692,7 @@ describe('DatafileCache', () => { const original = Object.freeze({ ...data('bundled'), configUpdatedAt: current, + fetchedAt: 500, }); cache.seed(original); const incoming = Object.freeze(response({ configUpdatedAt: next })); @@ -539,6 +703,8 @@ describe('DatafileCache', () => { expect(() => cache.read()).toThrow(error); expect(cache.updateFromSource(incoming, 'poll')).toBeUndefined(); + expect(cache.ageMs).toBe(0); + expect(original.fetchedAt).toBe(500); expect(cache.read()).toBe(original); expect(original._origin).toBe('bundled'); expect(incoming).toEqual(snapshot); @@ -671,6 +837,7 @@ describe('DatafileCache', () => { vi.setSystemTime(1_050); cache.updateFromSource(oldResponse, 'stream'); + expect(cache.ageMs).toBe(50); if (staleIfErrorMs === 0) { expect(() => cache.read()).toThrow(error); } else { @@ -686,6 +853,7 @@ describe('DatafileCache', () => { vi.setSystemTime(1_101); expect(() => cache.read()).toThrow(error); expect(cache.tryConfirm(replacement)).toBe(true); + expect(cache.ageMs).toBe(0); expect(cache.read()).toBe(accepted); }); }); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index f6840f2ab..d5ea96369 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -6,18 +6,23 @@ type Confirmation = Pick< 'configUpdatedAt' | 'revision' | 'projectId' | 'environment' >; -export type CacheMetadata = Confirmation & Pick; +export type CacheMetadata = Confirmation & { ageMs: number }; -type Revalidate = (signal: AbortSignal) => Promise; +export enum Freshness { + Fresh = 'fresh', + Stale = 'stale', + Expired = 'expired', + Unknown = 'unknown', +} + +type Fetch = (signal: AbortSignal) => Promise; type CacheResult = [TaggedData, Metrics['cacheStatus']]; export type CacheReadPolicy = { - /** Undefined adds no freshness evidence and keeps cached-read behavior. */ - isFresh: (data: CacheMetadata) => boolean | undefined; - /** Whether stale data may be served while revalidation runs. */ - isStale: (data: CacheMetadata) => boolean; + /** Unknown adds no freshness evidence and keeps cached-read behavior. */ + getStatus: (data: CacheMetadata) => Freshness; /** Omit for modes whose stream/poll loop already maintains the cache. */ - revalidate?: Revalidate; + fetch?: Fetch; }; /** @@ -33,13 +38,14 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { return undefined; } -/** Storage, serving policy, and revalidation driven by source callbacks. */ +/** Storage, serving policy, and fetching driven by source callbacks. */ export class DatafileCache { private data: TaggedData | undefined; + private freshAt: number | undefined; private failure: { error: Error; startedAt: number } | undefined; private abortController = new AbortController(); - private revalidation: Promise | undefined; + private fetching: Promise | undefined; constructor( private readonly staleIfErrorMs = Infinity, @@ -55,23 +61,47 @@ export class DatafileCache { return this.data?.revision; } + /** Time since the latest freshness evidence, if known. */ + get ageMs(): number { + return this.freshAt === undefined + ? Infinity + : Math.max(0, Date.now() - this.freshAt); + } + + /** Records freshness evidence without confirming recovery from a failure. */ + resetAge(): void { + if (this.data) this.freshAt = Date.now(); + } + /** Freshness checks can inspect retained metadata even after serving expires. */ private get metadata(): CacheMetadata | undefined { if (!this.data) return undefined; - const { projectId, environment, configUpdatedAt, revision, fetchedAt } = - this.data; - return { projectId, environment, configUpdatedAt, revision, fetchedAt }; + const { projectId, environment, configUpdatedAt, revision } = this.data; + return { + projectId, + environment, + configUpdatedAt, + revision, + ageMs: this.ageMs, + }; } /** Stores initial or fallback data without confirming recovery from a failure. */ seed(data: TaggedData): void { this.data = data; + this.freshAt = + typeof data.fetchedAt === 'number' && + Number.isFinite(data.fetchedAt) && + data.fetchedAt >= 0 + ? data.fetchedAt + : undefined; } /** Accepts a source update or confirms the current version without replacing it. */ updateFromSource(incoming: DatafileInput, origin: DataOrigin): void { if (this.isNewerData(incoming)) { this.data = tagData(incoming, origin); + this.resetAge(); this.failure = undefined; return; } @@ -103,6 +133,7 @@ export class DatafileCache { return false; } + this.resetAge(); this.failure = undefined; return true; } @@ -143,19 +174,23 @@ export class DatafileCache { async resolve(policy: CacheReadPolicy): Promise { const metadata = this.metadata; if (metadata) { - const fresh = policy.isFresh(metadata); - if (fresh !== false || !policy.revalidate) { - return [this.read()!, fresh ? 'HIT' : 'STALE']; + const status = policy.getStatus(metadata); + if ( + status === Freshness.Fresh || + status === Freshness.Unknown || + !policy.fetch + ) { + return [this.read()!, status === Freshness.Fresh ? 'HIT' : 'STALE']; } - if (policy.isStale(metadata) && this.canServe()) { + if (status === Freshness.Stale && this.canServe()) { const stale = this.read()!; - this.revalidateInBackground(policy.revalidate); + this.fetchInBackground(policy.fetch); return [stale, 'STALE']; } } - if (!policy.revalidate) return; - const { promise, signal } = this.startRevalidation(policy.revalidate); + if (!policy.fetch) return; + const { promise, signal } = this.startFetch(policy.fetch); try { await promise; signal.throwIfAborted(); @@ -167,46 +202,41 @@ export class DatafileCache { } // A cold fetch discovers the project; assess the original request's header. - if (!metadata && this.metadata) policy.isFresh(this.metadata); + if (!metadata && this.metadata) policy.getStatus(this.metadata); const data = this.read(); if (!data) - throw new Error( - '@vercel/flags-core: Revalidation returned no definitions', - ); + throw new Error('@vercel/flags-core: Fetch returned no definitions'); return [data, 'MISS']; } - private startRevalidation(revalidate: Revalidate) { + private startFetch(fetch: Fetch) { const { signal } = this.abortController; - if (this.revalidation) return { promise: this.revalidation, signal }; + if (this.fetching) return { promise: this.fetching, signal }; const promise = Promise.resolve() .then(() => { signal.throwIfAborted(); - return revalidate(signal); + return fetch(signal); }) .then(() => signal.throwIfAborted()) .catch((error) => { if (!signal.aborted) { this.fail( - error instanceof Error - ? error - : new Error('Unknown revalidation error'), + error instanceof Error ? error : new Error('Unknown fetch error'), ); } throw error; }) .finally(() => { // An old, aborted operation must not clear a newer one. - if (this.abortController.signal === signal) - this.revalidation = undefined; + if (this.abortController.signal === signal) this.fetching = undefined; }); - this.revalidation = promise; + this.fetching = promise; return { promise, signal }; } - private revalidateInBackground(revalidate: Revalidate): void { - const { promise, signal } = this.startRevalidation(revalidate); + private fetchInBackground(fetch: Fetch): void { + const { promise, signal } = this.startFetch(fetch); const background = promise.catch((error) => { if (!signal.aborted) { console.error('@vercel/flags-core: Revalidation failed:', error); @@ -222,7 +252,8 @@ export class DatafileCache { clear(): void { this.abortController.abort(); this.abortController = new AbortController(); - this.revalidation = undefined; + this.fetching = undefined; this.data = undefined; + this.freshAt = undefined; } } diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 4868af7d0..78d29b013 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,6 +1,10 @@ import type { DatafileInput } from '../types'; import { getRequestContext } from '../utils/request-context'; -import type { CacheMetadata, CacheReadPolicy } from './datafile-cache'; +import { + type CacheMetadata, + type CacheReadPolicy, + Freshness, +} from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; import { TypedEmitter } from './typed-emitter'; @@ -13,19 +17,18 @@ export type HeaderSourceEvents = { /** Request version evidence and fetching; the cache decides how to serve reads. */ export class HeaderSource extends TypedEmitter { private highestObserved = 0; - private lastSeen: { version: number; at: number } | undefined; constructor(private readonly options: NormalizedOptions) { super(); } /** Capture this request's header before any cold-cache fetch awaits. */ - getFreshnessCheck(): CacheReadPolicy['isFresh'] { + getStatusCheck(): CacheReadPolicy['getStatus'] { const { headers } = getRequestContext(); const header = headers?.['x-vercel-flags-config-versions'] ?? headers?.['flags-config-versions']; - return (data) => this.isFresh(data, header); + return (data) => this.getStatus(data, header); } private getUpdatedAtHeader(projectId: string, header: string | undefined) { @@ -41,41 +44,29 @@ export class HeaderSource extends TypedEmitter { return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; } - private isFresh( + private getStatus( data: CacheMetadata, header: string | undefined, - ): boolean | undefined { - const version = this.getUpdatedAtHeader(data.projectId, header); - if (!version) return; + ): Freshness { + const headerTs = this.getUpdatedAtHeader(data.projectId, header); + if (headerTs === undefined) return Freshness.Unknown; - const currentVersion = Number(data.configUpdatedAt); - this.highestObserved = Math.max(this.highestObserved, version); + const currentTs = Number(data.configUpdatedAt); + this.highestObserved = Math.max(this.highestObserved, headerTs); + if (!Number.isFinite(currentTs) || currentTs <= 0) return Freshness.Unknown; // An older matching request cannot undo a newer request's invalidation. - if (version === currentVersion && version === this.highestObserved) { - this.lastSeen = { version, at: Date.now() }; + if (headerTs === currentTs && headerTs === this.highestObserved) { this.emit('confirmed', data); } - if (!data.configUpdatedAt) return; - return version <= currentVersion; - } - - /** Whether this version is still inside its background-refresh window. */ - isStale = (data: CacheMetadata): boolean => { - const freshAt = Math.max( - data.fetchedAt ?? -Infinity, - this.lastSeen?.version === Number(data.configUpdatedAt) - ? this.lastSeen.at - : -Infinity, - ); + if (headerTs <= currentTs) return Freshness.Fresh; const { staleWhileRevalidateMs } = this.options; - return ( - staleWhileRevalidateMs > 0 && - Date.now() - freshAt <= staleWhileRevalidateMs - ); - }; + return staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs + ? Freshness.Stale + : Freshness.Expired; + } - revalidate = async (signal: AbortSignal): Promise => { + fetch = async (signal: AbortSignal): Promise => { const data = await fetchDatafile({ ...this.options, signal }); // Transports can finish after cancellation; never publish that response. signal.throwIfAborted(); @@ -91,7 +82,6 @@ export class HeaderSource extends TypedEmitter { } stop(): void { - this.lastSeen = undefined; this.highestObserved = 0; } } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 8c1ccc104..c1b1d4b1d 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -14,6 +14,7 @@ import { type CacheMetadata, type CacheReadPolicy, DatafileCache, + Freshness, } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; @@ -465,16 +466,15 @@ export class Controller implements ControllerInterface { private getCacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { - isFresh: this.headerSource.getFreshnessCheck(), - isStale: this.headerSource.isStale, - revalidate: this.headerSource.revalidate, + getStatus: this.headerSource.getStatusCheck(), + fetch: this.headerSource.fetch, }; } // Stream/poll maintain their existing schedules; reads do not trigger I/O. return { - isFresh: () => this.isConnected, - isStale: () => true, + getStatus: () => + this.isConnected ? Freshness.Fresh : Freshness.Stale, }; } diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index ea49e130a..f7db8fcaa 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -895,7 +895,7 @@ describe('Vercel mode (black-box)', () => { it.each([ 0, -1, - ])('ignores a background response with version delta %i without extending freshness', async (delta) => { + ])('retains a background response with version delta %i and renews age only on confirmation', async (delta) => { const instance = client(); await instance.evaluate('feature'); vi.setSystemTime(TIMESTAMP + 9_000); @@ -913,9 +913,11 @@ describe('Vercel mode (black-box)', () => { vi.setSystemTime(TIMESTAMP + 10_001); mockDatafileResponse(TIMESTAMP + 1, true); expect(await instance.evaluate('feature')).toMatchObject({ - value: true, - metrics: { cacheStatus: 'MISS' }, + value: delta !== 0, + metrics: { cacheStatus: delta === 0 ? 'STALE' : 'MISS' }, }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); expect(dataFetch).toHaveBeenCalledTimes(2); }); @@ -967,8 +969,9 @@ describe('Vercel mode (black-box)', () => { mockDatafileResponse(TIMESTAMP + 2, true); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'MISS', + delta === 0 ? 'STALE' : 'MISS', ); + await vi.advanceTimersByTimeAsync(0); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); expect(dataFetch).toHaveBeenCalledTimes(3); }); From 969205ae710ebdbf4583a36015bf26a73fa8f51e Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 11:30:51 +0200 Subject: [PATCH 10/20] feat(flags-core): assess stream and polling freshness by age --- .../vercel-flags-core/src/controller/index.ts | 31 ++-- .../src/controller/polling-source.ts | 4 + .../src/controller/stream-connection.ts | 4 +- .../src/controller/stream-source.ts | 6 + .../src/stale-if-error.test.ts | 163 +++++++++++++++--- .../src/stream-stale-if-error.test.ts | 65 ++++++- 6 files changed, 234 insertions(+), 39 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index c1b1d4b1d..d94284e79 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -161,6 +161,9 @@ export class Controller implements ControllerInterface { this.transition('streaming'); } }; + private onStreamPing = () => { + this.cache.resetAge(); + }; private onStreamConnected = () => { if (this.state === 'degraded' || this.state === 'initializing:stream') { this.transition('streaming'); @@ -184,10 +187,6 @@ export class Controller implements ControllerInterface { private onHeaderConfirmed = (data: CacheMetadata) => { this.cache.tryConfirm(data); }; - private onPollError = (error: Error) => { - this.cache.fail(error); - console.error('@vercel/flags-core: Poll failed:', error); - }; // --------------------------------------------------------------------------- // Source event wiring @@ -196,11 +195,12 @@ export class Controller implements ControllerInterface { private wireSourceEvents(): void { this.streamSource.on('data', this.onStreamData); this.streamSource.on('primed', this.onStreamPrimed); + this.streamSource.on('ping', this.onStreamPing); this.streamSource.on('connected', this.onStreamConnected); this.streamSource.on('disconnected', this.onStreamDisconnected); this.streamSource.on('error', this.onSourceError); this.pollingSource.on('data', this.onPollData); - this.pollingSource.on('error', this.onPollError); + this.pollingSource.on('error', this.onSourceError); this.headerSource.on('data', this.onHeaderData); this.headerSource.on('confirmed', this.onHeaderConfirmed); } @@ -208,11 +208,12 @@ export class Controller implements ControllerInterface { private unwireSourceEvents(): void { this.streamSource.off('data', this.onStreamData); this.streamSource.off('primed', this.onStreamPrimed); + this.streamSource.off('ping', this.onStreamPing); this.streamSource.off('connected', this.onStreamConnected); this.streamSource.off('disconnected', this.onStreamDisconnected); this.streamSource.off('error', this.onSourceError); this.pollingSource.off('data', this.onPollData); - this.pollingSource.off('error', this.onPollError); + this.pollingSource.off('error', this.onSourceError); this.headerSource.off('data', this.onHeaderData); this.headerSource.off('confirmed', this.onHeaderConfirmed); } @@ -389,7 +390,10 @@ export class Controller implements ControllerInterface { if (this.options.buildStep) { [result, cacheStatus] = await this.resolveDataForBuildStep(); } else if (result) { - cacheStatus = this.isConnected ? 'HIT' : 'STALE'; + const status = this.getBackgroundSourceStatus({ + ageMs: this.cache.ageMs, + }); + cacheStatus = status === Freshness.Fresh ? 'HIT' : 'STALE'; } else { // Preserve snapshot loading without starting stream/poll initialization. const bundled = await this.bundledSource.tryLoad(); @@ -472,12 +476,17 @@ export class Controller implements ControllerInterface { } // Stream/poll maintain their existing schedules; reads do not trigger I/O. - return { - getStatus: () => - this.isConnected ? Freshness.Fresh : Freshness.Stale, - }; + return { getStatus: this.getBackgroundSourceStatus }; } + private getBackgroundSourceStatus = (data: Pick) => { + if (this.isConnected) return this.streamSource.getStatus(data); + if (this.state === 'polling' || this.state === 'initializing:polling') { + return this.pollingSource.getStatus(data); + } + return Freshness.Unknown; + }; + // --------------------------------------------------------------------------- // Stream initialization // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index e39cd1915..1c4efd7d2 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,5 +1,6 @@ import type { DatafileInput } from '../types'; import type { Auth } from './auth'; +import { type CacheMetadata, Freshness } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { TypedEmitter } from './typed-emitter'; @@ -31,6 +32,9 @@ export class PollingSource extends TypedEmitter { this.config = config; } + getStatus = ({ ageMs }: Pick): Freshness => + ageMs <= this.config.polling.intervalMs ? Freshness.Fresh : Freshness.Stale; + /** * Perform a single poll request. * Emits 'data' on success, 'error' on failure. diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 99e029689..094519e5c 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -46,6 +46,7 @@ class TokenResolutionError extends Error { export type StreamCallbacks = { onDatafile: (data: BundledDefinitions) => void; onPrimed?: (message: PrimedMessage) => void; + onPing?: () => void; onDisconnect?: () => void; onError?: (error: Error) => void; }; @@ -69,7 +70,7 @@ export async function connectStream( callbacks: StreamCallbacks, ): Promise { const { host, abortController, fetch: fetchFn = globalThis.fetch } = config; - const { onDatafile, onPrimed, onDisconnect, onError } = callbacks; + const { onDatafile, onPrimed, onPing, onDisconnect, onError } = callbacks; let retryCount = 0; let lastAttemptTime = 0; @@ -244,6 +245,7 @@ export async function connectStream( // Pings prove the connection is alive — reset retry count // once initial data has been received if (message.type === 'ping' && initialDataReceived) { + onPing?.(); retryCount = 0; resetPingTimeout(); } diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index c2e7e697e..8a6844139 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,4 +1,5 @@ import type { DatafileInput } from '../types'; +import { type CacheMetadata, Freshness } from './datafile-cache'; import type { NormalizedOptions } from './normalized-options'; import { connectStream, type PrimedMessage } from './stream-connection'; import { TypedEmitter } from './typed-emitter'; @@ -6,6 +7,7 @@ import { TypedEmitter } from './typed-emitter'; export type StreamSourceEvents = { data: (data: DatafileInput) => void; primed: (message: PrimedMessage) => void; + ping: () => void; connected: () => void; disconnected: () => void; error: (error: Error) => void; @@ -27,6 +29,9 @@ export class StreamSource extends TypedEmitter { this.revision = revision; } + getStatus = ({ ageMs }: Pick): Freshness => + ageMs <= 30_000 ? Freshness.Fresh : Freshness.Stale; + /** * Start the stream connection. * Returns a promise that resolves when the first datafile or primed message arrives. @@ -70,6 +75,7 @@ export class StreamSource extends TypedEmitter { this.emit('primed', message); this.emit('connected'); }, + onPing: () => this.emit('ping'), onDisconnect: () => { this.emit('disconnected'); }, diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index 7a83ccca8..cd65a7baf 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -63,13 +63,6 @@ function client(options: CreateClientOptions = {}): FlagsClient { return result; } -function expectErrors(...errors: Error[]) { - expect(errorSpy.mock.calls).toEqual( - errors.map((error) => ['@vercel/flags-core: Poll failed:', error]), - ); - errorSpy.mockClear(); -} - beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -119,9 +112,11 @@ describe('polling stale-if-error through the public API', () => { const failure = new Error('offline'); poll.mockRejectedValue(failure); await vi.advanceTimersByTimeAsync(90_000); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); expect(poll).toHaveBeenCalledTimes(4); - expectErrors(failure, failure, failure); }); it('includes the finite deadline, preserves the first error, and uses existing error/default/bulk conventions', async () => { @@ -131,7 +126,7 @@ describe('polling stale-if-error through the public API', () => { readMs: 0, evaluationMs: 0, source: 'in-memory', - cacheStatus: 'STALE', + cacheStatus: 'HIT', connectionState: 'disconnected', mode: 'polling', }); @@ -142,7 +137,10 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(30_000); expect(await instance.evaluate('flagA')).toEqual(initial); await vi.advanceTimersByTimeAsync(30_000); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); await vi.advanceTimersByTimeAsync(1); await expect(instance.evaluate('flagA')).rejects.toBe(first); const fallback = { @@ -169,7 +167,6 @@ describe('polling stale-if-error through the public API', () => { expect(retained).toEqual(snapshot); expect(retained.definitions).toBe(snapshot.definitions); expect(poll).toHaveBeenCalledTimes(4); - expectErrors(first, repeated); }); it('does not expire healthy data between polls or start a poll from reads', async () => { @@ -184,6 +181,121 @@ describe('polling stale-if-error through the public API', () => { expect(poll).toHaveBeenCalledTimes(2); }); + it('marks data stale after the polling interval and resets age on an equal response', async () => { + const instance = client({ + polling: { intervalMs: 45_000, initTimeoutMs: 3_000 }, + }); + const initial = await instance.evaluate('flagA'); + const snapshot = await instance.getDatafile(); + const pending = deferred(); + poll.mockReturnValueOnce(pending.promise); + + await vi.advanceTimersByTimeAsync(45_000); + expect(await instance.evaluate('flagA')).toEqual(initial); + expect((await instance.getDatafile()).metrics.cacheStatus).toBe('HIT'); + await vi.advanceTimersByTimeAsync(1); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); + expect((await instance.getDatafile()).metrics.cacheStatus).toBe('STALE'); + expect(poll).toHaveBeenCalledTimes(2); + + pending.resolve(response(data())); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('flagA')).toEqual(initial); + const confirmed = await instance.getDatafile(); + expect(confirmed).toEqual(snapshot); + expect(confirmed.definitions).toBe(snapshot.definitions); + expect(confirmed.fetchedAt).toBe(0); + expect(poll).toHaveBeenCalledTimes(2); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('tracks %s freshness through equal polls without replacing the snapshot or changing lifecycle', async (seed) => { + vi.setSystemTime(100_000); + const supplied = Object.freeze(data({ fetchedAt: 1_000 })); + if (seed === 'bundled') { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: supplied, + state: 'ok', + }); + } + const instance = client({ + polling: { intervalMs: 45_000, initTimeoutMs: 3_000 }, + ...(seed === 'provided' ? { datafile: supplied } : {}), + }); + const initial = await instance.evaluate('flagA'); + expect(initial.metrics).toEqual({ + readMs: 0, + evaluationMs: 0, + source: seed === 'provided' ? 'in-memory' : 'embedded', + cacheStatus: 'HIT', + connectionState: 'disconnected', + mode: 'offline', // Preserve the existing initialization lifecycle. + }); + const snapshot = await instance.getDatafile(); + expect(snapshot.fetchedAt).toBe(1_000); + expect(snapshot.definitions).toBe(supplied.definitions); + expect(snapshot.metrics.cacheStatus).toBe('HIT'); + expect(poll).toHaveBeenCalledTimes(1); + const pending = deferred(); + poll.mockReturnValueOnce(pending.promise); + + await vi.advanceTimersByTimeAsync(44_999); + expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.getDatafile()).toEqual(snapshot); + expect(poll).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.getDatafile()).toEqual(snapshot); + expect(poll).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); + expect(await instance.getDatafile()).toEqual({ + ...snapshot, + metrics: { ...snapshot.metrics, cacheStatus: 'STALE' }, + }); + expect(poll).toHaveBeenCalledTimes(2); + + pending.resolve(response(data())); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('flagA')).toEqual(initial); + const confirmed = await instance.getDatafile(); + expect(confirmed).toEqual(snapshot); + expect(confirmed.definitions).toBe(supplied.definitions); + expect(confirmed.segments).toBe(supplied.segments); + expect(confirmed.fetchedAt).toBe(1_000); + expect(poll).toHaveBeenCalledTimes(2); + }); + + it.each([ + { configUpdatedAt: 9 }, + { projectId: 'other' }, + { environment: 'preview' }, + ])('does not renew polling freshness for a rejected response %j', async (override) => { + const instance = client({ + polling: { intervalMs: 45_000, initTimeoutMs: 3_000 }, + }); + await instance.evaluate('flagA'); + const snapshot = await instance.getDatafile(); + poll.mockResolvedValueOnce(response(data(override))); + await vi.advanceTimersByTimeAsync(45_001); + expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(await instance.getDatafile()).toEqual({ + ...snapshot, + metrics: { ...snapshot.metrics, cacheStatus: 'STALE' }, + }); + expect(poll).toHaveBeenCalledTimes(2); + }); + it('accepts fractional seconds and expires just after the inclusive millisecond deadline', async () => { const instance = client({ staleIfError: 0.25 }); const initial = await instance.evaluate('flagA'); @@ -191,14 +303,19 @@ describe('polling stale-if-error through the public API', () => { poll.mockRejectedValueOnce(failure); await vi.advanceTimersByTimeAsync(30_000); await vi.advanceTimersByTimeAsync(249); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); await vi.advanceTimersByTimeAsync(1); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); await vi.advanceTimersByTimeAsync(1); await expect(instance.evaluate('flagA')).rejects.toBe(failure); await expect(instance.getDatafile()).rejects.toBe(failure); expect(poll).toHaveBeenCalledTimes(2); - expectErrors(failure); }); it.each([ @@ -232,7 +349,6 @@ describe('polling stale-if-error through the public API', () => { snapshot.definitions, ); expect(poll).toHaveBeenCalledTimes(2); - expectErrors(failure); }); it.each([ @@ -264,7 +380,6 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(1); await expect(instance.evaluate('flagA')).rejects.toBe(failure); expect(poll).toHaveBeenCalledTimes(4); - expectErrors(failure, failure); }); it('recovers when polling reuses the same bundled object without changing its embedded origin', async () => { @@ -280,7 +395,7 @@ describe('polling stale-if-error through the public API', () => { readMs: 0, evaluationMs: 0, source: 'embedded', - cacheStatus: 'STALE', + cacheStatus: 'HIT', connectionState: 'disconnected', mode: 'offline', // Bundled initialization retains the existing lifecycle state. }); @@ -302,7 +417,6 @@ describe('polling stale-if-error through the public API', () => { expect(retained.definitions).toBe(supplied.definitions); expect(retained.segments).toBe(supplied.segments); expect(poll).toHaveBeenCalledTimes(3); - expectErrors(failure); }); it.each([ @@ -329,7 +443,6 @@ describe('polling stale-if-error through the public API', () => { snapshot.definitions, ); expect(poll).toHaveBeenCalledTimes(4); - expectErrors(failure); }); it.each([ @@ -344,7 +457,6 @@ describe('polling stale-if-error through the public API', () => { poll.mockRejectedValueOnce(failure); await vi.advanceTimersByTimeAsync(60_000); await expect(instance.evaluate('flagA')).rejects.toBe(failure); - expectErrors(failure); }); it('retains main acceptance of a newer mismatched identity and positive Infinity', async () => { @@ -359,7 +471,6 @@ describe('polling stale-if-error through the public API', () => { expect((await instance.getDatafile()).definitions).toBe( accepted.definitions, ); - expectErrors(failure); }); it('does not start SIE at initialization timeout; a late actual error does', async () => { @@ -383,7 +494,6 @@ describe('polling stale-if-error through the public API', () => { await expect(instance.evaluate('flagA')).rejects.toBe(failure); await vi.advanceTimersByTimeAsync(60_000); expect(poll).toHaveBeenCalledTimes(1); // Main starts no interval after this timeout. - expectErrors(failure); }); it.each([ @@ -411,7 +521,6 @@ describe('polling stale-if-error through the public API', () => { await expect(instance.evaluate('flagA')).rejects.toBe(failure); await expect(instance.getDatafile()).rejects.toBe(failure); expect(poll).toHaveBeenCalledTimes(2); - expectErrors(failure); }); it('observes overlapping polls in completion order without coalescing', async () => { @@ -442,7 +551,6 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(0); await expect(instance.evaluate('flagA')).rejects.toBe(secondFailure); expect(poll).toHaveBeenCalledTimes(5); - expectErrors(failure, secondFailure); }); it('keeps healthy streaming data with zero even when polling is configured', async () => { @@ -460,7 +568,10 @@ describe('polling stale-if-error through the public API', () => { const initial = await instance.evaluate('flagA'); expect(initial.metrics?.mode).toBe('streaming'); await vi.advanceTimersByTimeAsync(60_000); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(poll).not.toHaveBeenCalled(); }); diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index 7d26c7ef8..04e9305f2 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -155,6 +155,61 @@ afterEach(async () => { }); describe('stream stale-if-error through the public API', () => { + it.each([ + 'ping', + 'primed', + ] as const)('resets stream freshness on %s without changing the fetched snapshot', async (type) => { + const { instance, stream } = await start({ staleIfError: 0 }); + const initial = await instance.evaluate('flagA'); + const snapshot = await instance.getDatafile(); + await vi.advanceTimersByTimeAsync(30_000); + expect(await instance.evaluate('flagA')).toEqual(initial); + expect((await instance.getDatafile()).metrics.cacheStatus).toBe('HIT'); + await vi.advanceTimersByTimeAsync(1); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); + expect((await instance.getDatafile()).metrics.cacheStatus).toBe('STALE'); + + stream.push(type === 'ping' ? { type } : primed()); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('flagA')).toEqual(initial); + const confirmed = await instance.getDatafile(); + expect(confirmed).toEqual(snapshot); + expect(confirmed.definitions).toBe(snapshot.definitions); + expect(confirmed.fetchedAt).toBe(0); + await vi.advanceTimersByTimeAsync(30_000); + expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe('HIT'); + await vi.advanceTimersByTimeAsync(1); + expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expectRequests(['0']); + }); + + it('does not renew stream freshness on an invalid confirmation', async () => { + const { instance, stream } = await start(); + const snapshot = await instance.getDatafile(); + await vi.advanceTimersByTimeAsync(30_001); + for (const override of [ + { revision: 6 }, + { projectId: 'other' }, + { environment: 'preview' }, + ]) { + stream.push(primed(override)); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(await instance.getDatafile()).toEqual({ + ...snapshot, + metrics: { ...snapshot.metrics, cacheStatus: 'STALE' }, + }); + } + expectRequests(['0']); + }); + it.each([ undefined, Infinity, @@ -195,14 +250,22 @@ describe('stream stale-if-error through the public API', () => { // These messages emit connected but neither confirms the cached snapshot. reconnect.push(primed({ revision: 6 })); reconnect.push({ type: 'datafile', data: data({ configUpdatedAt: 9 }) }); + reconnect.push({ type: 'ping' }); await vi.advanceTimersByTimeAsync(1_000); expect(await instance.evaluate('flagA')).toMatchObject({ value: true, - metrics: { mode: 'streaming', connectionState: 'connected' }, + metrics: { + mode: 'streaming', + connectionState: 'connected', + cacheStatus: 'HIT', + }, }); expect((await instance.getDatafile()).configUpdatedAt).toBe(10); await vi.advanceTimersByTimeAsync(1); await expectExpired(instance, first); + reconnect.push({ type: 'ping' }); + await vi.advanceTimersByTimeAsync(0); + await expectExpired(instance, first); const fallback = { value: false, variantId: null, From 288f8040a7d98d72c70342a4e1e14513d0a52e2b Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 11:31:31 +0200 Subject: [PATCH 11/20] test(flags-core): cover and document header freshness confirmations --- .changeset/header-driven-vercel-mode.md | 2 +- packages/vercel-flags-core/CLAUDE.md | 26 +- packages/vercel-flags-core/README.md | 14 +- .../controller/datafile-cache-policy.test.ts | 279 ++++++++++++++++++ 4 files changed, 304 insertions(+), 17 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 2c3975dac..745bc9ff2 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -4,6 +4,6 @@ Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved. -The controller supplies source freshness, staleness, and revalidation callbacks to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. Streaming/polling retain their existing schedules. +The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age without clearing or extending a stale-if-error failure. Source schedules stay unchanged. Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 45707bb61..637e7383e 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -118,12 +118,12 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu **Vercel runtime** (`vercel: true`, default when `VERCEL=1`): - Load provided or bundled definitions during initialization, then select Vercel mode. - Do not start stream/poll; the first read fetches if the cache is empty. -- HeaderSource parses the request's project version and owns `highestObserved` and `lastSeen`. +- HeaderSource parses the request's project version and owns `highestObserved`. The cache owns freshness age. - A matching header confirms freshness only when no newer version has been observed. -- The controller passes `isFresh`, `isStale`, and `revalidate` callbacks to `cache.resolve()`. +- The controller passes `getStatus` and `fetch` callbacks to `cache.resolve()`. The cache selects cached/background/blocking behavior and shares refresh work. - A newer header permits background refresh within `staleWhileRevalidate` seconds of the - latest accepted fetch or matching header; unknown/expired freshness blocks for refresh. + latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. - Missing/malformed headers use cached data without fetching, subject to stale-if-error. @@ -250,9 +250,9 @@ When updating tests for new behavior, preserve the strength of existing assertio ### Stream Connection - Uses fetch with streaming body (NDJSON format) -- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onDisconnect`, and `onError` (failure evidence for cache policy) +- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onPing` (age-only reset), `onDisconnect`, and `onError` (failure evidence for cache policy) - Sends `X-Revision` header with the current revision number on every connection (including reconnects), allowing the server to respond with a lightweight `primed` message instead of a full datafile when the revision is current -- The `primed` message confirms the client's data is up-to-date; it resolves the init promise (like `datafile`) but does not update data — only transitions state to `streaming` +- The `primed` message confirms the client's data is up-to-date; it resolves the init promise (like `datafile`) but does not update data — resets cache age and clears a failure when revision/identity match, then transitions state to `streaming` - Reconnects with exponential backoff (base: 1s, max: 60s, max retries: 15) - Retries on transient errors both before and after initial data is received. Before initial data, retries continue until max retries are exhausted or the abort controller is aborted (e.g., by the Controller's init timeout). The init promise rejects when the loop exits without data. - Default `initTimeoutMs`: 3000ms @@ -280,8 +280,9 @@ The Controller selects the origin. Initial/fallback snapshots are tagged before `tagData` returns a shallow copy. Accepted fetched/stream/poll data is stamped with `fetchedAt`; provided and bundled data preserves valid finite nonnegative timestamps. -Missing/invalid timestamps mean unknown fetch age. Loading data never resets its age, -and equal/older source responses do not replace or retag the cache. +The cache seeds its own freshness age from that timestamp; missing/invalid timestamps +mean unknown age. Accepted updates and valid confirmations reset cache age without +rewriting the stored `fetchedAt`. Equal/older responses do not replace or retag data. ### Usage Tracking @@ -324,13 +325,16 @@ version/revision confirmations clear it; repeated errors/disconnects do not rene the first-error deadline. Stream opening/pings and initialization timeout alone are not recovery/failure evidence respectively. -`cache.resolve(policy)` receives mode-specific `isFresh`, `isStale`, and optional -`revalidate` functions. It owns background/blocking decisions, `waitUntil`, shared -revalidation, and cancellation on clear. HeaderSource supplies small version/age +`cache.resolve(policy)` receives a mode-specific `getStatus` callback returning +`Freshness.Fresh`, `Stale`, `Expired`, or `Unknown`, and an optional `fetch` callback. +It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation on clear. HeaderSource supplies small version/age checks and a fetch callback; it does not read the cache. Header confirmations are forwarded through controller event wiring. Stream/poll modes omit on-read revalidation and retain their existing schedules. New public time windows use seconds; internal -normalized durations and `fetchedAt` use milliseconds. +normalized durations, cache age, and `fetchedAt` use milliseconds. Polling is fresh +through its interval; streaming through 30 seconds. Accepted updates, valid confirmations, +and stream pings reset cache age. Pings preserve any failure and its original deadline. +Polling errors use the shared source-error handler without logging each failed poll. ### Evaluation Safety diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 9389bcda3..8067da359 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -50,8 +50,9 @@ const client = createClient(process.env.FLAGS!, { `staleWhileRevalidate` defaults to 10 seconds and accepts finite, nonnegative values, including fractions. `0` makes refreshes block. The window starts at the latest -accepted fetch or matching request-header confirmation. Bundled/provided definitions -preserve their original `fetchedAt`; unknown or expired freshness requires a blocking +accepted fetch or valid confirmation, including an equal-version fetch response. +The cache tracks this age independently of `fetchedAt`. Bundled/provided definitions +preserve their original `fetchedAt`; unknown or expired cache age requires a blocking refresh when a newer request version arrives. Refresh failures use `staleIfError`. A newer-header read attempts blocking recovery after that failure allowance expires. @@ -92,9 +93,12 @@ retained for recovery, including its revision for stream reconnection. A clean stream close or ping timeout records `stream: disconnected` if no earlier failure exists. `getFallbackDatafile()` remains an independent bundled-data export. -Stream/poll modes have no age-based expiry while the source is healthy, and reads -do not trigger an extra refresh after expiry. Build/offline behavior, source scheduling, retries, -timeouts, metrics categories, and logging are unchanged. An initialization timeout +Polling data is marked stale after the polling interval; streaming data after 30 +seconds. Accepted updates and valid confirmations reset cache age without rewriting +`fetchedAt`. Stream pings also reset age, while preserving any failure and its deadline. +Age alone does not prevent stream/poll reads or trigger extra requests. Source scheduling, +retries, timeouts, and build/offline behavior remain unchanged. Poll errors feed the +shared failure handler without logging each failed poll. An initialization timeout alone does not start the allowance. Existing startup limitations remain: when initial polling times out, no recurring interval is started, even if that in-flight request later completes. diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index 3a1e8f2ee..723cb4220 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -1,12 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DatafileInput } from '../types'; +import { getRequestContext } from '../utils/request-context'; +import { Authentication } from './auth'; import { type CacheReadPolicy, DatafileCache, Freshness, } from './datafile-cache'; +import { fetchDatafile } from './fetch-datafile'; +import { HeaderSource } from './header-source'; +import { normalizeOptions } from './normalized-options'; import { tagData } from './tagged-data'; +vi.mock('../utils/request-context', () => ({ getRequestContext: vi.fn() })); +vi.mock('./fetch-datafile', () => ({ fetchDatafile: vi.fn() })); + function data(configUpdatedAt = 1): DatafileInput { return { projectId: 'prj_policy', @@ -30,6 +38,12 @@ let errorSpy: ReturnType; let warnSpy: ReturnType; beforeEach(() => { vi.useFakeTimers({ now: 1_000 }); + vi.mocked(getRequestContext).mockReset(); + vi.mocked(getRequestContext).mockReturnValue({ + ctx: undefined, + headers: undefined, + }); + vi.mocked(fetchDatafile).mockReset(); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -297,3 +311,268 @@ describe('cache read callbacks', () => { expect(cache.read()?.configUpdatedAt).toBe(2); }); }); + +describe('header freshness policy', () => { + function source(staleWhileRevalidate = 1) { + return new HeaderSource( + normalizeOptions({ + auth: new Authentication(undefined), + vercel: true, + staleWhileRevalidate, + }), + ); + } + + function statusCheck(headerSource: HeaderSource, header: string | undefined) { + vi.mocked(getRequestContext).mockReturnValue({ + ctx: undefined, + headers: + header === undefined + ? undefined + : { 'x-vercel-flags-config-versions': header }, + }); + return headerSource.getStatusCheck(); + } + + it.each([ + undefined, + '', + 'flags_other=2', + 'flags_prj_policy=invalid', + 'flags_prj_policy=0', + 'flags_prj_policy=-1', + 'flags_prj_policy=Infinity', + ])('treats missing or malformed header %s as unknown', (header) => { + const headerSource = source(); + const confirmed = vi.fn(); + headerSource.on('confirmed', confirmed); + expect(statusCheck(headerSource, header)({ ...data(), ageMs: 0 })).toBe( + Freshness.Unknown, + ); + expect(confirmed).not.toHaveBeenCalled(); + }); + + it.each([ + undefined, + '', + 'invalid', + NaN, + Infinity, + 0, + ])('treats missing or invalid cached timestamp %s as unknown', (configUpdatedAt) => { + const headerSource = source(); + const confirmed = vi.fn(); + headerSource.on('confirmed', confirmed); + expect( + statusCheck( + headerSource, + 'flags_prj_policy=2', + )({ + ...data(), + configUpdatedAt, + ageMs: 0, + }), + ).toBe(Freshness.Unknown); + expect(confirmed).not.toHaveBeenCalled(); + }); + + it.each([ + [1, 2, Infinity, 1, Freshness.Fresh], + [2, 2, Infinity, 1, Freshness.Fresh], + [3, 2, 0, 1, Freshness.Stale], + [3, 2, 1_000, 1, Freshness.Stale], + [3, 2, 1_001, 1, Freshness.Expired], + [3, 2, Infinity, 1, Freshness.Expired], + [3, 2, 0, 0, Freshness.Expired], + [3, 2, 500, 0.5, Freshness.Stale], + [3, 2, 501, 0.5, Freshness.Expired], + ])('assesses header %s against timestamp %s with age %s and SWR %s as %s', (headerTs, currentTs, ageMs, swr, status) => { + const headerSource = source(swr); + const confirmed = vi.fn(); + headerSource.on('confirmed', confirmed); + const metadata = { ...data(currentTs), ageMs }; + expect( + statusCheck( + headerSource, + `flags_other=99; flags_prj_policy=${headerTs}`, + )(metadata), + ).toBe(status); + expect(confirmed).toHaveBeenCalledTimes(headerTs === currentTs ? 1 : 0); + }); + + it('resets cache age on an equal highest-observed header, then blocks older confirmations until stop', async () => { + const cache = new DatafileCache(0); + const original = Object.freeze( + tagData({ ...data(), fetchedAt: 500 }, 'bundled'), + ); + cache.seed(original); + const headerSource = source(); + const confirmed = vi.fn((metadata) => cache.tryConfirm(metadata)); + headerSource.on('confirmed', confirmed); + expect( + await cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=1'), + }), + ).toEqual([original, 'HIT']); + expect(cache.ageMs).toBe(0); + expect(original.fetchedAt).toBe(500); + expect(original._origin).toBe('bundled'); + expect(confirmed).toHaveBeenCalledTimes(1); + + vi.setSystemTime(1_100); + expect( + await cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=2'), + }), + ).toEqual([original, 'STALE']); + const error = new Error('outage'); + cache.fail(error); + await expect( + cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=1'), + }), + ).rejects.toBe(error); + expect(cache.ageMs).toBe(100); + expect(confirmed).toHaveBeenCalledTimes(1); + + headerSource.stop(); + expect( + await cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=1'), + }), + ).toEqual([original, 'HIT']); + expect(cache.ageMs).toBe(0); + expect(confirmed).toHaveBeenCalledTimes(2); + expect(original.fetchedAt).toBe(500); + }); + + it('assesses the captured raw header after a shared cold fetch discovers the project', async () => { + const cache = new DatafileCache(0); + const headerSource = source(); + const confirmed = vi.fn((metadata) => cache.tryConfirm(metadata)); + headerSource.on('confirmed', confirmed); + const headers = { 'x-vercel-flags-config-versions': 'flags_prj_policy=2' }; + vi.mocked(getRequestContext).mockReturnValue({ ctx: undefined, headers }); + const originalCheck = vi.fn(headerSource.getStatusCheck()); + const pending = deferred(); + const fetch = vi.fn(async () => { + await pending.promise; + cache.updateFromSource(data(), 'fetched'); + }); + const firstRead = cache.resolve({ getStatus: originalCheck, fetch }); + headers['x-vercel-flags-config-versions'] = 'flags_prj_policy=1'; + const laterCheck = vi.fn(headerSource.getStatusCheck()); + const secondRead = cache.resolve({ getStatus: laterCheck, fetch }); + await vi.advanceTimersByTimeAsync(0); + expect(originalCheck).not.toHaveBeenCalled(); + expect(laterCheck).not.toHaveBeenCalled(); + pending.resolve(); + expect(await Promise.all([firstRead, secondRead])).toEqual([ + [cache.read(), 'MISS'], + [cache.read(), 'MISS'], + ]); + expect(fetch).toHaveBeenCalledTimes(1); + expect(originalCheck).toHaveReturnedWith(Freshness.Stale); + expect(laterCheck).toHaveReturnedWith(Freshness.Fresh); + expect(originalCheck).toHaveBeenCalledTimes(1); + expect(laterCheck).toHaveBeenCalledTimes(1); + expect(confirmed).not.toHaveBeenCalled(); + + vi.setSystemTime(1_100); + cache.fail(new Error('outage')); + await expect(cache.resolve({ getStatus: laterCheck })).rejects.toThrow( + 'outage', + ); + expect(cache.ageMs).toBe(100); + expect(confirmed).not.toHaveBeenCalled(); + }); + + it('accepts the fallback header and gives the Vercel header precedence', () => { + const headerSource = source(); + vi.mocked(getRequestContext).mockReturnValue({ + ctx: undefined, + headers: { 'flags-config-versions': 'flags_prj_policy=1' }, + }); + expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( + Freshness.Fresh, + ); + vi.mocked(getRequestContext).mockReturnValue({ + ctx: undefined, + headers: { + 'x-vercel-flags-config-versions': 'flags_prj_policy=2', + 'flags-config-versions': 'flags_prj_policy=1', + }, + }); + expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( + Freshness.Expired, + ); + }); + + it('emits raw fetched data and confirms equal responses without changing fetchedAt', async () => { + const cache = new DatafileCache(); + const original = Object.freeze( + tagData({ ...data(), fetchedAt: 500 }, 'bundled'), + ); + cache.seed(original); + const headerSource = source(); + const onData = vi.fn((raw) => cache.updateFromSource(raw, 'fetched')); + headerSource.on('data', onData); + const incoming = Object.freeze({ + ...data(), + configUpdatedAt: 1, + revision: 1, + digest: 'test', + }); + vi.mocked(fetchDatafile).mockResolvedValue(incoming); + const fetch = headerSource.fetch; + const signal = new AbortController().signal; + vi.setSystemTime(2_000); + await fetch(signal); + expect(onData).toHaveBeenCalledExactlyOnceWith(incoming); + expect(onData.mock.calls[0]?.[0]).toBe(incoming); + expect(fetchDatafile).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ signal }), + ); + expect(cache.read()).toBe(original); + expect(cache.ageMs).toBe(0); + expect(original.fetchedAt).toBe(500); + expect(original._origin).toBe('bundled'); + expect(incoming).not.toHaveProperty('_origin'); + expect(incoming).not.toHaveProperty('fetchedAt'); + expect( + await cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=2'), + }), + ).toEqual([original, 'STALE']); + }); + + it('suppresses a successful transport response after cache clear cancels the fetch', async () => { + const cache = new DatafileCache(0); + const headerSource = source(); + const pending = deferred(); + vi.mocked(fetchDatafile).mockImplementation(async () => { + await pending.promise; + return { ...data(2), configUpdatedAt: 2, revision: 2, digest: 'test' }; + }); + const onData = vi.fn((raw) => cache.updateFromSource(raw, 'fetched')); + headerSource.on('data', onData); + const reading = cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=2'), + fetch: headerSource.fetch, + }); + const outcome = expect(reading).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(0); + expect(fetchDatafile).toHaveBeenCalledTimes(1); + const signal = vi.mocked(fetchDatafile).mock.calls[0]?.[0].signal; + cache.clear(); + pending.resolve(); + await outcome; + expect(signal?.aborted).toBe(true); + expect(onData).not.toHaveBeenCalled(); + expect(cache.read()).toBeUndefined(); + expect(cache.ageMs).toBe(Infinity); + const replacement = tagData(data(), 'provided'); + cache.seed(replacement); + expect(cache.read()).toBe(replacement); + }); +}); From bb89c2203c80da82b9398dbd943b353db9205ac0 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 13:31:41 +0200 Subject: [PATCH 12/20] refactor(flags-core): simplify freshness policies while preserving recovery --- packages/vercel-flags-core/CLAUDE.md | 2 +- .../controller/datafile-cache-policy.test.ts | 73 ++++++++++--------- .../src/controller/datafile-cache.ts | 29 ++++---- .../src/controller/header-source.ts | 51 ++++++------- .../vercel-flags-core/src/controller/index.ts | 51 ++++++------- .../src/controller/polling-source.ts | 4 +- .../src/controller/stream-source.ts | 4 +- 7 files changed, 106 insertions(+), 108 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 637e7383e..eb0d6f1d5 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -326,7 +326,7 @@ the first-error deadline. Stream opening/pings and initialization timeout alone are not recovery/failure evidence respectively. `cache.resolve(policy)` receives a mode-specific `getStatus` callback returning -`Freshness.Fresh`, `Stale`, `Expired`, or `Unknown`, and an optional `fetch` callback. +`fresh`, `stale`, `expired`, or `unknown`, and an optional `fetch` callback. It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation on clear. HeaderSource supplies small version/age checks and a fetch callback; it does not read the cache. Header confirmations are forwarded through controller event wiring. Stream/poll modes omit on-read revalidation diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index 723cb4220..d00ccc129 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -5,7 +5,7 @@ import { Authentication } from './auth'; import { type CacheReadPolicy, DatafileCache, - Freshness, + type Freshness, } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; @@ -58,16 +58,19 @@ afterEach(() => { }); describe('cache read callbacks', () => { - it.each( - Object.values(Freshness), - )('serves %s without fetching when fetch is omitted, subject to SIE', async (status) => { + it.each([ + 'expired', + 'fresh', + 'stale', + 'unknown', + ] satisfies Freshness[])('serves %s without fetching when fetch is omitted, subject to SIE', async (status) => { const cache = new DatafileCache(0); const original = tagData(data(), 'provided'); cache.seed(original); const policy = { getStatus: vi.fn(() => status) }; expect(await cache.resolve(policy)).toEqual([ original, - status === Freshness.Fresh ? 'HIT' : 'STALE', + status === 'fresh' ? 'HIT' : 'STALE', ]); const error = new Error('outage'); cache.fail(error); @@ -77,15 +80,15 @@ describe('cache read callbacks', () => { it('returns undefined without assessing an empty cache when fetch is omitted', async () => { const cache = new DatafileCache(); - const getStatus = vi.fn(() => Freshness.Fresh); + const getStatus = vi.fn(() => 'fresh' as const); expect(await cache.resolve({ getStatus })).toBeUndefined(); expect(getStatus).not.toHaveBeenCalled(); }); it.each([ - Freshness.Fresh, - Freshness.Unknown, - ])('serves a %s assessment without fetching or clearing a failure', async (status) => { + 'fresh', + 'unknown', + ] as const)('serves a %s assessment without fetching or clearing a failure', async (status) => { const cache = new DatafileCache(0); const original = tagData(data(), 'provided'); cache.seed(original); @@ -96,7 +99,7 @@ describe('cache read callbacks', () => { expect(await cache.resolve(policy)).toEqual([ original, - status === Freshness.Fresh ? 'HIT' : 'STALE', + status === 'fresh' ? 'HIT' : 'STALE', ]); expect(policy.getStatus).toHaveBeenCalledExactlyOnceWith({ projectId: 'prj_policy', @@ -123,7 +126,7 @@ describe('cache read callbacks', () => { }); const settled = vi.fn(); const reading = cache - .resolve({ getStatus: () => Freshness.Expired, fetch }) + .resolve({ getStatus: () => 'expired' as const, fetch }) .then(settled); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); @@ -144,7 +147,7 @@ describe('cache read callbacks', () => { .fn>() .mockRejectedValueOnce(firstError) .mockRejectedValue(new Error('later outage')); - const policy = { getStatus: () => Freshness.Expired, fetch }; + const policy = { getStatus: () => 'expired' as const, fetch }; expect(await cache.resolve(policy)).toEqual([original, 'STALE']); vi.setSystemTime(1_100); expect(await cache.resolve(policy)).toEqual([original, 'STALE']); @@ -158,7 +161,7 @@ describe('cache read callbacks', () => { cache.seed(tagData(data(), 'provided')); const fetch = vi.fn().mockRejectedValue('transport failed'); await expect( - cache.resolve({ getStatus: () => Freshness.Expired, fetch }), + cache.resolve({ getStatus: () => 'expired' as const, fetch }), ).rejects.toThrow('Unknown fetch error'); expect(() => cache.read()).toThrow('Unknown fetch error'); expect(fetch).toHaveBeenCalledTimes(1); @@ -174,7 +177,7 @@ describe('cache read callbacks', () => { const failure = new Error('fetch failed'); const fetch = vi.fn().mockRejectedValue(failure); expect( - await cache.resolve({ getStatus: () => Freshness.Stale, fetch }), + await cache.resolve({ getStatus: () => 'stale' as const, fetch }), ).toEqual([original, 'STALE']); await waitUntil.mock.calls[0]?.[0]; expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); @@ -197,12 +200,12 @@ describe('cache read callbacks', () => { await pending.promise; cache.updateFromSource(data(2), 'fetched'); }); - const policy = { getStatus: () => Freshness.Stale, fetch }; + const policy = { getStatus: () => 'stale' as const, fetch }; expect(await cache.resolve(policy)).toEqual([original, 'STALE']); expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); const settled = vi.fn(); const blocking = cache - .resolve({ ...policy, getStatus: () => Freshness.Expired }) + .resolve({ ...policy, getStatus: () => 'expired' as const }) .then((result) => { settled(); return result; @@ -225,7 +228,7 @@ describe('cache read callbacks', () => { const fetch = vi .fn>() .mockRejectedValueOnce(failure); - const policy = { getStatus: () => Freshness.Stale, fetch }; + const policy = { getStatus: () => 'stale' as const, fetch }; expect((await cache.resolve(policy))?.[1]).toBe('STALE'); await waitUntil.mock.calls[0]?.[0]; @@ -251,7 +254,7 @@ describe('cache read callbacks', () => { const fetch = vi.fn>(() => { throw failure; }); - const policy = { getStatus: () => Freshness.Expired, fetch }; + const policy = { getStatus: () => 'expired' as const, fetch }; await expect(cache.resolve(policy)).rejects.toBe(failure); fetch.mockImplementationOnce(async () => cache.updateFromSource(data(), 'fetched'), @@ -264,7 +267,7 @@ describe('cache read callbacks', () => { const cache = new DatafileCache(); const fetch = vi.fn(async () => {}); const reading = cache.resolve({ - getStatus: () => Freshness.Expired, + getStatus: () => 'expired' as const, fetch, }); const outcome = expect(reading).rejects.toThrow(); @@ -286,7 +289,7 @@ describe('cache read callbacks', () => { signal.throwIfAborted(); cache.updateFromSource(data(2), 'fetched'); }); - const policy = { getStatus: () => Freshness.Expired, fetch }; + const policy = { getStatus: () => 'expired' as const, fetch }; const oldRead = cache.resolve(policy); const cancelled = expect(oldRead).rejects.toThrow('cancelled transport'); await vi.advanceTimersByTimeAsync(0); @@ -347,7 +350,7 @@ describe('header freshness policy', () => { const confirmed = vi.fn(); headerSource.on('confirmed', confirmed); expect(statusCheck(headerSource, header)({ ...data(), ageMs: 0 })).toBe( - Freshness.Unknown, + 'unknown', ); expect(confirmed).not.toHaveBeenCalled(); }); @@ -372,20 +375,20 @@ describe('header freshness policy', () => { configUpdatedAt, ageMs: 0, }), - ).toBe(Freshness.Unknown); + ).toBe('unknown'); expect(confirmed).not.toHaveBeenCalled(); }); it.each([ - [1, 2, Infinity, 1, Freshness.Fresh], - [2, 2, Infinity, 1, Freshness.Fresh], - [3, 2, 0, 1, Freshness.Stale], - [3, 2, 1_000, 1, Freshness.Stale], - [3, 2, 1_001, 1, Freshness.Expired], - [3, 2, Infinity, 1, Freshness.Expired], - [3, 2, 0, 0, Freshness.Expired], - [3, 2, 500, 0.5, Freshness.Stale], - [3, 2, 501, 0.5, Freshness.Expired], + [1, 2, Infinity, 1, 'fresh'], + [2, 2, Infinity, 1, 'fresh'], + [3, 2, 0, 1, 'stale'], + [3, 2, 1_000, 1, 'stale'], + [3, 2, 1_001, 1, 'expired'], + [3, 2, Infinity, 1, 'expired'], + [3, 2, 0, 0, 'expired'], + [3, 2, 500, 0.5, 'stale'], + [3, 2, 501, 0.5, 'expired'], ])('assesses header %s against timestamp %s with age %s and SWR %s as %s', (headerTs, currentTs, ageMs, swr, status) => { const headerSource = source(swr); const confirmed = vi.fn(); @@ -472,8 +475,8 @@ describe('header freshness policy', () => { [cache.read(), 'MISS'], ]); expect(fetch).toHaveBeenCalledTimes(1); - expect(originalCheck).toHaveReturnedWith(Freshness.Stale); - expect(laterCheck).toHaveReturnedWith(Freshness.Fresh); + expect(originalCheck).toHaveReturnedWith('stale'); + expect(laterCheck).toHaveReturnedWith('fresh'); expect(originalCheck).toHaveBeenCalledTimes(1); expect(laterCheck).toHaveBeenCalledTimes(1); expect(confirmed).not.toHaveBeenCalled(); @@ -494,7 +497,7 @@ describe('header freshness policy', () => { headers: { 'flags-config-versions': 'flags_prj_policy=1' }, }); expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( - Freshness.Fresh, + 'fresh', ); vi.mocked(getRequestContext).mockReturnValue({ ctx: undefined, @@ -504,7 +507,7 @@ describe('header freshness policy', () => { }, }); expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( - Freshness.Expired, + 'expired', ); }); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index d5ea96369..a3eb96371 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -8,12 +8,7 @@ type Confirmation = Pick< export type CacheMetadata = Confirmation & { ageMs: number }; -export enum Freshness { - Fresh = 'fresh', - Stale = 'stale', - Expired = 'expired', - Unknown = 'unknown', -} +export type Freshness = 'fresh' | 'stale' | 'expired' | 'unknown'; type Fetch = (signal: AbortSignal) => Promise; type CacheResult = [TaggedData, Metrics['cacheStatus']]; @@ -74,7 +69,7 @@ export class DatafileCache { } /** Freshness checks can inspect retained metadata even after serving expires. */ - private get metadata(): CacheMetadata | undefined { + public get metadata(): CacheMetadata | undefined { if (!this.data) return undefined; const { projectId, environment, configUpdatedAt, revision } = this.data; return { @@ -133,9 +128,14 @@ export class DatafileCache { return false; } + this.confirm(); + return true; + } + + /** Confirms the current cache state by clearing failures and resetting age. */ + confirm(): void { this.resetAge(); this.failure = undefined; - return true; } /** Preserves existing acceptance, including missing or unparseable versions. */ @@ -172,17 +172,15 @@ export class DatafileCache { } async resolve(policy: CacheReadPolicy): Promise { + // Expired entries can still recover through confirmation or a blocking fetch. const metadata = this.metadata; if (metadata) { const status = policy.getStatus(metadata); - if ( - status === Freshness.Fresh || - status === Freshness.Unknown || - !policy.fetch - ) { - return [this.read()!, status === Freshness.Fresh ? 'HIT' : 'STALE']; + if (status === 'fresh' || status === 'unknown' || !policy.fetch) { + return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; } - if (status === Freshness.Stale && this.canServe()) { + + if (status === 'stale' && this.canServe()) { const stale = this.read()!; this.fetchInBackground(policy.fetch); return [stale, 'STALE']; @@ -190,6 +188,7 @@ export class DatafileCache { } if (!policy.fetch) return; + const { promise, signal } = this.startFetch(policy.fetch); try { await promise; diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 78d29b013..fbfc61976 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,10 +1,6 @@ import type { DatafileInput } from '../types'; import { getRequestContext } from '../utils/request-context'; -import { - type CacheMetadata, - type CacheReadPolicy, - Freshness, -} from './datafile-cache'; +import type { CacheMetadata, CacheReadPolicy } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; import { TypedEmitter } from './typed-emitter'; @@ -28,7 +24,28 @@ export class HeaderSource extends TypedEmitter { const header = headers?.['x-vercel-flags-config-versions'] ?? headers?.['flags-config-versions']; - return (data) => this.getStatus(data, header); + + return (data) => { + const headerTs = this.getUpdatedAtHeader(data.projectId, header); + if (headerTs === undefined) return 'unknown'; + + const currentTs = Number(data.configUpdatedAt); + this.highestObserved = Math.max(this.highestObserved, headerTs); + + if (!Number.isFinite(currentTs) || currentTs <= 0) return 'unknown'; + + // An older matching request cannot undo a newer request's invalidation. + if (headerTs === currentTs && headerTs === this.highestObserved) { + this.emit('confirmed', data); + } + + if (headerTs <= currentTs) return 'fresh'; + + const { staleWhileRevalidateMs } = this.options; + return staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs + ? 'stale' + : 'expired'; + }; } private getUpdatedAtHeader(projectId: string, header: string | undefined) { @@ -44,28 +61,6 @@ export class HeaderSource extends TypedEmitter { return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; } - private getStatus( - data: CacheMetadata, - header: string | undefined, - ): Freshness { - const headerTs = this.getUpdatedAtHeader(data.projectId, header); - if (headerTs === undefined) return Freshness.Unknown; - - const currentTs = Number(data.configUpdatedAt); - this.highestObserved = Math.max(this.highestObserved, headerTs); - if (!Number.isFinite(currentTs) || currentTs <= 0) return Freshness.Unknown; - // An older matching request cannot undo a newer request's invalidation. - if (headerTs === currentTs && headerTs === this.highestObserved) { - this.emit('confirmed', data); - } - - if (headerTs <= currentTs) return Freshness.Fresh; - const { staleWhileRevalidateMs } = this.options; - return staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs - ? Freshness.Stale - : Freshness.Expired; - } - fetch = async (signal: AbortSignal): Promise => { const data = await fetchDatafile({ ...this.options, signal }); // Transports can finish after cancellation; never publish that response. diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index d94284e79..499014531 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -14,7 +14,6 @@ import { type CacheMetadata, type CacheReadPolicy, DatafileCache, - Freshness, } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; @@ -226,10 +225,6 @@ export class Controller implements ControllerInterface { this.state = to; } - private get isConnected(): boolean { - return this.state === 'streaming'; - } - private get mode(): Metrics['mode'] { if (this.options.buildStep) return 'build'; switch (this.state) { @@ -348,9 +343,10 @@ export class Controller implements ControllerInterface { readMs: Date.now() - startTime, source: originToMetricsSource(result._origin), cacheStatus, - connectionState: this.isConnected - ? ('connected' as const) - : ('disconnected' as const), + connectionState: + this.state === 'streaming' + ? ('connected' as const) + : ('disconnected' as const), mode: this.mode, }, } satisfies Datafile; @@ -390,10 +386,14 @@ export class Controller implements ControllerInterface { if (this.options.buildStep) { [result, cacheStatus] = await this.resolveDataForBuildStep(); } else if (result) { - const status = this.getBackgroundSourceStatus({ - ageMs: this.cache.ageMs, - }); - cacheStatus = status === Freshness.Fresh ? 'HIT' : 'STALE'; + const metadata = this.cache.metadata; + // Snapshots must not turn request headers into freshness evidence. + const status = + metadata && this.state !== 'vercel' + ? this.cacheReadPolicy.getStatus(metadata) + : 'unknown'; + + cacheStatus = status === 'fresh' ? 'HIT' : 'STALE'; } else { // Preserve snapshot loading without starting stream/poll initialization. const bundled = await this.bundledSource.tryLoad(); @@ -430,9 +430,10 @@ export class Controller implements ControllerInterface { readMs: Date.now() - startTime, source: originToMetricsSource(result._origin), cacheStatus, - connectionState: this.isConnected - ? ('connected' as const) - : ('disconnected' as const), + connectionState: + this.state === 'streaming' + ? ('connected' as const) + : ('disconnected' as const), mode: this.mode, }, } satisfies Datafile; @@ -462,12 +463,13 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } - const result = await this.cache.resolve(this.getCacheReadPolicy()); + const result = await this.cache.resolve(this.cacheReadPolicy); if (result) return result; + return this.resolveDataWithFallbacks(); } - private getCacheReadPolicy(): CacheReadPolicy { + private get cacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { getStatus: this.headerSource.getStatusCheck(), @@ -475,17 +477,16 @@ export class Controller implements ControllerInterface { }; } - // Stream/poll maintain their existing schedules; reads do not trigger I/O. - return { getStatus: this.getBackgroundSourceStatus }; - } + if (this.state === 'streaming') { + return { getStatus: this.streamSource.getStatus }; + } - private getBackgroundSourceStatus = (data: Pick) => { - if (this.isConnected) return this.streamSource.getStatus(data); if (this.state === 'polling' || this.state === 'initializing:polling') { - return this.pollingSource.getStatus(data); + return { getStatus: this.pollingSource.getStatus }; } - return Freshness.Unknown; - }; + + return { getStatus: () => 'unknown' }; + } // --------------------------------------------------------------------------- // Stream initialization diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 1c4efd7d2..189fdcff1 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,6 +1,6 @@ import type { DatafileInput } from '../types'; import type { Auth } from './auth'; -import { type CacheMetadata, Freshness } from './datafile-cache'; +import type { CacheMetadata, Freshness } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { TypedEmitter } from './typed-emitter'; @@ -33,7 +33,7 @@ export class PollingSource extends TypedEmitter { } getStatus = ({ ageMs }: Pick): Freshness => - ageMs <= this.config.polling.intervalMs ? Freshness.Fresh : Freshness.Stale; + ageMs <= this.config.polling.intervalMs ? 'fresh' : 'stale'; /** * Perform a single poll request. diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index 8a6844139..719716778 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,5 +1,5 @@ import type { DatafileInput } from '../types'; -import { type CacheMetadata, Freshness } from './datafile-cache'; +import type { CacheMetadata, Freshness } from './datafile-cache'; import type { NormalizedOptions } from './normalized-options'; import { connectStream, type PrimedMessage } from './stream-connection'; import { TypedEmitter } from './typed-emitter'; @@ -30,7 +30,7 @@ export class StreamSource extends TypedEmitter { } getStatus = ({ ageMs }: Pick): Freshness => - ageMs <= 30_000 ? Freshness.Fresh : Freshness.Stale; + ageMs <= 30_000 ? 'fresh' : 'stale'; /** * Start the stream connection. From c5bc7044618f12496bd5a205cd88853cd7e038b4 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 13:31:56 +0200 Subject: [PATCH 13/20] test(flags-core): guard snapshot freshness and cold-cache recovery --- .../src/vercel-mode.black-box.test.ts | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index f7db8fcaa..1dd545025 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -234,12 +234,18 @@ describe('Vercel mode (black-box)', () => { expect((await instance.evaluate('feature')).value).toBe(true); }); - it('recovers on a later read when the cold-cache fetch fails', async () => { - const instance = client({ datafile: undefined }); + it.each([ + undefined, + 0, + 0.01, + ])('recovers on a later read when the cold-cache fetch fails with staleIfError=%s', async (staleIfError) => { + const instance = client({ datafile: undefined, staleIfError }); dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); await expect(instance.evaluate('feature')).rejects.toThrow( 'Failed to fetch data', ); + expect(dataFetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(11); mockDatafileResponse(TIMESTAMP, true); expect(await instance.evaluate('feature')).toMatchObject({ @@ -330,6 +336,71 @@ describe('Vercel mode (black-box)', () => { ).toBe(true); }); + it('does not renew freshness from a matching header on a snapshot read', async () => { + const input = { ...datafile(), fetchedAt: TIMESTAMP }; + const instance = client({ datafile: input }); + await instance.initialize(); + vi.setSystemTime(TIMESTAMP + 11_000); + setVersion(TIMESTAMP); + const snapshot = await instance.getDatafile(); + expect(snapshot).toEqual({ + ...input, + metrics: { + readMs: 0, + source: 'in-memory', + cacheStatus: 'STALE', + connectionState: 'disconnected', + mode: 'vercel', + }, + }); + expect(snapshot.definitions).toBe(input.definitions); + expect(dataFetch).not.toHaveBeenCalled(); + + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('does not observe newer snapshot headers when assessing later matching evaluations', async () => { + const input = { ...datafile(), fetchedAt: TIMESTAMP }; + const instance = client({ datafile: input }); + await instance.initialize(); + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP + 1); + const snapshot = await instance.getDatafile(); + expect(snapshot).toEqual({ + ...input, + metrics: { + readMs: 0, + source: 'in-memory', + cacheStatus: 'STALE', + connectionState: 'disconnected', + mode: 'vercel', + }, + }); + expect(dataFetch).not.toHaveBeenCalled(); + + setVersion(TIMESTAMP); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'HIT' }, + }); + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + it.each([ undefined, 'flags_other=1700000000000', From a246910ba7ac27d08a83970261d0f4d656acf4e5 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 14:55:10 +0200 Subject: [PATCH 14/20] test(flags-core): retain seconds-based stale-if-error coverage --- packages/vercel-flags-core/CLAUDE.md | 2 +- .../vercel-flags-core/src/vercel-mode.black-box.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index eb0d6f1d5..2b27f7098 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -125,7 +125,7 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu - A newer header permits background refresh within `staleWhileRevalidate` seconds of the latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its - `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. + `staleIfError` allowance; expiry forces blocking recovery on the next newer-header read. - Missing/malformed headers use cached data without fetching, subject to stale-if-error. - `getDatafile()` remains a snapshot read: it enforces the same failure policy but does not inspect request headers. Disabling both stream and polling selects offline mode. diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 1dd545025..a7e8624c1 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -593,7 +593,7 @@ describe('Vercel mode (black-box)', () => { dataFetch.mockResolvedValueOnce( new Response(null, { status: 503, statusText: 'Service Unavailable' }), ); - const instance = client({ staleIfErrorMs: 0 }); + const instance = client({ staleIfError: 0 }); const failed = await instance.evaluate('feature', false); expect(failed.value).toBe(false); @@ -1089,7 +1089,7 @@ describe('Vercel mode (black-box)', () => { }); it('shares the first-error deadline with snapshots and recovers after expiry', async () => { - const instance = client({ staleIfErrorMs: 1_000 }); + const instance = client({ staleIfError: 1 }); setVersion(TIMESTAMP + 1); const firstError = new Error('first failure'); dataFetch.mockRejectedValueOnce(firstError); @@ -1129,7 +1129,7 @@ describe('Vercel mode (black-box)', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const instance = client({ datafile: { ...datafile(), fetchedAt: TIMESTAMP }, - staleIfErrorMs: 0, + staleIfError: 0, }); setVersion(TIMESTAMP + 1); const failure = new Error('background failure'); From 44fec8c27033a418bb2c5e7fc704f935f454584b Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 15:07:48 +0200 Subject: [PATCH 15/20] docs(flags-core): explain cache serving and recovery checks --- packages/vercel-flags-core/src/controller/datafile-cache.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index a3eb96371..16fa1dbbf 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -177,9 +177,13 @@ export class DatafileCache { if (metadata) { const status = policy.getStatus(metadata); if (status === 'fresh' || status === 'unknown' || !policy.fetch) { + // No on-read refresh is requested or available here. Even a fresh + // assessment must pass read()'s stale-if-error check before serving. return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; } + // If stale-if-error has expired, fall through to a blocking recovery fetch. + // Calling read() here would throw before a background fetch could start. if (status === 'stale' && this.canServe()) { const stale = this.read()!; this.fetchInBackground(policy.fetch); From 0a527cf2e7197c78e89c100a0b4968444a2231a5 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 15:11:46 +0200 Subject: [PATCH 16/20] docs(flags-core): clarify cache and source policy decisions --- packages/prepare-flags-definitions/src/index.ts | 1 + .../src/controller/datafile-cache.ts | 12 +++++++++--- .../src/controller/header-source.ts | 3 ++- packages/vercel-flags-core/src/controller/index.ts | 11 ++++++----- .../src/controller/stream-connection.ts | 2 ++ .../vercel-flags-core/src/controller/tagged-data.ts | 2 +- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/prepare-flags-definitions/src/index.ts b/packages/prepare-flags-definitions/src/index.ts index 1747eaa81..06f27cbf1 100644 --- a/packages/prepare-flags-definitions/src/index.ts +++ b/packages/prepare-flags-definitions/src/index.ts @@ -184,6 +184,7 @@ async function fetchDatafile( if (res.ok) { const definitions = (await res.json()) as BundledDefinitions; + // Preserve fetch time so loading the bundle does not make old data fresh. return { ...definitions, fetchedAt: Date.now() }; } diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 16fa1dbbf..71d4114ac 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -36,6 +36,7 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { /** Storage, serving policy, and fetching driven by source callbacks. */ export class DatafileCache { private data: TaggedData | undefined; + // Confirmations refresh age without rewriting the datafile's persisted fetch time. private freshAt: number | undefined; private failure: { error: Error; startedAt: number } | undefined; @@ -47,6 +48,7 @@ export class DatafileCache { private readonly waitUntil: WaitUntil = () => {}, ) {} + /** Expired data still exists; fallback loading must not bypass its failure policy. */ get hasData(): boolean { return this.data !== undefined; } @@ -153,6 +155,7 @@ export class DatafileCache { } fail(error: Error): void { + // Repeated failures must not keep extending the stale-if-error allowance. this.failure ??= { error, startedAt: Date.now() }; } @@ -172,13 +175,13 @@ export class DatafileCache { } async resolve(policy: CacheReadPolicy): Promise { - // Expired entries can still recover through confirmation or a blocking fetch. const metadata = this.metadata; if (metadata) { + // The assessment may confirm recovery, so run it before read() checks failure. const status = policy.getStatus(metadata); if (status === 'fresh' || status === 'unknown' || !policy.fetch) { - // No on-read refresh is requested or available here. Even a fresh - // assessment must pass read()'s stale-if-error check before serving. + // Stream/poll omit fetch because they maintain the cache independently. + // read() still enforces stale-if-error, even for a fresh assessment. return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; } @@ -206,6 +209,7 @@ export class DatafileCache { // A cold fetch discovers the project; assess the original request's header. if (!metadata && this.metadata) policy.getStatus(this.metadata); + // Serve the accepted cache entry; the response may have contained older data. const data = this.read(); if (!data) throw new Error('@vercel/flags-core: Fetch returned no definitions'); @@ -214,6 +218,7 @@ export class DatafileCache { private startFetch(fetch: Fetch) { const { signal } = this.abortController; + // Share the fetch, but let each caller assess its own request's headers. if (this.fetching) return { promise: this.fetching, signal }; const promise = Promise.resolve() @@ -252,6 +257,7 @@ export class DatafileCache { } } + /** Clearing storage is not recovery; restored seeds keep the failure deadline. */ clear(): void { this.abortController.abort(); this.abortController = new AbortController(); diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index fbfc61976..308715249 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -18,7 +18,7 @@ export class HeaderSource extends TypedEmitter { super(); } - /** Capture this request's header before any cold-cache fetch awaits. */ + /** Capture the header now so a shared fetch cannot switch the request being assessed. */ getStatusCheck(): CacheReadPolicy['getStatus'] { const { headers } = getRequestContext(); const header = @@ -39,6 +39,7 @@ export class HeaderSource extends TypedEmitter { this.emit('confirmed', data); } + // This request is satisfied; only confirmation above can renew age or clear failure. if (headerTs <= currentTs) return 'fresh'; const { staleWhileRevalidateMs } = this.options; diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 499014531..67f671de9 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -154,13 +154,13 @@ export class Controller implements ControllerInterface { }; private onStreamPrimed = (message: PrimedMessage) => { this.cache.tryConfirm(message, 'revision'); - // The server confirmed our revision is current — no new data needed. - // Transition to streaming like a normal connected event. + // The stream is connected even if its revision no longer matches the cache. if (this.state === 'degraded' || this.state === 'initializing:stream') { this.transition('streaming'); } }; private onStreamPing = () => { + // Pings refresh age but do not prove recovery from a recorded failure. this.cache.resetAge(); }; private onStreamConnected = () => { @@ -277,6 +277,7 @@ export class Controller implements ControllerInterface { } } + // Select header mode after hydration so provided/bundled data avoids a cold fetch. if (this.headerSource.isAvailable()) { this.transition('vercel'); return; @@ -447,7 +448,7 @@ export class Controller implements ControllerInterface { } // --------------------------------------------------------------------------- - // Data resolution (shared by read() and getDatafile()) + // Data resolution // --------------------------------------------------------------------------- /** @@ -455,8 +456,7 @@ export class Controller implements ControllerInterface { * current mode. Returns tagged data and cache status. * * Build step: cached → bundled → one-time fetch - * Runtime with cache: return cached data - * Runtime without cache: stream/poll → datafile → bundled → fetch → throw + * Runtime: source policy chooses cached data or refresh; fall back if empty. */ private async resolveData(): Promise<[TaggedData, Metrics['cacheStatus']]> { if (this.options.buildStep) { @@ -481,6 +481,7 @@ export class Controller implements ControllerInterface { return { getStatus: this.streamSource.getStatus }; } + // Seeded initialization can leave the active poller in 'initializing:polling'. if (this.state === 'polling' || this.state === 'initializing:polling') { return { getStatus: this.pollingSource.getStatus }; } diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 094519e5c..16f7a76f5 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -75,6 +75,7 @@ export async function connectStream( let lastAttemptTime = 0; const reportError = (error: unknown): void => { + // Deliberate shutdown must not start a stale-if-error deadline. if (abortController.signal.aborted) return; onError?.( error instanceof Error @@ -275,6 +276,7 @@ export async function connectStream( if (abortController.signal.aborted) { break; } + // Ping timeouts report failure through onDisconnect below, not an abort error. if (!connectionAbort.signal.aborted) { reportError(error); } diff --git a/packages/vercel-flags-core/src/controller/tagged-data.ts b/packages/vercel-flags-core/src/controller/tagged-data.ts index f87824c22..61e6a0e5f 100644 --- a/packages/vercel-flags-core/src/controller/tagged-data.ts +++ b/packages/vercel-flags-core/src/controller/tagged-data.ts @@ -15,7 +15,7 @@ export type TaggedData = DatafileInput & { }; /** - * Tags a DatafileInput with metadata. + * Stamp live arrivals; reusing provided/bundled data must preserve its original age. */ export function tagData(data: DatafileInput, origin: DataOrigin): TaggedData { const tagged: TaggedData = { ...data, _origin: origin }; From 39ba439fa3e1d457e85be6de657d944165ac3edb Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 15:18:16 +0200 Subject: [PATCH 17/20] feat(flags-core): fall back to stream or polling without version headers --- .changeset/header-driven-vercel-mode.md | 2 + packages/vercel-flags-core/CLAUDE.md | 6 +- packages/vercel-flags-core/README.md | 7 +- .../src/controller/datafile-cache.ts | 9 +- .../src/controller/header-source.ts | 17 +- .../vercel-flags-core/src/controller/index.ts | 49 +++- .../src/controller/normalized-options.ts | 1 + .../src/vercel-mode.black-box.test.ts | 276 ++++++++++++++++-- 8 files changed, 329 insertions(+), 38 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 745bc9ff2..e61301cff 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -4,6 +4,8 @@ Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved. +An evaluation with a missing or empty version header permanently starts streaming when enabled, otherwise polling. Concurrent reads share startup, and pending header refreshes are cancelled without losing cached data or resetting stale-if-error. Initialization and snapshot reads do not trigger this switch. + The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age without clearing or extending a stale-if-error failure. Source schedules stay unchanged. Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 2b27f7098..9eef40af4 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -126,7 +126,11 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfError` allowance; expiry forces blocking recovery on the next newer-header read. -- Missing/malformed headers use cached data without fetching, subject to stale-if-error. +- Evaluations without a version header (including an empty header) permanently + start streaming if enabled, otherwise polling, using the existing startup timeouts. + Concurrent reads share source startup. Pending header fetches are cancelled without + clearing stored data or the failure deadline; their readers resume through the new source. +- Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error. - `getDatafile()` remains a snapshot read: it enforces the same failure policy but does not inspect request headers. Disabling both stream and polling selects offline mode. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 8067da359..56782e6f5 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -37,8 +37,11 @@ Outside Vercel, pass an SDK key explicitly: `createClient(process.env.FLAGS)`. When `VERCEL=1`, the client defaults to `vercel: true`. Initialization loads provided or bundled definitions without starting a stream or polling. Request version headers -indicate when cached definitions need refreshing; reads without a usable header keep -cached definitions, fetching only when the cache is empty. +indicate when cached definitions need refreshing. If an evaluation has no version +header (or an empty one), the client permanently switches to streaming when enabled, +otherwise polling. Concurrent evaluations share that startup and later headers do +not switch the client back. A present but malformed or unrelated header keeps the +existing cached-read behavior, fetching only when the cache is empty. ```ts const client = createClient(process.env.FLAGS!, { diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 71d4114ac..04a2e7b46 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -257,11 +257,16 @@ export class DatafileCache { } } - /** Clearing storage is not recovery; restored seeds keep the failure deadline. */ - clear(): void { + /** Switching sources cancels revalidation without changing storage or failure. */ + cancelFetch(): void { this.abortController.abort(); this.abortController = new AbortController(); this.fetching = undefined; + } + + /** Clearing storage is not recovery; restored seeds keep the failure deadline. */ + clear(): void { + this.cancelFetch(); this.data = undefined; this.freshAt = undefined; } diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 308715249..e8a19aa06 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -18,12 +18,21 @@ export class HeaderSource extends TypedEmitter { super(); } - /** Capture the header now so a shared fetch cannot switch the request being assessed. */ - getStatusCheck(): CacheReadPolicy['getStatus'] { + private getVersionHeader(): string | undefined { const { headers } = getRequestContext(); - const header = + return ( headers?.['x-vercel-flags-config-versions'] ?? - headers?.['flags-config-versions']; + headers?.['flags-config-versions'] + ); + } + + hasVersionHeader(): boolean { + return Boolean(this.getVersionHeader()); + } + + /** Capture the header now so a shared fetch cannot switch the request being assessed. */ + getStatusCheck(): CacheReadPolicy['getStatus'] { + const header = this.getVersionHeader(); return (data) => { const headerTs = this.getUpdatedAtHeader(data.projectId, header); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 67f671de9..cf3cbef75 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -78,6 +78,7 @@ type State = * **Runtime — Vercel mode** (vercel enabled, with stream or polling enabled): * - Loads provided/bundled data before selecting the mode; no startup network * - HeaderSource checks request versions and refreshes when needed + * - An evaluation without a version header permanently starts stream/poll * - Cache applies version acceptance and stale-if-error to all served data * * **Runtime — offline mode** (neither stream nor polling): @@ -104,6 +105,8 @@ export class Controller implements ControllerInterface { private pollingSource: PollingSource; private bundledSource: BundledSource; private headerSource: HeaderSource; + // Retain the startup promise so fallback stays permanent and concurrent reads share it. + private headerFallback: Promise | undefined; // Usage tracking private usageTracker: UsageTracker; @@ -278,7 +281,7 @@ export class Controller implements ControllerInterface { } // Select header mode after hydration so provided/bundled data avoids a cold fetch. - if (this.headerSource.isAvailable()) { + if (this.headerSource.isAvailable() && !this.headerFallback) { this.transition('vercel'); return; } @@ -463,12 +466,48 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } - const result = await this.cache.resolve(this.cacheReadPolicy); - if (result) return result; + if (this.state === 'vercel' && !this.headerSource.hasVersionHeader()) { + this.headerFallback = this.initializeHeaderFallback(); + } + + if (this.headerFallback) { + await this.headerFallback; + if (this.state === 'shutdown') { + throw new Error('@vercel/flags-core: Client is shut down'); + } + } + + const usingHeaders = this.state === 'vercel'; + try { + const result = await this.cache.resolve(this.cacheReadPolicy); + if (result) return result; + } catch (error) { + // A concurrent read may have switched sources and cancelled this header fetch. + if (usingHeaders && this.headerFallback) return this.resolveData(); + throw error; + } return this.resolveDataWithFallbacks(); } + private async initializeHeaderFallback(): Promise { + this.cache.cancelFetch(); + this.headerSource.stop(); + + if (this.options.stream.enabled) { + this.transition('initializing:stream'); + await this.tryInitializeStream(); + return; + } + + this.transition('initializing:polling'); + await this.tryInitializePolling(); + if (this.state === 'shutdown') return; + // A timed-out first poll must not leave the fallback without future updates. + this.pollingSource.startInterval(); + this.transition('polling'); + } + private get cacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { @@ -563,7 +602,7 @@ export class Controller implements ControllerInterface { if (this.options.polling.initTimeoutMs <= 0) { try { await pollPromise; - if (this.cache.hasData) { + if (this.state !== 'shutdown' && this.cache.hasData) { this.pollingSource.startInterval(); return true; } @@ -593,7 +632,7 @@ export class Controller implements ControllerInterface { return false; } - if (this.cache.hasData) { + if (this.state !== 'shutdown' && this.cache.hasData) { this.pollingSource.startInterval(); return true; } diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index feb7cf38e..7d7e95dde 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -49,6 +49,7 @@ export type ControllerOptions = { /** * Use request version headers instead of streaming or polling at runtime. * Initialization starts no network activity; reads fetch only when needed. + * A read without a version header permanently falls back to stream/poll. * Disabling both stream and polling still selects offline mode. * @default process.env.VERCEL === '1' */ diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index a7e8624c1..477e60313 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -45,8 +45,25 @@ function deferred() { return { promise, resolve, reject }; } +function mockStream() { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(value) { + controller = value; + }, + }); + return { + response: new Response(body), + push: (message: unknown) => + controller.enqueue( + new TextEncoder().encode(`${JSON.stringify(message)}\n`), + ), + }; +} + const clients = new Set(); const dataFetch = vi.fn(); +const streamFetch = vi.fn(); const transport = vi.fn(); let cleanupContext = () => {}; @@ -86,12 +103,17 @@ beforeEach(() => { }); dataFetch.mockReset(); dataFetch.mockRejectedValue(new Error('Unexpected datafile fetch')); + streamFetch.mockReset(); + streamFetch.mockRejectedValue(new Error('Unexpected stream fetch')); transport.mockReset(); transport.mockImplementation((input, init) => { const url = String(input); if (url === 'https://flags.vercel.com/v1/datafile') { return dataFetch(input, init); } + if (url === 'https://flags.vercel.com/v1/stream') { + return streamFetch(input, init); + } if (url === 'https://flags.vercel.com/v1/ingest') { return Promise.resolve(new Response()); } @@ -167,10 +189,13 @@ describe('Vercel mode (black-box)', () => { }); it.each([ - 'provided', - 'bundled', - 'empty', - ] as const)('uses the %s cache without a header, fetching only when empty', async (cache) => { + ['streaming', 'provided'], + ['streaming', 'bundled'], + ['streaming', 'empty'], + ['polling', 'provided'], + ['polling', 'bundled'], + ['polling', 'empty'], + ] as const)('falls back to %s with a %s cache when no header arrives', async (mode, cache) => { setVersion(undefined); if (cache === 'bundled') { vi.mocked(readBundledDefinitions).mockResolvedValue({ @@ -178,22 +203,221 @@ describe('Vercel mode (black-box)', () => { state: 'ok', }); } - mockDatafileResponse(TIMESTAMP, true); + const stream = mockStream(); + streamFetch.mockResolvedValueOnce(stream.response); + mockDatafileResponse(TIMESTAMP + 1, true); const instance = client({ + stream: mode === 'streaming', datafile: cache === 'provided' ? datafile() : undefined, }); + const reading = instance.evaluate('feature'); + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 1, true) }); + await vi.advanceTimersByTimeAsync(0); + expect(await reading).toMatchObject({ + value: true, + metrics: { mode, source: 'in-memory', cacheStatus: 'HIT' }, + }); + expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + expect(readBundledDefinitions).toHaveBeenCalledTimes( + cache === 'provided' ? 0 : 1, + ); + + // Later headers cannot switch the client back or trigger on-read refreshes. + setVersion(TIMESTAMP + 100); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + + if (mode === 'streaming') { + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 2) }); + } else { + mockDatafileResponse(TIMESTAMP + 2); + await vi.advanceTimersByTimeAsync(30_000); + } + await vi.advanceTimersByTimeAsync(0); expect(await instance.evaluate('feature')).toMatchObject({ - value: cache === 'empty', - metrics: { - mode: 'vercel', - cacheStatus: cache === 'empty' ? 'MISS' : 'STALE', - }, + value: false, + metrics: { mode, cacheStatus: 'HIT' }, }); - expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'STALE', + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 0); + }); + + it.each([ + 'absent', + 'empty', + 'no context', + ])('shares polling startup after a previously usable header becomes %s', async (header) => { + const instance = client({ stream: false }); + expect((await instance.evaluate('feature')).metrics?.mode).toBe('vercel'); + cleanupContext(); + if (header !== 'no context') { + cleanupContext = setRequestContext( + header === 'empty' ? { [HEADER]: '' } : {}, + ); + } + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const settled = vi.fn(); + const first = instance.evaluate('feature').then(settled); + await vi.advanceTimersByTimeAsync(0); + setVersion(TIMESTAMP + 100); + const second = instance.bulkEvaluate([{ key: 'feature' }]); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await first; + expect(settled).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + value: true, + metrics: expect.objectContaining({ mode: 'polling' }), + }), ); - expect(dataFetch).toHaveBeenCalledTimes(cache === 'empty' ? 1 : 0); + expect((await second).feature).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).not.toHaveBeenCalled(); + }); + + it.each([ + 'success', + 'failure', + ])('discards a cancelled header refresh ending in %s after polling takes over', async (outcome) => { + const pendingHeader = deferred(); + dataFetch.mockReturnValueOnce(pendingHeader.promise); + const instance = client({ stream: false, staleIfError: 0 }); + setVersion(TIMESTAMP + 1); + const originalRead = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + + setVersion(undefined); + mockDatafileResponse(TIMESTAMP + 2, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); + expect(signal?.aborted).toBe(true); + if (outcome === 'success') { + pendingHeader.resolve(Response.json(datafile(TIMESTAMP + 3))); + } else { + pendingHeader.reject(new Error('late header failure')); + } + + expect(await originalRead).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('preserves the failure deadline through fallback until polling confirms recovery', async () => { + const instance = client({ stream: false, staleIfError: 1 }); + const firstError = new Error('header failure'); + setVersion(TIMESTAMP + 1); + dataFetch.mockRejectedValueOnce(firstError); + expect((await instance.evaluate('feature')).value).toBe(false); + + vi.setSystemTime(TIMESTAMP + 1_001); + setVersion(undefined); + dataFetch.mockRejectedValueOnce(new Error('poll failure')); + await expect(instance.evaluate('feature')).rejects.toBe(firstError); + await expect(instance.getDatafile()).rejects.toBe(firstError); + expect(dataFetch).toHaveBeenCalledTimes(2); + + mockDatafileResponse(TIMESTAMP); + await vi.advanceTimersByTimeAsync(30_000); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(3); + expect(streamFetch).not.toHaveBeenCalled(); + }); + + it('continues polling after the first fallback poll times out', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const firstPoll = deferred(); + dataFetch.mockReturnValueOnce(firstPoll.promise); + const instance = client({ stream: false, disableMetrics: true }); + setVersion(undefined); + const reading = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(3_000); + expect(await reading).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'STALE' }, + }); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); + + mockDatafileResponse(TIMESTAMP + 2, true); + await vi.advanceTimersByTimeAsync(30_000); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + firstPoll.resolve(Response.json(datafile(TIMESTAMP + 1))); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('continues connecting after fallback stream initialization times out', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stream = mockStream(); + streamFetch.mockResolvedValueOnce(stream.response); + const instance = client(); + setVersion(undefined); + const reading = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(3_000); + expect(await reading).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 1, true) }); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + expect(streamFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it.each([ + 0, 3_000, + ])('does not start a polling interval after shutdown with timeout %i', async (initTimeoutMs) => { + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client({ + stream: false, + polling: { intervalMs: 30_000, initTimeoutMs }, + disableMetrics: true, + }); + setVersion(undefined); + const reading = instance.evaluate('feature'); + const rejection = expect(reading).rejects.toThrow('Client is shut down'); + await vi.advanceTimersByTimeAsync(0); + await instance.shutdown(); + clients.delete(instance); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await rejection; + await vi.advanceTimersByTimeAsync(60_000); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); }); it('retains the original cold request header when the request context changes during a fetch', async () => { @@ -436,8 +660,11 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(mode === 'vercel' ? 1 : 0); }); - it('does not enable runtime header refresh during a build', async () => { - setVersion(TIMESTAMP + 20_000); + it.each([ + undefined, + TIMESTAMP + 20_000, + ])('does not enable runtime sources during a build with header %s', async (version) => { + setVersion(version); const instance = client({ buildStep: true }); const result = await instance.evaluate('feature'); @@ -445,6 +672,7 @@ describe('Vercel mode (black-box)', () => { expect(result.value).toBe(false); expect(result.metrics?.mode).toBe('build'); expect(dataFetch).not.toHaveBeenCalled(); + expect(streamFetch).not.toHaveBeenCalled(); }); it.each([ @@ -528,7 +756,7 @@ describe('Vercel mode (black-box)', () => { }); it('rechecks new request versions instead of caching the first HIT forever', async () => { - const instance = client(); + const instance = client({ stream: false }); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( 'HIT', ); @@ -553,10 +781,12 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(2); setVersion(); - expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'STALE', - ); - expect(dataFetch).toHaveBeenCalledTimes(2); + mockDatafileResponse(TIMESTAMP + 60_000, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(3); }); it('does not make a fresh request wait on another requests blocking refresh', async () => { @@ -842,7 +1072,6 @@ describe('Vercel mode (black-box)', () => { it.each([ ['older', TIMESTAMP - 1], - ['missing', undefined], ['malformed', 'invalid'], ['newer', TIMESTAMP + 1], ] as const)('does not renew freshness for %s headers', async (_kind, version) => { @@ -1103,8 +1332,8 @@ describe('Vercel mode (black-box)', () => { expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); vi.setSystemTime(TIMESTAMP + 1_001); - // Older/missing evidence must not reset the first-error deadline or fetch. - for (const version of [undefined, 'invalid', TIMESTAMP - 1, TIMESTAMP]) { + // Older/malformed evidence must not reset the first-error deadline or fetch. + for (const version of ['invalid', TIMESTAMP - 1, TIMESTAMP]) { setVersion(version); await expect(instance.evaluate('feature')).rejects.toBe(firstError); await expect(instance.getDatafile()).rejects.toBe(firstError); @@ -1172,7 +1401,6 @@ describe('Vercel mode (black-box)', () => { }); it.each([ - '', 'flags_other=123', `flags_${PROJECT_ID}=0`, `flags_${PROJECT_ID}=-1`, From 6af0325d8df3ff1132faa7b0e143ea174cf371a6 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 15:27:56 +0200 Subject: [PATCH 18/20] fix(flags-core): confirm cache recovery on stream pings --- .changeset/header-driven-vercel-mode.md | 2 +- packages/vercel-flags-core/CLAUDE.md | 7 ++-- packages/vercel-flags-core/README.md | 7 ++-- .../vercel-flags-core/src/controller/index.ts | 4 +- .../src/stream-stale-if-error.test.ts | 40 ++++++++++++++----- 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index e61301cff..a14ad8a3c 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -6,6 +6,6 @@ Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. In An evaluation with a missing or empty version header permanently starts streaming when enabled, otherwise polling. Concurrent reads share startup, and pending header refreshes are cancelled without losing cached data or resetting stale-if-error. Initialization and snapshot reads do not trigger this switch. -The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age without clearing or extending a stale-if-error failure. Source schedules stay unchanged. +The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age and clear stale-if-error failures because each connection sends `primed` or a datafile first. Source schedules stay unchanged. Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 9eef40af4..fecdfb2a8 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -254,7 +254,7 @@ When updating tests for new behavior, preserve the strength of existing assertio ### Stream Connection - Uses fetch with streaming body (NDJSON format) -- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onPing` (age-only reset), `onDisconnect`, and `onError` (failure evidence for cache policy) +- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onPing` (resets age and clears failure), `onDisconnect`, and `onError` (failure evidence for cache policy) - Sends `X-Revision` header with the current revision number on every connection (including reconnects), allowing the server to respond with a lightweight `primed` message instead of a full datafile when the revision is current - The `primed` message confirms the client's data is up-to-date; it resolves the init promise (like `datafile`) but does not update data — resets cache age and clears a failure when revision/identity match, then transitions state to `streaming` - Reconnects with exponential backoff (base: 1s, max: 60s, max retries: 15) @@ -326,7 +326,7 @@ boundary. `hasData` and `revision` expose coordination metadata even after expir so retained data is not replaced by fallback and stream reconnects can still send `X-Revision`. `seed()` never clears failure. Accepted source updates or valid version/revision confirmations clear it; repeated errors/disconnects do not renew -the first-error deadline. Stream opening/pings and initialization timeout alone +the first-error deadline. Stream opening and initialization timeout alone are not recovery/failure evidence respectively. `cache.resolve(policy)` receives a mode-specific `getStatus` callback returning @@ -337,7 +337,8 @@ forwarded through controller event wiring. Stream/poll modes omit on-read revali and retain their existing schedules. New public time windows use seconds; internal normalized durations, cache age, and `fetchedAt` use milliseconds. Polling is fresh through its interval; streaming through 30 seconds. Accepted updates, valid confirmations, -and stream pings reset cache age. Pings preserve any failure and its original deadline. +and stream pings reset cache age. Pings also clear failures: each connection sends +`primed` or a datafile before pings, so they confirm recovery without rewriting `fetchedAt`. Polling errors use the shared source-error handler without logging each failed poll. ### Evaluation Safety diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 56782e6f5..119c056ce 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -84,8 +84,9 @@ The allowance starts at the first consecutive failure. Repeated errors, disconnects, and provided or bundled fallback data do not renew it. An accepted source update, or a finite equal version for the same project and environment, clears the outage. A stream `primed` message also clears it when its finite numeric -revision and identity match the cached entry. Opening a connection or receiving -a ping alone does not clear a failure. A later failure starts a new allowance. +revision and identity match the cached entry. Pings clear failures too: the server +sends `primed` or a datafile before pings on each connection. Opening a connection +alone does not clear a failure. A later failure starts a new allowance. Responses are observed in completion order, with existing version acceptance. After expiry, `evaluate()` returns the caller's default with reason `error`, or @@ -98,7 +99,7 @@ exists. `getFallbackDatafile()` remains an independent bundled-data export. Polling data is marked stale after the polling interval; streaming data after 30 seconds. Accepted updates and valid confirmations reset cache age without rewriting -`fetchedAt`. Stream pings also reset age, while preserving any failure and its deadline. +`fetchedAt`. Stream pings also reset age and clear any failure. Age alone does not prevent stream/poll reads or trigger extra requests. Source scheduling, retries, timeouts, and build/offline behavior remain unchanged. Poll errors feed the shared failure handler without logging each failed poll. An initialization timeout diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index cf3cbef75..7b5288164 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -163,8 +163,8 @@ export class Controller implements ControllerInterface { } }; private onStreamPing = () => { - // Pings refresh age but do not prove recovery from a recorded failure. - this.cache.resetAge(); + // Each connection sends primed/datafile before pings, so a ping confirms recovery. + this.cache.confirm(); }; private onStreamConnected = () => { if (this.state === 'degraded' || this.state === 'initializing:stream') { diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index 04e9305f2..5759c7e75 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -227,8 +227,9 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0', '1', '2', '3', '4', '5', '6', '7']); }); - it('keeps the inclusive first-error deadline through retries, HTTP open, pings, and connected events', async () => { + it('keeps the first-error deadline through retries and unconfirmed messages, then recovers on ping', async () => { const { instance, stream } = await start({ staleIfError: 3 }); + const snapshot = await instance.getDatafile(); const first = new Error('first stream read failed'); const repeated = new Error('reconnect failed'); const reconnect = mockStream(); @@ -246,11 +247,9 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0', '1']); await vi.advanceTimersByTimeAsync(1); expectRequests(['0', '1', '2']); - reconnect.push({ type: 'ping' }); // These messages emit connected but neither confirms the cached snapshot. reconnect.push(primed({ revision: 6 })); reconnect.push({ type: 'datafile', data: data({ configUpdatedAt: 9 }) }); - reconnect.push({ type: 'ping' }); await vi.advanceTimersByTimeAsync(1_000); expect(await instance.evaluate('flagA')).toMatchObject({ value: true, @@ -263,9 +262,6 @@ describe('stream stale-if-error through the public API', () => { expect((await instance.getDatafile()).configUpdatedAt).toBe(10); await vi.advanceTimersByTimeAsync(1); await expectExpired(instance, first); - reconnect.push({ type: 'ping' }); - await vi.advanceTimersByTimeAsync(0); - await expectExpired(instance, first); const fallback = { value: false, variantId: null, @@ -282,6 +278,16 @@ describe('stream stale-if-error through the public API', () => { flagA: fallback, missing: { ...fallback, value: undefined }, }); + reconnect.push({ type: 'ping' }); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('flagA')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'HIT' }, + }); + const recovered = await instance.getDatafile(); + expect(recovered).toEqual(snapshot); + expect(recovered.definitions).toBe(snapshot.definitions); + expect(recovered.fetchedAt).toBe(0); expectRequests(['0', '1', '2']); }); @@ -565,7 +571,7 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0', '1']); }); - it('starts SIE only at ping timeout and keeps the original failure through another ping timeout', async () => { + it('starts a new SIE allowance when a recovered stream times out again', async () => { const { instance } = await start({ staleIfError: 0.1 }); const reconnect = mockStream(); const third = mockStream(); @@ -577,7 +583,6 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0']); await vi.advanceTimersByTimeAsync(2); expectRequests(['0', '1']); - reconnect.push({ type: 'ping' }); await vi.advanceTimersByTimeAsync(99); expect((await instance.evaluate('flagA')).value).toBe(true); await vi.advanceTimersByTimeAsync(1); @@ -587,8 +592,23 @@ describe('stream stale-if-error through the public API', () => { expect(failure).toBeInstanceOf(Error); expect(failure).toMatchObject({ message: 'stream: disconnected' }); await expectExpired(instance, failure as Error); - await vi.advanceTimersByTimeAsync(89_901); - await expectExpired(instance, failure as Error); + + reconnect.push(primed()); + reconnect.push({ type: 'ping' }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.evaluate('flagA')).value).toBe(true); + await vi.advanceTimersByTimeAsync(90_000); + expect((await instance.evaluate('flagA')).value).toBe(true); + await vi.advanceTimersByTimeAsync(100); + expect((await instance.evaluate('flagA')).value).toBe(true); + await vi.advanceTimersByTimeAsync(1); + const second = await instance + .evaluate('flagA') + .catch((error: unknown) => error); + expect(second).toBeInstanceOf(Error); + expect(second).toMatchObject({ message: 'stream: disconnected' }); + expect(second).not.toBe(failure); + await expectExpired(instance, second as Error); expectRequests(['0', '1', '1']); }); From dcf59836f9c1424e935ddda83708e47f473921ce Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 16:54:07 +0200 Subject: [PATCH 19/20] refactor(flags-core): share runtime fallback resolution --- packages/vercel-flags-core/CLAUDE.md | 3 + .../vercel-flags-core/src/controller/index.ts | 121 +++++++------- .../src/vercel-mode.black-box.test.ts | 151 +++++++++++++++--- 3 files changed, 203 insertions(+), 72 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index fecdfb2a8..cf37ea2fe 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -130,6 +130,9 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu start streaming if enabled, otherwise polling, using the existing startup timeouts. Concurrent reads share source startup. Pending header fetches are cancelled without clearing stored data or the failure deadline; their readers resume through the new source. + Missing-header reads use the shared `resolveDataWithFallbacks()` path. Its pending + promise is cleared on completion; a separate flag keeps header mode disabled. + Source startup falls back to the current cache before provided/bundled definitions. - Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error. - `getDatafile()` remains a snapshot read: it enforces the same failure policy but does not inspect request headers. Disabling both stream and polling selects offline mode. diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 7b5288164..4ed1e72fc 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -105,8 +105,9 @@ export class Controller implements ControllerInterface { private pollingSource: PollingSource; private bundledSource: BundledSource; private headerSource: HeaderSource; - // Retain the startup promise so fallback stays permanent and concurrent reads share it. - private headerFallback: Promise | undefined; + private headerModeDisabled = false; + // Share only pending fallback work; later reads must use the current cache. + private fallbackPromise: Promise | undefined; // Usage tracking private usageTracker: UsageTracker; @@ -281,7 +282,7 @@ export class Controller implements ControllerInterface { } // Select header mode after hydration so provided/bundled data avoids a cold fetch. - if (this.headerSource.isAvailable() && !this.headerFallback) { + if (this.headerSource.isAvailable() && !this.headerModeDisabled) { this.transition('vercel'); return; } @@ -466,48 +467,25 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } - if (this.state === 'vercel' && !this.headerSource.hasVersionHeader()) { - this.headerFallback = this.initializeHeaderFallback(); - } - - if (this.headerFallback) { - await this.headerFallback; - if (this.state === 'shutdown') { - throw new Error('@vercel/flags-core: Client is shut down'); - } + const usingHeaders = this.state === 'vercel'; + if ( + (usingHeaders && !this.headerSource.hasVersionHeader()) || + this.fallbackPromise + ) { + return this.resolveDataWithFallbacks(); } - const usingHeaders = this.state === 'vercel'; try { const result = await this.cache.resolve(this.cacheReadPolicy); if (result) return result; } catch (error) { - // A concurrent read may have switched sources and cancelled this header fetch. - if (usingHeaders && this.headerFallback) return this.resolveData(); - throw error; + // A cancelled header read joins the same fallback path as the switching read. + if (!usingHeaders || !this.headerModeDisabled) throw error; } return this.resolveDataWithFallbacks(); } - private async initializeHeaderFallback(): Promise { - this.cache.cancelFetch(); - this.headerSource.stop(); - - if (this.options.stream.enabled) { - this.transition('initializing:stream'); - await this.tryInitializeStream(); - return; - } - - this.transition('initializing:polling'); - await this.tryInitializePolling(); - if (this.state === 'shutdown') return; - // A timed-out first poll must not leave the fallback without future updates. - this.pollingSource.startInterval(); - this.transition('polling'); - } - private get cacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { @@ -754,31 +732,64 @@ export class Controller implements ControllerInterface { ); } - /** - * Retrieves data using the fallback chain (called when no cached data exists). - * Streaming mode: stream → datafile → bundled. - * Polling mode: poll → datafile → bundled. - * Offline mode: datafile → bundled → one-time fetch. - */ + private assertActive(): void { + if (this.state === 'shutdown') { + throw new Error('@vercel/flags-core: Client is shut down'); + } + } + + /** Share startup, then read again so completion never bypasses stale-if-error. */ private async resolveDataWithFallbacks(): Promise< [TaggedData, Metrics['cacheStatus']] > { - // Try the configured primary source + this.assertActive(); + this.fallbackPromise ??= this.loadDataWithFallbacks().finally(() => { + this.fallbackPromise = undefined; + }); + const cacheStatus = await this.fallbackPromise; + this.assertActive(); + return [this.cache.read()!, cacheStatus]; + } + + /** Start stream/poll, then fall back to current cache → provided → bundled. */ + private async loadDataWithFallbacks(): Promise { + const switchingFromHeaders = this.state === 'vercel'; + if (switchingFromHeaders) { + this.headerModeDisabled = true; + this.cache.cancelFetch(); + this.headerSource.stop(); + } else { + // A cancelled header fetch can finish after the new source is already ready. + const cached = await this.cache.resolve(this.cacheReadPolicy); + if (cached) return cached[1]; + } + this.assertActive(); + + let sourceInitialized = false; if (this.options.stream.enabled) { this.transition('initializing:stream'); - const streamSuccess = await this.tryInitializeStream(); - if (streamSuccess && this.cache.hasData) { - this.transition('streaming'); - return [this.cache.read()!, 'MISS']; - } + sourceInitialized = await this.tryInitializeStream(); } else if (this.options.polling.enabled) { this.transition('initializing:polling'); - const pollingSuccess = await this.tryInitializePolling(); - if (pollingSuccess && this.cache.hasData) { - this.transition('polling'); - return [this.cache.read()!, 'MISS']; - } + sourceInitialized = await this.tryInitializePolling(); + } + this.assertActive(); + + if (sourceInitialized && !switchingFromHeaders) { + this.transition(this.options.stream.enabled ? 'streaming' : 'polling'); + } + if (switchingFromHeaders && !this.options.stream.enabled) { + // Header fallback must keep updating even when its first poll times out. + this.pollingSource.startInterval(); + this.transition('polling'); + } + + // Preserve newer cached data on timeout; expired failures must throw before reseeding. + const cached = await this.cache.resolve(this.cacheReadPolicy); + if (cached) { + return sourceInitialized && !switchingFromHeaders ? 'MISS' : cached[1]; } + this.assertActive(); // Fallback chain: datafile → bundled → one-time fetch this.transition('initializing:fallback'); @@ -786,15 +797,16 @@ export class Controller implements ControllerInterface { if (this.options.datafile) { this.cache.seed(tagData(this.options.datafile, 'provided')); this.transition('degraded'); - return [this.cache.read()!, 'STALE']; + return 'STALE'; } const bundled = await this.bundledSource.tryLoad(); + this.assertActive(); if (bundled) { console.warn('@vercel/flags-core: Using bundled definitions as fallback'); this.cache.seed(tagData(bundled, 'bundled')); this.transition('degraded'); - return [this.cache.read()!, 'STALE']; + return 'STALE'; } // Last resort: one-time fetch (only when no stream/poll configured) @@ -809,10 +821,11 @@ export class Controller implements ControllerInterface { } catch { // fetch failed — fall through to throw } + this.assertActive(); if (fetched) { this.cache.seed(tagData(fetched, 'fetched')); this.transition('degraded'); - return [this.cache.read()!, 'MISS']; + return 'MISS'; } } diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 477e60313..3a0bb05a5 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -287,59 +287,174 @@ describe('Vercel mode (black-box)', () => { }); it.each([ - 'success', - 'failure', - ])('discards a cancelled header refresh ending in %s after polling takes over', async (outcome) => { + ['success', 'before'], + ['failure', 'before'], + ['success', 'after'], + ['failure', 'after'], + ] as const)('discards a cancelled header refresh ending in %s %s polling is ready', async (outcome, timing) => { const pendingHeader = deferred(); dataFetch.mockReturnValueOnce(pendingHeader.promise); const instance = client({ stream: false, staleIfError: 0 }); setVersion(TIMESTAMP + 1); - const originalRead = instance.evaluate('feature'); + const settled = vi.fn(); + const originalRead = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); await vi.advanceTimersByTimeAsync(0); const signal = dataFetch.mock.calls[0]?.[1]?.signal; setVersion(undefined); - mockDatafileResponse(TIMESTAMP + 2, true); - expect(await instance.evaluate('feature')).toMatchObject({ - value: true, - metrics: { mode: 'polling' }, - }); + const pendingPoll = deferred(); + dataFetch.mockReturnValueOnce(pendingPoll.promise); + const switchingRead = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); expect(signal?.aborted).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(2); + if (timing === 'after') { + pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2, true))); + await switchingRead; + } if (outcome === 'success') { pendingHeader.resolve(Response.json(datafile(TIMESTAMP + 3))); } else { pendingHeader.reject(new Error('late header failure')); } + await vi.advanceTimersByTimeAsync(0); + if (timing === 'before') { + expect(settled).not.toHaveBeenCalled(); + pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2, true))); + } + + for (const result of await Promise.all([originalRead, switchingRead])) { + expect(result).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); + } + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['streaming', 'provided'], + ['streaming', 'bundled'], + ['polling', 'provided'], + ['polling', 'bundled'], + ] as const)('retains newer cached data over %s startup timeout and the original %s seed', async (mode, seed) => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: datafile(), + state: 'ok', + }); + const instance = client({ + stream: mode === 'streaming', + datafile: seed === 'provided' ? datafile() : undefined, + staleWhileRevalidate: 0, + disableMetrics: true, + }); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await instance.evaluate('feature')).value).toBe(true); + const snapshot = await instance.getDatafile(); + await vi.advanceTimersByTimeAsync(30_001); - expect(await originalRead).toMatchObject({ + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stream = mockStream(); + streamFetch.mockResolvedValueOnce(stream.response); + const pendingPoll = deferred(); + dataFetch.mockReturnValueOnce(pendingPoll.promise); + setVersion(undefined); + const reading = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(3_000); + expect(await reading).toMatchObject({ + value: true, + metrics: { source: 'remote', cacheStatus: 'STALE' }, + }); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + mode === 'streaming' + ? '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background' + : '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); + const retained = await instance.getDatafile(); + expect(retained.configUpdatedAt).toBe(TIMESTAMP + 1); + expect(retained.definitions).toBe(snapshot.definitions); + expect(retained.fetchedAt).toBe(snapshot.fetchedAt); + expect(readBundledDefinitions).toHaveBeenCalledTimes( + seed === 'bundled' ? 1 : 0, + ); + expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 1); + + // A later source update must replace the cache, not reuse a completed fallback result. + setVersion(TIMESTAMP + 100); + if (mode === 'streaming') { + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 2) }); + } else { + pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2))); + } + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 1); + }); + + it('retries an empty-cache fallback after startup fails', async () => { + const instance = client({ + stream: false, + datafile: undefined, + disableMetrics: true, + }); + setVersion(undefined); + dataFetch.mockRejectedValueOnce(new Error('poll failed')); + await expect(instance.evaluate('feature')).rejects.toThrow( + 'No flag definitions available', + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + + setVersion(TIMESTAMP + 100); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ value: true, metrics: { mode: 'polling' }, }); - expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); expect(dataFetch).toHaveBeenCalledTimes(2); + expect(streamFetch).not.toHaveBeenCalled(); }); it('preserves the failure deadline through fallback until polling confirms recovery', async () => { - const instance = client({ stream: false, staleIfError: 1 }); - const firstError = new Error('header failure'); + const instance = client({ + stream: false, + staleIfError: 1, + staleWhileRevalidate: 0, + }); setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await instance.evaluate('feature')).value).toBe(true); + const snapshot = await instance.getDatafile(); + const firstError = new Error('header failure'); + setVersion(TIMESTAMP + 2); dataFetch.mockRejectedValueOnce(firstError); - expect((await instance.evaluate('feature')).value).toBe(false); + expect((await instance.evaluate('feature')).value).toBe(true); vi.setSystemTime(TIMESTAMP + 1_001); setVersion(undefined); dataFetch.mockRejectedValueOnce(new Error('poll failure')); await expect(instance.evaluate('feature')).rejects.toBe(firstError); await expect(instance.getDatafile()).rejects.toBe(firstError); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(3); - mockDatafileResponse(TIMESTAMP); + mockDatafileResponse(TIMESTAMP + 1, true); await vi.advanceTimersByTimeAsync(30_000); expect(await instance.evaluate('feature')).toMatchObject({ - value: false, + value: true, metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); - expect(dataFetch).toHaveBeenCalledTimes(3); + const recovered = await instance.getDatafile(); + expect(recovered.definitions).toBe(snapshot.definitions); + expect(recovered.fetchedAt).toBe(snapshot.fetchedAt); + expect(dataFetch).toHaveBeenCalledTimes(4); expect(streamFetch).not.toHaveBeenCalled(); }); From a22fcdee12f6d3ab5938214c6df02856193ef6d1 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Fri, 25 Sep 2026 12:26:38 +0200 Subject: [PATCH 20/20] refactor(flags-core): keep header fallback on the existing path --- packages/vercel-flags-core/CLAUDE.md | 5 +- .../src/controller/header-source.ts | 6 +- .../vercel-flags-core/src/controller/index.ts | 124 +++++++++--------- 3 files changed, 64 insertions(+), 71 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index cf37ea2fe..cc69019c7 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -130,9 +130,8 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu start streaming if enabled, otherwise polling, using the existing startup timeouts. Concurrent reads share source startup. Pending header fetches are cancelled without clearing stored data or the failure deadline; their readers resume through the new source. - Missing-header reads use the shared `resolveDataWithFallbacks()` path. Its pending - promise is cleared on completion; a separate flag keeps header mode disabled. - Source startup falls back to the current cache before provided/bundled definitions. + `resolveData()` checks header availability and uses `resolveDataWithFallbacks()` + to start the configured source. Handover retains cached data before considering seeds. - Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error. - `getDatafile()` remains a snapshot read: it enforces the same failure policy but does not inspect request headers. Disabling both stream and polling selects offline mode. diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index e8a19aa06..dabbc481c 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -26,8 +26,8 @@ export class HeaderSource extends TypedEmitter { ); } - hasVersionHeader(): boolean { - return Boolean(this.getVersionHeader()); + isAvailable(): boolean { + return this.isEnabled() && Boolean(this.getVersionHeader()); } /** Capture the header now so a shared fetch cannot switch the request being assessed. */ @@ -78,7 +78,7 @@ export class HeaderSource extends TypedEmitter { this.emit('data', data); }; - isAvailable(): boolean { + isEnabled(): boolean { // Explicit offline mode disables header-driven refreshes too. return ( this.options.vercel && diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 4ed1e72fc..9b9c0a2fd 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -106,8 +106,9 @@ export class Controller implements ControllerInterface { private bundledSource: BundledSource; private headerSource: HeaderSource; private headerModeDisabled = false; - // Share only pending fallback work; later reads must use the current cache. - private fallbackPromise: Promise | undefined; + private sourceStartup: + | Promise<[TaggedData, Metrics['cacheStatus']]> + | undefined; // Usage tracking private usageTracker: UsageTracker; @@ -282,7 +283,7 @@ export class Controller implements ControllerInterface { } // Select header mode after hydration so provided/bundled data avoids a cold fetch. - if (this.headerSource.isAvailable() && !this.headerModeDisabled) { + if (this.headerSource.isEnabled() && !this.headerModeDisabled) { this.transition('vercel'); return; } @@ -468,20 +469,38 @@ export class Controller implements ControllerInterface { } const usingHeaders = this.state === 'vercel'; - if ( - (usingHeaders && !this.headerSource.hasVersionHeader()) || - this.fallbackPromise - ) { - return this.resolveDataWithFallbacks(); + if (usingHeaders && !this.headerSource.isAvailable()) { + this.headerModeDisabled = true; + this.cache.cancelFetch(); + this.headerSource.stop(); + // The existing fallback chain starts stream/poll; concurrent reads share it. + this.sourceStartup = this.resolveDataWithFallbacks().finally(() => { + this.sourceStartup = undefined; + }); + } + if (this.sourceStartup) { + await this.sourceStartup; + if (this.state === 'shutdown') { + throw new Error('@vercel/flags-core: Client is shut down'); + } } + let result: [TaggedData, Metrics['cacheStatus']] | undefined; try { - const result = await this.cache.resolve(this.cacheReadPolicy); - if (result) return result; + result = await this.cache.resolve(this.cacheReadPolicy); } catch (error) { - // A cancelled header read joins the same fallback path as the switching read. - if (!usingHeaders || !this.headerModeDisabled) throw error; + if ( + !usingHeaders || + !this.headerModeDisabled || + this.state === 'shutdown' + ) { + throw error; + } + // An old header fetch may finish after handover; read from the new source. + if (this.sourceStartup) await this.sourceStartup; + result = await this.cache.resolve(this.cacheReadPolicy); } + if (result) return result; return this.resolveDataWithFallbacks(); } @@ -732,64 +751,41 @@ export class Controller implements ControllerInterface { ); } - private assertActive(): void { - if (this.state === 'shutdown') { - throw new Error('@vercel/flags-core: Client is shut down'); - } - } - - /** Share startup, then read again so completion never bypasses stale-if-error. */ + /** + * Retrieves data when the cache is empty or header mode is unavailable. + * Streaming mode: stream → datafile → bundled. + * Polling mode: poll → datafile → bundled. + * Offline mode: datafile → bundled → one-time fetch. + */ private async resolveDataWithFallbacks(): Promise< [TaggedData, Metrics['cacheStatus']] > { - this.assertActive(); - this.fallbackPromise ??= this.loadDataWithFallbacks().finally(() => { - this.fallbackPromise = undefined; - }); - const cacheStatus = await this.fallbackPromise; - this.assertActive(); - return [this.cache.read()!, cacheStatus]; - } - - /** Start stream/poll, then fall back to current cache → provided → bundled. */ - private async loadDataWithFallbacks(): Promise { const switchingFromHeaders = this.state === 'vercel'; - if (switchingFromHeaders) { - this.headerModeDisabled = true; - this.cache.cancelFetch(); - this.headerSource.stop(); - } else { - // A cancelled header fetch can finish after the new source is already ready. - const cached = await this.cache.resolve(this.cacheReadPolicy); - if (cached) return cached[1]; - } - this.assertActive(); - - let sourceInitialized = false; + // Try the configured primary source if (this.options.stream.enabled) { this.transition('initializing:stream'); - sourceInitialized = await this.tryInitializeStream(); + const streamSuccess = await this.tryInitializeStream(); + if (streamSuccess && this.cache.hasData) { + this.transition('streaming'); + return [this.cache.read()!, 'MISS']; + } } else if (this.options.polling.enabled) { this.transition('initializing:polling'); - sourceInitialized = await this.tryInitializePolling(); - } - this.assertActive(); - - if (sourceInitialized && !switchingFromHeaders) { - this.transition(this.options.stream.enabled ? 'streaming' : 'polling'); - } - if (switchingFromHeaders && !this.options.stream.enabled) { - // Header fallback must keep updating even when its first poll times out. - this.pollingSource.startInterval(); - this.transition('polling'); + const pollingSuccess = await this.tryInitializePolling(); + if (switchingFromHeaders && this.state !== 'shutdown') { + // Missing headers must not leave the client without updates after a timeout. + this.pollingSource.startInterval(); + this.transition('polling'); + } + if (pollingSuccess && this.cache.hasData) { + this.transition('polling'); + return [this.cache.read()!, 'MISS']; + } } - // Preserve newer cached data on timeout; expired failures must throw before reseeding. - const cached = await this.cache.resolve(this.cacheReadPolicy); - if (cached) { - return sourceInitialized && !switchingFromHeaders ? 'MISS' : cached[1]; - } - this.assertActive(); + // Handover can start with newer cached data; do not replace it with the seed. + const cached = this.cache.read(); + if (cached) return [cached, 'STALE']; // Fallback chain: datafile → bundled → one-time fetch this.transition('initializing:fallback'); @@ -797,16 +793,15 @@ export class Controller implements ControllerInterface { if (this.options.datafile) { this.cache.seed(tagData(this.options.datafile, 'provided')); this.transition('degraded'); - return 'STALE'; + return [this.cache.read()!, 'STALE']; } const bundled = await this.bundledSource.tryLoad(); - this.assertActive(); if (bundled) { console.warn('@vercel/flags-core: Using bundled definitions as fallback'); this.cache.seed(tagData(bundled, 'bundled')); this.transition('degraded'); - return 'STALE'; + return [this.cache.read()!, 'STALE']; } // Last resort: one-time fetch (only when no stream/poll configured) @@ -821,11 +816,10 @@ export class Controller implements ControllerInterface { } catch { // fetch failed — fall through to throw } - this.assertActive(); if (fetched) { this.cache.seed(tagData(fetched, 'fetched')); this.transition('degraded'); - return 'MISS'; + return [this.cache.read()!, 'MISS']; } }