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/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md new file mode 100644 index 000000000..a14ad8a3c --- /dev/null +++ b/.changeset/header-driven-vercel-mode.md @@ -0,0 +1,11 @@ +--- +"@vercel/flags-core": minor +--- + +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 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/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..06f27cbf1 100644 --- a/packages/prepare-flags-definitions/src/index.ts +++ b/packages/prepare-flags-definitions/src/index.ts @@ -183,7 +183,9 @@ async function fetchDatafile( } if (res.ok) { - return res.json() as Promise; + 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() }; } if (res.status === 404) { diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index f8128cd2d..cc69019c7 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 + 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 waitUntil?: (promise: Promise) => void; // default: @vercel/functions waitUntil @@ -112,7 +115,28 @@ 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`. The cache owns freshness age. +- A matching header confirms freshness only when no newer version has been observed. +- 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 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. +- 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. + `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. + +**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 @@ -232,9 +256,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` (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 — 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 @@ -260,7 +284,11 @@ 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. +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 @@ -293,16 +321,28 @@ 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` 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 +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 +`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, 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 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 - 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..119c056ce 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -33,10 +33,40 @@ 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. 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!, { + 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 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. + +`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!, { @@ -54,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 @@ -66,9 +97,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. -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, -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 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 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/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/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts new file mode 100644 index 000000000..d00ccc129 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -0,0 +1,581 @@ +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, + type 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', + 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 }); + 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(() => {}); +}); +afterEach(() => { + try { + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + vi.useRealTimers(); + } +}); + +describe('cache read callbacks', () => { + 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 === '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(() => 'fresh' as const); + expect(await cache.resolve({ getStatus })).toBeUndefined(); + expect(getStatus).not.toHaveBeenCalled(); + }); + + it.each([ + '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); + const policy = { + getStatus: vi.fn(() => status), + fetch: vi.fn(async () => {}), + }; + + expect(await cache.resolve(policy)).toEqual([ + original, + status === 'fresh' ? 'HIT' : 'STALE', + ]); + expect(policy.getStatus).toHaveBeenCalledExactlyOnceWith({ + projectId: 'prj_policy', + environment: 'production', + configUpdatedAt: 1, + revision: undefined, + ageMs: Infinity, + }); + const failure = new Error('outage'); + cache.fail(failure); + await expect(cache.resolve(policy)).rejects.toBe(failure); + 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: () => 'expired' as const, 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: () => 'expired' as const, 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: () => 'expired' as const, 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: () => 'stale' as const, 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 () => { + const waitUntil = vi.fn(); + const cache = new DatafileCache(Infinity, waitUntil); + const original = tagData(data(), 'provided'); + cache.seed(original); + const pending = deferred(); + const fetch = vi.fn(async () => { + await pending.promise; + cache.updateFromSource(data(2), 'fetched'); + }); + 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: () => 'expired' as const }) + .then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(fetch).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 fetch = vi + .fn>() + .mockRejectedValueOnce(failure); + const policy = { getStatus: () => 'stale' as const, fetch }; + + 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); + + fetch.mockImplementationOnce(async () => + cache.updateFromSource(data(2), 'fetched'), + ); + expect((await cache.resolve(policy))?.[1]).toBe('MISS'); + expect(cache.read()?.configUpdatedAt).toBe(2); + expect(fetch).toHaveBeenCalledTimes(2); + expect(waitUntil).toHaveBeenCalledTimes(1); + }); + + it('contains synchronous fetch failures and permits a later retry', async () => { + const cache = new DatafileCache(); + const failure = new Error('synchronous failure'); + const fetch = vi.fn>(() => { + throw failure; + }); + const policy = { getStatus: () => 'expired' as const, fetch }; + await expect(cache.resolve(policy)).rejects.toBe(failure); + fetch.mockImplementationOnce(async () => + cache.updateFromSource(data(), 'fetched'), + ); + expect((await cache.resolve(policy))?.[1]).toBe('MISS'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('cancels queued fetch without invoking the callback', async () => { + const cache = new DatafileCache(); + const fetch = vi.fn(async () => {}); + const reading = cache.resolve({ + getStatus: () => 'expired' as const, + fetch, + }); + const outcome = expect(reading).rejects.toThrow(); + cache.clear(); + await outcome; + expect(fetch).not.toHaveBeenCalled(); + }); + + 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 fetch = vi + .fn>() + .mockImplementationOnce(() => oldPending.promise) + .mockImplementationOnce(async (signal) => { + await nextPending.promise; + signal.throwIfAborted(); + cache.updateFromSource(data(2), 'fetched'); + }); + const policy = { getStatus: () => 'expired' as const, fetch }; + const oldRead = cache.resolve(policy); + const cancelled = expect(oldRead).rejects.toThrow('cancelled transport'); + await vi.advanceTimersByTimeAsync(0); + const oldSignal = fetch.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(fetch).toHaveBeenCalledTimes(2); + nextPending.resolve(); + expect(await Promise.all([nextRead, sharedRead])).toEqual([ + [cache.read(), 'MISS'], + [cache.read(), 'MISS'], + ]); + 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( + '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('unknown'); + expect(confirmed).not.toHaveBeenCalled(); + }); + + it.each([ + [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(); + 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('stale'); + expect(laterCheck).toHaveReturnedWith('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( + '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( + '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); + }); +}); 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..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,8 +599,14 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, origin)).toBeUndefined(); - expect(cache.read()).toBe(incoming); - expect(incoming).toEqual({ ...snapshot, _origin: origin }); + expect(cache.ageMs).toBe(0); + 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 +641,22 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, 'poll')).toBeUndefined(); - expect(cache.read()).toBe(incoming); - expect(incoming).toEqual({ ...snapshot, _origin: 'poll' }); + expect(cache.ageMs).toBe(0); + 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.ageMs).toBe(100); + expect(cache.read()).toBe(accepted); vi.setSystemTime(2_101); expect(() => cache.read()).toThrow(nextError); }); @@ -497,11 +671,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([ @@ -516,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 })); @@ -526,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); @@ -646,29 +825,36 @@ 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); cache.updateFromSource(oldResponse, 'stream'); + expect(cache.ageMs).toBe(50); 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.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 97752a79a..04a2e7b46 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< @@ -6,6 +6,20 @@ type Confirmation = Pick< 'configUpdatedAt' | 'revision' | 'projectId' | 'environment' >; +export type CacheMetadata = Confirmation & { ageMs: number }; + +export type Freshness = 'fresh' | 'stale' | 'expired' | 'unknown'; + +type Fetch = (signal: AbortSignal) => Promise; +type CacheResult = [TaggedData, Metrics['cacheStatus']]; + +export type CacheReadPolicy = { + /** 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. */ + fetch?: Fetch; +}; + /** * Parses a configUpdatedAt value (number or string) into a numeric timestamp. * Returns undefined if the value is missing or cannot be parsed. @@ -19,13 +33,22 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { return undefined; } -/** Storage and failure-relative read policy; callers provide source evidence. */ +/** 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; - constructor(private readonly staleIfErrorMs = Infinity) {} + private abortController = new AbortController(); + private fetching: Promise | undefined; + + constructor( + private readonly staleIfErrorMs = Infinity, + private readonly waitUntil: WaitUntil = () => {}, + ) {} + /** Expired data still exists; fallback loading must not bypass its failure policy. */ get hasData(): boolean { return this.data !== undefined; } @@ -35,15 +58,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. */ + public get metadata(): CacheMetadata | undefined { + if (!this.data) return undefined; + 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; } @@ -75,10 +130,16 @@ export class DatafileCache { return false; } - this.failure = undefined; + this.confirm(); return true; } + /** Confirms the current cache state by clearing failures and resetting age. */ + confirm(): void { + this.resetAge(); + this.failure = undefined; + } + /** Preserves existing acceptance, including missing or unparseable versions. */ private isNewerData(incoming: DatafileInput): boolean { if (!this.data) return true; @@ -94,24 +155,119 @@ export class DatafileCache { } fail(error: Error): void { + // Repeated failures must not keep extending the stale-if-error allowance. 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) { + // The assessment may confirm recovery, so run it before read() checks failure. + const status = policy.getStatus(metadata); + if (status === 'fresh' || status === 'unknown' || !policy.fetch) { + // 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']; + } - const withinAllowance = - this.staleIfErrorMs > 0 && - Date.now() - this.failure.startedAt <= this.staleIfErrorMs; - if (!withinAllowance) { - throw this.failure.error; + // 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); + return [stale, 'STALE']; + } } - return this.data; + + if (!policy.fetch) return; + + const { promise, signal } = this.startFetch(policy.fetch); + 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.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'); + return [data, 'MISS']; + } + + 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() + .then(() => { + signal.throwIfAborted(); + return fetch(signal); + }) + .then(() => signal.throwIfAborted()) + .catch((error) => { + if (!signal.aborted) { + this.fail( + 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.fetching = undefined; + }); + this.fetching = promise; + return { promise, signal }; + } + + 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); + } + }); + try { + this.waitUntil(background); + } catch { + // Registration is best-effort; the handled refresh continues regardless. + } + } + + /** 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 new file mode 100644 index 000000000..dabbc481c --- /dev/null +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -0,0 +1,92 @@ +import type { DatafileInput } from '../types'; +import { getRequestContext } from '../utils/request-context'; +import type { CacheMetadata, CacheReadPolicy } from './datafile-cache'; +import { fetchDatafile } from './fetch-datafile'; +import type { NormalizedOptions } from './normalized-options'; +import { TypedEmitter } from './typed-emitter'; + +export type HeaderSourceEvents = { + data: (data: DatafileInput) => void; + confirmed: (data: CacheMetadata) => void; +}; + +/** Request version evidence and fetching; the cache decides how to serve reads. */ +export class HeaderSource extends TypedEmitter { + private highestObserved = 0; + + constructor(private readonly options: NormalizedOptions) { + super(); + } + + private getVersionHeader(): string | undefined { + const { headers } = getRequestContext(); + return ( + headers?.['x-vercel-flags-config-versions'] ?? + headers?.['flags-config-versions'] + ); + } + + isAvailable(): boolean { + return this.isEnabled() && 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); + 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); + } + + // This request is satisfied; only confirmation above can renew age or clear failure. + if (headerTs <= currentTs) return 'fresh'; + + const { staleWhileRevalidateMs } = this.options; + return staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs + ? 'stale' + : 'expired'; + }; + } + + 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; + } + + fetch = 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); + }; + + isEnabled(): boolean { + // Explicit offline mode disables header-driven refreshes too. + return ( + this.options.vercel && + (this.options.stream.enabled || this.options.polling.enabled) + ); + } + + stop(): void { + 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..9b9c0a2fd 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -10,8 +10,13 @@ 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 { type ControllerOptions, type NormalizedOptions, @@ -41,6 +46,7 @@ type State = | 'initializing:fallback' | 'streaming' | 'polling' + | 'vercel' | 'degraded' | 'build:loading' | 'build:ready' @@ -69,6 +75,12 @@ 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 + * - 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): * - Init fallback: constructor datafile → bundled → one-time fetch → throw * - Read fallback: in-memory value → constructor datafile → bundled → one-time fetch → throw @@ -92,6 +104,11 @@ export class Controller implements ControllerInterface { private streamSource: StreamSource; private pollingSource: PollingSource; private bundledSource: BundledSource; + private headerSource: HeaderSource; + private headerModeDisabled = false; + private sourceStartup: + | Promise<[TaggedData, Metrics['cacheStatus']]> + | undefined; // Usage tracking private usageTracker: UsageTracker; @@ -106,7 +123,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( @@ -115,6 +135,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, @@ -138,12 +159,15 @@ 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 = () => { + // 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') { this.transition('streaming'); @@ -155,15 +179,17 @@ 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 onPollError = (error: Error) => { - this.cache.fail(error); - console.error('@vercel/flags-core: Poll failed:', error); + private onHeaderData = (data: DatafileInput) => { + this.cache.updateFromSource(data, 'fetched'); + }; + private onHeaderConfirmed = (data: CacheMetadata) => { + this.cache.tryConfirm(data); }; // --------------------------------------------------------------------------- @@ -173,21 +199,27 @@ 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.onStreamError); + 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); } 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.onStreamError); + 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); } // --------------------------------------------------------------------------- @@ -198,10 +230,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) { @@ -209,6 +237,8 @@ export class Controller implements ControllerInterface { return 'streaming'; case 'polling': return 'polling'; + case 'vercel': + return 'vercel'; default: return 'offline'; } @@ -224,6 +254,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 { @@ -245,14 +276,18 @@ export class Controller implements ControllerInterface { if (!this.cache.hasData) { try { const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - this.cache.seed(tagData(bundled, 'bundled')); - } + if (bundled) this.cache.seed(tagData(bundled, 'bundled')); } catch { // Bundled definitions not available — proceed without revision } } + // Select header mode after hydration so provided/bundled data avoids a cold fetch. + if (this.headerSource.isEnabled() && !this.headerModeDisabled) { + this.transition('vercel'); + return; + } + // If we already have data (from provided datafile or bundled definitions), // start updates. Both streaming and polling wait for initial data before // being considered initialized, so we know we have fresh data. @@ -314,9 +349,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; @@ -332,6 +368,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')); @@ -355,7 +392,14 @@ export class Controller implements ControllerInterface { if (this.options.buildStep) { [result, cacheStatus] = await this.resolveDataForBuildStep(); } else if (result) { - cacheStatus = this.isConnected ? '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(); @@ -392,9 +436,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; @@ -408,7 +453,7 @@ export class Controller implements ControllerInterface { } // --------------------------------------------------------------------------- - // Data resolution (shared by read() and getDatafile()) + // Data resolution // --------------------------------------------------------------------------- /** @@ -416,23 +461,70 @@ 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) { return this.resolveDataForBuildStep(); } - const data = this.cache.read(); - if (data) { - const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; - return [data, cacheStatus]; + const usingHeaders = this.state === 'vercel'; + 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 { + result = await this.cache.resolve(this.cacheReadPolicy); + } catch (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(); } + private get cacheReadPolicy(): CacheReadPolicy { + if (this.state === 'vercel') { + return { + getStatus: this.headerSource.getStatusCheck(), + fetch: this.headerSource.fetch, + }; + } + + if (this.state === 'streaming') { + 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 }; + } + + return { getStatus: () => 'unknown' }; + } + // --------------------------------------------------------------------------- // Stream initialization // --------------------------------------------------------------------------- @@ -507,7 +599,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; } @@ -537,7 +629,7 @@ export class Controller implements ControllerInterface { return false; } - if (this.cache.hasData) { + if (this.state !== 'shutdown' && this.cache.hasData) { this.pollingSource.startInterval(); return true; } @@ -660,7 +752,7 @@ export class Controller implements ControllerInterface { } /** - * Retrieves data using the fallback chain (called when no cached data exists). + * 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. @@ -668,6 +760,7 @@ export class Controller implements ControllerInterface { private async resolveDataWithFallbacks(): Promise< [TaggedData, Metrics['cacheStatus']] > { + const switchingFromHeaders = this.state === 'vercel'; // Try the configured primary source if (this.options.stream.enabled) { this.transition('initializing:stream'); @@ -679,12 +772,21 @@ export class Controller implements ControllerInterface { } else if (this.options.polling.enabled) { this.transition('initializing: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']; } } + // 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'); diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 78a77a002..7d7e95dde 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 = 10; /** * Configuration options for Controller @@ -45,9 +46,27 @@ 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. + * 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' + */ + 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. + * Accepts finite, non-negative seconds, including fractional seconds. + * Set to 0 to always block on refresh. + * @default 10 + */ + staleWhileRevalidate?: 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 +122,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 +180,21 @@ export function normalizeOptions( }; } + const staleWhileRevalidate = + options.staleWhileRevalidate ?? DEFAULT_STALE_WHILE_REVALIDATE; + if (!Number.isFinite(staleWhileRevalidate) || staleWhileRevalidate < 0) { + throw new Error( + '@vercel/flags-core: staleWhileRevalidate must be a finite, non-negative number of seconds.', + ); + } + return { auth: options.auth, datafile: options.datafile, stream, polling, + vercel: options.vercel ?? process.env.VERCEL === '1', + staleWhileRevalidateMs: staleWhileRevalidate * 1000, staleIfErrorMs: staleIfError * 1000, buildStep, fetch: options.fetch ?? globalThis.fetch, diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index e39cd1915..189fdcff1 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 ? 'fresh' : '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..16f7a76f5 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,11 +70,12 @@ 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; const reportError = (error: unknown): void => { + // Deliberate shutdown must not start a stale-if-error deadline. if (abortController.signal.aborted) return; onError?.( error instanceof Error @@ -244,6 +246,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(); } @@ -273,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/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index c2e7e697e..719716778 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 ? 'fresh' : '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/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..61e6a0e5f 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. + * Stamp live arrivals; reusing provided/bundled data must preserve its original age. */ 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/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..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 @@ -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, @@ -172,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(); @@ -191,14 +247,17 @@ 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 }) }); 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); @@ -219,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']); }); @@ -502,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(); @@ -514,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); @@ -524,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']); }); diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 6b67468db..5e1b6eb52 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; }; @@ -73,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; }; 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..3a0bb05a5 --- /dev/null +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -0,0 +1,1602 @@ +/** 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 }; +} + +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 = () => {}; + +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')); + 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()); + } + 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([ + ['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({ + definitions: datafile(), + state: 'ok', + }); + } + 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: false, + metrics: { mode, cacheStatus: 'HIT' }, + }); + 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((await second).feature).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).not.toHaveBeenCalled(); + }); + + it.each([ + ['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 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); + 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); + + 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(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, + 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(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(3); + + mockDatafileResponse(TIMESTAMP + 1, true); + await vi.advanceTimersByTimeAsync(30_000); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + 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(); + }); + + 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 () => { + 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.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({ + value: true, + metrics: { mode: 'vercel', cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + 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('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', + `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.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'); + + expect(result.value).toBe(false); + expect(result.metrics?.mode).toBe('build'); + expect(dataFetch).not.toHaveBeenCalled(); + expect(streamFetch).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({ stream: false }); + 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(); + 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 () => { + 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({ staleIfError: 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: Revalidation 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, + 0.1, + 20, + ])('honors staleWhileRevalidate=%s at the boundary and on expiry', async (staleWhileRevalidate) => { + const waitUntil = vi.fn(); + const instance = client({ staleWhileRevalidate, waitUntil }); + await instance.evaluate('feature'); + waitUntil.mockClear(); + + const windowMs = (staleWhileRevalidate ?? 10) * 1000; + 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({ staleWhileRevalidate: 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 staleWhileRevalidate=%s', (staleWhileRevalidate) => { + expect(() => client({ staleWhileRevalidate })).toThrow( + 'staleWhileRevalidate must be a finite, non-negative number of seconds', + ); + }); + + 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], + ['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, + ])('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); + 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: delta !== 0, + metrics: { cacheStatus: delta === 0 ? 'STALE' : 'MISS' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); + 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: Revalidation 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( + delta === 0 ? 'STALE' : 'MISS', + ); + await vi.advanceTimersByTimeAsync(0); + 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({ staleIfError: 1 }); + 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/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); + } + 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 }, + staleIfError: 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: Revalidation 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', + }), + }), + ]), + ); + }); +});