From ace5b10d5b1602a7d391cc119b2c313f45a9779c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 08:59:16 +0200 Subject: [PATCH 01/65] refactor --- packages/vercel-flags-core/CLAUDE.md | 18 +- .../src/controller/bundled-source.ts | 88 +++ .../src/controller/fetch-datafile.ts | 66 ++ .../index.test.ts} | 108 ++- .../index.ts} | 658 ++++++++---------- .../src/controller/polling-source.ts | 84 +++ .../stream-connection.test.ts | 0 .../stream-connection.ts | 0 .../src/controller/stream-source.ts | 79 +++ .../src/controller/tagged-data.ts | 38 + .../src/controller/typed-emitter.ts | 34 + .../src/data-source/in-memory-data-source.ts | 48 -- .../vercel-flags-core/src/index.common.ts | 10 +- .../vercel-flags-core/src/index.make.test.ts | 20 +- packages/vercel-flags-core/src/index.make.ts | 9 +- .../vercel-flags-core/src/openfeature.test.ts | 53 +- 16 files changed, 800 insertions(+), 513 deletions(-) create mode 100644 packages/vercel-flags-core/src/controller/bundled-source.ts create mode 100644 packages/vercel-flags-core/src/controller/fetch-datafile.ts rename packages/vercel-flags-core/src/{data-source/flag-network-data-source.test.ts => controller/index.test.ts} (94%) rename packages/vercel-flags-core/src/{data-source/flag-network-data-source.ts => controller/index.ts} (54%) create mode 100644 packages/vercel-flags-core/src/controller/polling-source.ts rename packages/vercel-flags-core/src/{data-source => controller}/stream-connection.test.ts (100%) rename packages/vercel-flags-core/src/{data-source => controller}/stream-connection.ts (100%) create mode 100644 packages/vercel-flags-core/src/controller/stream-source.ts create mode 100644 packages/vercel-flags-core/src/controller/tagged-data.ts create mode 100644 packages/vercel-flags-core/src/controller/typed-emitter.ts delete mode 100644 packages/vercel-flags-core/src/data-source/in-memory-data-source.ts diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index e5542699..25e5a2f9 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -13,10 +13,15 @@ src/ ├── types.ts # Type definitions ├── errors.ts # Error classes ├── evaluate.ts # Core evaluation logic -├── data-source/ # Data source implementations -│ ├── flag-network-data-source.ts -│ ├── in-memory-data-source.ts -│ └── stream-connection.ts +├── controller/ # Controller (state machine) and I/O sources +│ ├── index.ts # Controller class +│ ├── stream-source.ts # StreamSource (wraps stream-connection) +│ ├── polling-source.ts # PollingSource (wraps fetch-datafile) +│ ├── bundled-source.ts # BundledSource (wraps read-bundled-definitions) +│ ├── stream-connection.ts # Low-level NDJSON stream connection +│ ├── fetch-datafile.ts # HTTP datafile fetch with retry +│ ├── tagged-data.ts # Data origin tagging types/helpers +│ └── typed-emitter.ts # Lightweight typed event emitter ├── openfeature.*.ts # OpenFeature provider ├── utils/ # Utilities │ ├── usage-tracker.ts @@ -48,15 +53,16 @@ type FlagsClient = { 4. Evaluate segment-based rules against entity context 5. Return fallthrough default if no match -### FlagNetworkDataSource Options +### Controller Options ```typescript -type FlagNetworkDataSourceOptions = { +type ControllerOptions = { sdkKey: string; 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) buildStep?: boolean; // Override build step auto-detection + sources?: { stream?: StreamSource; polling?: PollingSource; bundled?: BundledSource }; // DI for testing }; ``` diff --git a/packages/vercel-flags-core/src/controller/bundled-source.ts b/packages/vercel-flags-core/src/controller/bundled-source.ts new file mode 100644 index 00000000..9713e983 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/bundled-source.ts @@ -0,0 +1,88 @@ +import { FallbackEntryNotFoundError, FallbackNotFoundError } from '../errors'; +import type { BundledDefinitions, BundledDefinitionsResult } from '../types'; +import { readBundledDefinitions } from '../utils/read-bundled-definitions'; +import type { TaggedData } from './tagged-data'; +import { tagData } from './tagged-data'; +import { TypedEmitter } from './typed-emitter'; + +export type BundledSourceEvents = { + data: (data: TaggedData) => void; +}; + +/** + * Manages loading of bundled flag definitions. + * Wraps readBundledDefinitions() and emits typed events. + */ +export class BundledSource extends TypedEmitter { + private promise: Promise | undefined; + + constructor(sdkKey: string) { + super(); + // Eagerly start loading bundled definitions + this.promise = readBundledDefinitions(sdkKey); + } + + /** + * Load bundled definitions and return as TaggedData. + * Emits 'data' on success. + * Throws if bundled definitions are not available. + */ + async load(): Promise { + const result = await this.getResult(); + + if (result?.state === 'ok' && result.definitions) { + const tagged = tagData(result.definitions, 'bundled'); + this.emit('data', tagged); + return tagged; + } + + throw new Error( + '@vercel/flags-core: No flag definitions available. ' + + 'Bundled definitions not found.', + ); + } + + /** + * Get the raw BundledDefinitions (for getFallbackDatafile). + * Throws typed errors if not available. + */ + async getRaw(): Promise { + const result = await this.getResult(); + + if (!result) { + throw new FallbackNotFoundError(); + } + + switch (result.state) { + case 'ok': + return result.definitions; + case 'missing-file': + throw new FallbackNotFoundError(); + case 'missing-entry': + throw new FallbackEntryNotFoundError(); + case 'unexpected-error': + throw new Error( + '@vercel/flags-core: Failed to read bundled definitions: ' + + String(result.error), + ); + } + } + + /** + * Check if bundled definitions loaded successfully (without throwing). + */ + async tryLoad(): Promise { + const result = await this.getResult(); + if (result?.state === 'ok' && result.definitions) { + const tagged = tagData(result.definitions, 'bundled'); + this.emit('data', tagged); + return tagged; + } + return undefined; + } + + private async getResult(): Promise { + if (!this.promise) return undefined; + return this.promise; + } +} diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.ts new file mode 100644 index 00000000..fe7eca0e --- /dev/null +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.ts @@ -0,0 +1,66 @@ +import { version } from '../../package.json'; +import type { BundledDefinitions } from '../types'; +import { sleep } from '../utils/sleep'; + +export const DEFAULT_FETCH_TIMEOUT_MS = 10_000; +export const MAX_FETCH_RETRIES = 3; +export const FETCH_RETRY_BASE_DELAY_MS = 500; + +/** + * Fetches the datafile from the flags service with retry logic. + * + * Implements exponential backoff with jitter for transient failures. + * Does not retry 4xx errors (except 429) as they indicate client errors. + */ +export async function fetchDatafile( + host: string, + sdkKey: string, + fetchFn: typeof globalThis.fetch, +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt < MAX_FETCH_RETRIES; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + DEFAULT_FETCH_TIMEOUT_MS, + ); + + let shouldRetry = true; + try { + const res = await fetchFn(`${host}/v1/datafile`, { + headers: { + Authorization: `Bearer ${sdkKey}`, + 'User-Agent': `VercelFlagsCore/${version}`, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + // Don't retry 4xx errors (except 429) + if (res.status >= 400 && res.status < 500 && res.status !== 429) { + shouldRetry = false; + } + throw new Error(`Failed to fetch data: ${res.statusText}`); + } + + return res.json() as Promise; + } catch (error) { + clearTimeout(timeoutId); + lastError = + error instanceof Error ? error : new Error('Unknown fetch error'); + + if (!shouldRetry) throw lastError; + + if (attempt < MAX_FETCH_RETRIES - 1) { + const delay = + FETCH_RETRY_BASE_DELAY_MS * 2 ** attempt + Math.random() * 500; + await sleep(delay); + } + } + } + + throw lastError ?? new Error('Failed to fetch data after retries'); +} diff --git a/packages/vercel-flags-core/src/data-source/flag-network-data-source.test.ts b/packages/vercel-flags-core/src/controller/index.test.ts similarity index 94% rename from packages/vercel-flags-core/src/data-source/flag-network-data-source.test.ts rename to packages/vercel-flags-core/src/controller/index.test.ts index 07e85ab7..82a2108d 100644 --- a/packages/vercel-flags-core/src/data-source/flag-network-data-source.test.ts +++ b/packages/vercel-flags-core/src/controller/index.test.ts @@ -11,7 +11,7 @@ import { vi, } from 'vitest'; import type { BundledDefinitions, DatafileInput } from '../types'; -import { FlagNetworkDataSource } from './flag-network-data-source'; +import { Controller } from '.'; // Mock the bundled definitions module vi.mock('../utils/read-bundled-definitions', () => ({ @@ -93,9 +93,9 @@ async function assertIngestRequest( ); } -describe('FlagNetworkDataSource', () => { +describe('Controller', () => { // Note: Low-level NDJSON parsing tests (parse datafile, ignore ping, handle split chunks) - // are in stream-connection.test.ts. These tests focus on FlagNetworkDataSource-specific behavior. + // are in stream-connection.test.ts. These tests focus on Controller-specific behavior. it('should abort the stream connection when shutdown is called', async () => { let abortSignalReceived: AbortSignal | undefined; @@ -127,7 +127,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); await dataSource.read(); expect(abortSignalReceived).toBeDefined(); @@ -164,7 +164,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); const result = await dataSource.read(); expect(result).toMatchObject(definitions); @@ -192,7 +192,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); // First call gets initial data await dataSource.read(); @@ -236,7 +236,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', polling: false, // Disable polling to test stream timeout in isolation }); @@ -287,7 +287,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', polling: false, // Disable polling to test stream error fallback in isolation }); @@ -329,7 +329,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); await dataSource.read(); expect(capturedHeaders).not.toBeNull(); @@ -357,7 +357,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); await dataSource.read(); // Verify no warning on first successful read (stream is connected) @@ -398,31 +398,27 @@ describe('FlagNetworkDataSource', () => { describe('constructor validation', () => { it('should throw for missing SDK key', () => { - expect(() => new FlagNetworkDataSource({ sdkKey: '' })).toThrow( + expect(() => new Controller({ sdkKey: '' })).toThrow( '@vercel/flags-core: SDK key must be a string starting with "vf_"', ); }); it('should throw for SDK key not starting with vf_', () => { - expect( - () => new FlagNetworkDataSource({ sdkKey: 'invalid_key' }), - ).toThrow( + expect(() => new Controller({ sdkKey: 'invalid_key' })).toThrow( '@vercel/flags-core: SDK key must be a string starting with "vf_"', ); }); it('should throw for non-string SDK key', () => { expect( - () => new FlagNetworkDataSource({ sdkKey: 123 as unknown as string }), + () => new Controller({ sdkKey: 123 as unknown as string }), ).toThrow( '@vercel/flags-core: SDK key must be a string starting with "vf_"', ); }); it('should accept valid SDK key', () => { - expect( - () => new FlagNetworkDataSource({ sdkKey: 'vf_valid_key' }), - ).not.toThrow(); + expect(() => new Controller({ sdkKey: 'vf_valid_key' })).not.toThrow(); }); }); @@ -446,7 +442,7 @@ describe('FlagNetworkDataSource', () => { state: 'ok', }); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); const result = await dataSource.read(); // Should use bundled definitions without making stream request @@ -475,7 +471,7 @@ describe('FlagNetworkDataSource', () => { state: 'ok', }); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); const result = await dataSource.read(); expect(result).toMatchObject(bundledDefinitions); @@ -503,7 +499,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); await dataSource.read(); expect(streamRequested).toBe(true); @@ -534,7 +530,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); const result = await dataSource.read(); expect(result).toMatchObject(fetchedDefinitions); @@ -562,7 +558,7 @@ describe('FlagNetworkDataSource', () => { state: 'ok', }); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); // First read const firstResult = await dataSource.read(); @@ -596,7 +592,7 @@ describe('FlagNetworkDataSource', () => { state: 'ok', }); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', }); @@ -612,7 +608,7 @@ describe('FlagNetworkDataSource', () => { state: 'missing-file', }); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', }); @@ -635,7 +631,7 @@ describe('FlagNetworkDataSource', () => { state: 'missing-entry', }); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', }); @@ -659,7 +655,7 @@ describe('FlagNetworkDataSource', () => { error: new Error('Some error'), }); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', }); @@ -696,7 +692,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: { initTimeoutMs: 500 }, // Much shorter timeout polling: false, // Disable polling to test stream timeout directly @@ -745,7 +741,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: false, polling: true, @@ -774,7 +770,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: false, polling: { intervalMs: 100, initTimeoutMs: 5000 }, @@ -824,7 +820,7 @@ describe('FlagNetworkDataSource', () => { environment: 'production', }; - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', datafile: providedDatafile, stream: false, @@ -861,7 +857,7 @@ describe('FlagNetworkDataSource', () => { environment: 'production', }; - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', datafile: providedDatafile, }); @@ -908,7 +904,7 @@ describe('FlagNetworkDataSource', () => { environment: 'production', }; - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', datafile: providedDatafile, stream: false, @@ -978,7 +974,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: { initTimeoutMs: 100 }, // Short timeout to trigger polling fallback polling: { intervalMs: 50, initTimeoutMs: 5000 }, @@ -1030,7 +1026,7 @@ describe('FlagNetworkDataSource', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: { initTimeoutMs: 100 }, polling: { intervalMs: 100, initTimeoutMs: 5000 }, @@ -1087,7 +1083,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: true, polling: false, // Disable polling to test stream-only mode @@ -1149,7 +1145,7 @@ describe('FlagNetworkDataSource', () => { environment: 'production', }; - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', datafile: providedDatafile, stream: true, @@ -1205,7 +1201,7 @@ describe('FlagNetworkDataSource', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: { initTimeoutMs: 5000 }, polling: { intervalMs: 100, initTimeoutMs: 5000 }, @@ -1237,7 +1233,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); const result = await dataSource.getDatafile(); expect(result).toMatchObject(remoteDefinitions); @@ -1275,7 +1271,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); const result = await dataSource.getDatafile(); // Should fetch from network, NOT use bundled definitions @@ -1315,7 +1311,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); // First read via initialize/read to establish stream connection await dataSource.read(); @@ -1348,7 +1344,7 @@ describe('FlagNetworkDataSource', () => { state: 'ok', }); - const dataSource = new FlagNetworkDataSource({ sdkKey: 'vf_test_key' }); + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); const result = await dataSource.getDatafile(); expect(result.projectId).toBe('bundled'); @@ -1373,7 +1369,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: false, polling: false, @@ -1396,7 +1392,7 @@ describe('FlagNetworkDataSource', () => { describe('buildStep option', () => { it('should always load bundled definitions regardless of buildStep', async () => { // bundled definitions are always loaded as ultimate fallback - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', buildStep: false, stream: false, @@ -1449,7 +1445,7 @@ describe('FlagNetworkDataSource', () => { state: 'ok', }); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', buildStep: true, // Force build step behavior stream: true, // Would normally enable streaming @@ -1487,7 +1483,7 @@ describe('FlagNetworkDataSource', () => { environment: 'production', }; - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', buildStep: true, datafile: providedDatafile, @@ -1534,7 +1530,7 @@ describe('FlagNetworkDataSource', () => { state: 'ok', }); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', // buildStep not specified - should auto-detect from CI=1 }); @@ -1581,7 +1577,7 @@ describe('FlagNetworkDataSource', () => { state: 'ok', }); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', // buildStep not specified - should auto-detect from NEXT_PHASE }); @@ -1612,7 +1608,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', buildStep: false, // Explicitly override CI detection }); @@ -1669,7 +1665,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', polling: false, }); @@ -1733,7 +1729,7 @@ describe('FlagNetworkDataSource', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: true, polling: { intervalMs: 50, initTimeoutMs: 5000 }, @@ -1802,7 +1798,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', polling: false, }); @@ -1865,7 +1861,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', datafile: providedDatafile, polling: false, @@ -1931,7 +1927,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', polling: false, }); @@ -1990,7 +1986,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', polling: false, }); @@ -2043,7 +2039,7 @@ describe('FlagNetworkDataSource', () => { }), ); - const dataSource = new FlagNetworkDataSource({ + const dataSource = new Controller({ sdkKey: 'vf_test_key', polling: false, }); diff --git a/packages/vercel-flags-core/src/data-source/flag-network-data-source.ts b/packages/vercel-flags-core/src/controller/index.ts similarity index 54% rename from packages/vercel-flags-core/src/data-source/flag-network-data-source.ts rename to packages/vercel-flags-core/src/controller/index.ts index 8666acb8..15c5a30f 100644 --- a/packages/vercel-flags-core/src/data-source/flag-network-data-source.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -1,8 +1,5 @@ -import { version } from '../../package.json'; -import { FallbackEntryNotFoundError, FallbackNotFoundError } from '../errors'; import type { BundledDefinitions, - BundledDefinitionsResult, Datafile, DatafileInput, DataSource, @@ -10,23 +7,31 @@ import type { PollingOptions, StreamOptions, } from '../types'; -import { readBundledDefinitions } from '../utils/read-bundled-definitions'; -import { sleep } from '../utils/sleep'; import { type TrackReadOptions, UsageTracker } from '../utils/usage-tracker'; -import { connectStream } from './stream-connection'; + +export { BundledSource } from './bundled-source'; + +import { BundledSource } from './bundled-source'; +import { fetchDatafile } from './fetch-datafile'; + +export { PollingSource } from './polling-source'; + +import { PollingSource } from './polling-source'; + +export { StreamSource } from './stream-source'; + +import { StreamSource } from './stream-source'; +import { originToMetricsSource, type TaggedData, tagData } from './tagged-data'; const FLAGS_HOST = 'https://flags.vercel.com'; const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; const DEFAULT_POLLING_INTERVAL_MS = 30_000; const DEFAULT_POLLING_INIT_TIMEOUT_MS = 3_000; -const DEFAULT_FETCH_TIMEOUT_MS = 10_000; -const MAX_FETCH_RETRIES = 3; -const FETCH_RETRY_BASE_DELAY_MS = 500; /** - * Configuration options for FlagNetworkDataSource + * Configuration options for Controller */ -export type FlagNetworkDataSourceOptions = { +export type ControllerOptions = { /** SDK key for authentication (must start with "vf_") */ sdkKey: string; @@ -69,11 +74,22 @@ export type FlagNetworkDataSourceOptions = { * @default globalThis.fetch */ fetch?: typeof globalThis.fetch; + + /** + * Custom source modules for dependency injection (testing). + * When provided, these replace the default source instances. + */ + sources?: { + stream?: StreamSource; + polling?: PollingSource; + bundled?: BundledSource; + }; }; -/** - * Normalized internal options - */ +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + type NormalizedOptions = { sdkKey: string; datafile: DatafileInput | undefined; @@ -84,11 +100,25 @@ type NormalizedOptions = { }; /** - * Normalizes user-provided options to internal format with defaults + * Explicit states for the controller state machine. */ -function normalizeOptions( - options: FlagNetworkDataSourceOptions, -): NormalizedOptions { +type State = + | 'idle' + | 'initializing:stream' + | 'initializing:polling' + | 'initializing:fallback' + | 'streaming' + | 'polling' + | 'degraded' + | 'build:loading' + | 'build:ready' + | 'shutdown'; + +// --------------------------------------------------------------------------- +// Option normalization +// --------------------------------------------------------------------------- + +function normalizeOptions(options: ControllerOptions): NormalizedOptions { const autoDetectedBuildStep = process.env.CI === '1' || process.env.NEXT_PHASE === 'phase-production-build'; @@ -130,69 +160,32 @@ function normalizeOptions( }; } +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + /** - * Fetches the datafile from the flags service with retry logic. - * - * Implements exponential backoff with jitter for transient failures. - * Does not retry 4xx errors (except 429) as they indicate client errors. + * Parses a configUpdatedAt value (number or string) into a numeric timestamp. + * Returns undefined if the value is missing or cannot be parsed. */ -async function fetchDatafile( - host: string, - sdkKey: string, - fetchFn: typeof globalThis.fetch, -): Promise { - let lastError: Error | undefined; - - for (let attempt = 0; attempt < MAX_FETCH_RETRIES; attempt++) { - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - DEFAULT_FETCH_TIMEOUT_MS, - ); - - let shouldRetry = true; - try { - const res = await fetchFn(`${host}/v1/datafile`, { - headers: { - Authorization: `Bearer ${sdkKey}`, - 'User-Agent': `VercelFlagsCore/${version}`, - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!res.ok) { - // Don't retry 4xx errors (except 429) - if (res.status >= 400 && res.status < 500 && res.status !== 429) { - shouldRetry = false; - } - throw new Error(`Failed to fetch data: ${res.statusText}`); - } - - return res.json() as Promise; - } catch (error) { - clearTimeout(timeoutId); - lastError = - error instanceof Error ? error : new Error('Unknown fetch error'); - - if (!shouldRetry) throw lastError; - - if (attempt < MAX_FETCH_RETRIES - 1) { - const delay = - FETCH_RETRY_BASE_DELAY_MS * 2 ** attempt + Math.random() * 500; - await sleep(delay); - } - } +function parseConfigUpdatedAt(value: unknown): number | undefined { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isNaN(parsed) ? undefined : parsed; } - - throw lastError ?? new Error('Failed to fetch data after retries'); + return undefined; } +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + /** * A DataSource implementation that connects to flags.vercel.com. * - * Behavior differs based on environment: + * Implemented as a state machine controller that delegates all I/O to + * source modules (StreamSource, PollingSource, BundledSource). * * **Build step** (CI=1 or Next.js build, or buildStep: true): * - Uses datafile (if provided) or bundled definitions @@ -204,38 +197,29 @@ async function fetchDatafile( * - If stream reconnects while polling → stop polling * - If stream disconnects → start polling (if enabled) */ -export class FlagNetworkDataSource implements DataSource { +export class Controller implements DataSource { private options: NormalizedOptions; private host = FLAGS_HOST; - // Data state - private data: DatafileInput | undefined; - private bundledDefinitionsPromise: - | Promise - | undefined; + // State machine + private state: State = 'idle'; - // Stream state - private streamAbortController: AbortController | undefined; - private streamPromise: Promise | undefined; - private isStreamConnected: boolean = false; - private hasWarnedAboutStaleData: boolean = false; + // Data state — tagged with origin + private data: TaggedData | undefined; - // Polling state - private pollingIntervalId: ReturnType | undefined; - private pollingAbortController: AbortController | undefined; + // Sources (I/O delegates) + private streamSource: StreamSource; + private pollingSource: PollingSource; + private bundledSource: BundledSource; - // Initialization state — suppresses onDisconnect from starting polling - // while initialize() is still running its own fallback chain - private isInitializing: boolean = false; + // UI state + private hasWarnedAboutStaleData: boolean = false; // Usage tracking private usageTracker: UsageTracker; private isFirstGetData: boolean = true; - /** - * Creates a new FlagNetworkDataSource instance. - */ - constructor(options: FlagNetworkDataSourceOptions) { + constructor(options: ControllerOptions) { if ( !options.sdkKey || typeof options.sdkKey !== 'string' || @@ -248,14 +232,33 @@ export class FlagNetworkDataSource implements DataSource { this.options = normalizeOptions(options); - // Always load bundled definitions as ultimate fallback - this.bundledDefinitionsPromise = readBundledDefinitions( - this.options.sdkKey, - ); + // Create source modules (or use injected ones for testing) + this.streamSource = + options.sources?.stream ?? + new StreamSource({ + host: this.host, + sdkKey: this.options.sdkKey, + fetch: this.options.fetch, + }); + + this.pollingSource = + options.sources?.polling ?? + new PollingSource({ + host: this.host, + sdkKey: this.options.sdkKey, + intervalMs: this.options.polling.intervalMs, + fetch: this.options.fetch, + }); + + this.bundledSource = + options.sources?.bundled ?? new BundledSource(this.options.sdkKey); + + // Wire source events to state machine + this.wireSourceEvents(); // If datafile provided, use it immediately if (this.options.datafile) { - this.data = this.options.datafile; + this.data = tagData(this.options.datafile, 'provided'); } this.usageTracker = new UsageTracker({ @@ -264,6 +267,77 @@ export class FlagNetworkDataSource implements DataSource { }); } + // --------------------------------------------------------------------------- + // Source event wiring + // --------------------------------------------------------------------------- + + private wireSourceEvents(): void { + // Stream events + this.streamSource.on('data', (data) => { + if (this.isNewerData(data)) { + this.data = data; + } + this.hasWarnedAboutStaleData = false; + }); + + this.streamSource.on('connected', () => { + // Stream reconnected while polling → stop polling, transition to streaming + if (this.state === 'polling') { + this.pollingSource.stop(); + this.transition('streaming'); + } + // During normal streaming, just confirm state + else if (this.state === 'streaming') { + // Already in streaming state, no transition needed + } + // During initialization, initialize() handles the transition + }); + + this.streamSource.on('disconnected', () => { + // Only react to disconnects when we're in streaming state. + // During initialization states, initialize() manages its own fallback chain. + if (this.state === 'streaming') { + if (this.options.polling.enabled) { + this.pollingSource.startInterval(); + this.transition('polling'); + } else { + this.transition('degraded'); + } + } + }); + + // Polling events + this.pollingSource.on('data', (data) => { + if (this.isNewerData(data)) { + this.data = data; + } + }); + + this.pollingSource.on('error', (error) => { + console.error('@vercel/flags-core: Poll failed:', error); + }); + } + + // --------------------------------------------------------------------------- + // State machine + // --------------------------------------------------------------------------- + + private transition(to: State): void { + this.state = to; + } + + private get isConnected(): boolean { + return this.state === 'streaming'; + } + + private get isInitializing(): boolean { + return ( + this.state === 'initializing:stream' || + this.state === 'initializing:polling' || + this.state === 'initializing:fallback' + ); + } + // --------------------------------------------------------------------------- // Public API (DataSource interface) // --------------------------------------------------------------------------- @@ -276,16 +350,15 @@ export class FlagNetworkDataSource implements DataSource { */ async initialize(): Promise { if (this.options.buildStep) { + this.transition('build:loading'); await this.initializeForBuildStep(); + this.transition('build:ready'); return; } // Hydrate from provided datafile if not already set (e.g., after shutdown) - // Usually the constructor sets this, but if the client was shutdown and - // then init'd again we need to set it again. This also means that any - // previous data we've seen before shutdown is lost. We'll "start fresh". if (!this.data && this.options.datafile) { - this.data = this.options.datafile; + this.data = tagData(this.options.datafile, 'provided'); } // If we already have data (from provided datafile), start background updates @@ -295,28 +368,36 @@ export class FlagNetworkDataSource implements DataSource { return; } - this.isInitializing = true; - try { - // Try stream first - if (this.options.stream.enabled) { - const streamSuccess = await this.tryInitializeStream(); - if (streamSuccess) return; + // Fallback chain + if (this.options.stream.enabled) { + this.transition('initializing:stream'); + const streamSuccess = await this.tryInitializeStream(); + if (streamSuccess) { + this.transition('streaming'); + return; } + } - // Fall back to polling - if (this.options.polling.enabled) { - const pollingSuccess = await this.tryInitializePolling(); - if (pollingSuccess) return; + if (this.options.polling.enabled) { + this.transition('initializing:polling'); + const pollingSuccess = await this.tryInitializePolling(); + if (pollingSuccess) { + this.transition('polling'); + return; } + } - // Fall back to provided datafile (already set in constructor if provided) - if (this.data) return; + this.transition('initializing:fallback'); - // Fall back to bundled definitions - await this.initializeFromBundled(); - } finally { - this.isInitializing = false; + // Fall back to provided datafile (already set in constructor if provided) + if (this.data) { + this.transition('degraded'); + return; } + + // Fall back to bundled definitions + await this.initializeFromBundled(); + this.transition('degraded'); } /** @@ -329,19 +410,19 @@ export class FlagNetworkDataSource implements DataSource { const isFirstRead = this.isFirstGetData; this.isFirstGetData = false; - let result: DatafileInput; - let source: Metrics['source']; + let result: TaggedData; let cacheStatus: Metrics['cacheStatus']; if (this.options.buildStep) { - [result, source, cacheStatus] = await this.getDataForBuildStep(); + [result, cacheStatus] = await this.getDataForBuildStep(); } else if (cachedData) { - [result, source, cacheStatus] = this.getDataFromCache(cachedData); + [result, cacheStatus] = this.getDataFromCache(cachedData); } else { - [result, source, cacheStatus] = await this.getDataWithFallbacks(); + [result, cacheStatus] = await this.getDataWithFallbacks(); } const readMs = Date.now() - startTime; + const source = originToMetricsSource(result._origin); this.trackRead(startTime, cacheHadDefinitions, isFirstRead, source); return Object.assign(result, { @@ -349,7 +430,7 @@ export class FlagNetworkDataSource implements DataSource { readMs, source, cacheStatus, - connectionState: this.isStreamConnected + connectionState: this.isConnected ? ('connected' as const) : ('disconnected' as const), }, @@ -360,11 +441,12 @@ export class FlagNetworkDataSource implements DataSource { * Shuts down the data source and releases resources. */ async shutdown(): Promise { - this.stopStream(); - this.stopPolling(); - this.data = this.options.datafile; - this.isInitializing = false; - this.isStreamConnected = false; + this.streamSource.stop(); + this.pollingSource.stop(); + this.data = this.options.datafile + ? tagData(this.options.datafile, 'provided') + : undefined; + this.transition('shutdown'); this.hasWarnedAboutStaleData = false; await this.usageTracker.flush(); } @@ -380,24 +462,29 @@ export class FlagNetworkDataSource implements DataSource { async getDatafile(): Promise { const startTime = Date.now(); - let result: DatafileInput; + let result: TaggedData; let source: Metrics['source']; let cacheStatus: Metrics['cacheStatus']; if (this.options.buildStep) { - [result, source, cacheStatus] = await this.getDataForBuildStep(); - } else if (this.isStreamConnected && this.data) { - [result, source, cacheStatus] = this.getDataFromCache(); + [result, cacheStatus] = await this.getDataForBuildStep(); + source = originToMetricsSource(result._origin); + } else if (this.isConnected && this.data) { + [result, cacheStatus] = this.getDataFromCache(); + source = originToMetricsSource(result._origin); } else { const fetched = await fetchDatafile( this.host, this.options.sdkKey, this.options.fetch, ); - if (this.isNewerData(fetched)) { - this.data = fetched; + const tagged = tagData(fetched, 'fetched'); + if (this.isNewerData(tagged)) { + this.data = tagged; } - [result, source, cacheStatus] = [this.data ?? fetched, 'remote', 'MISS']; + result = this.data ?? tagged; + source = 'remote'; + cacheStatus = 'MISS'; } return Object.assign(result, { @@ -405,7 +492,7 @@ export class FlagNetworkDataSource implements DataSource { readMs: Date.now() - startTime, source, cacheStatus, - connectionState: this.isStreamConnected + connectionState: this.isConnected ? ('connected' as const) : ('disconnected' as const), }, @@ -416,33 +503,11 @@ export class FlagNetworkDataSource implements DataSource { * Returns the bundled fallback datafile. */ async getFallbackDatafile(): Promise { - if (!this.bundledDefinitionsPromise) { - throw new FallbackNotFoundError(); - } - - const bundledResult = await this.bundledDefinitionsPromise; - - if (!bundledResult) { - throw new FallbackNotFoundError(); - } - - switch (bundledResult.state) { - case 'ok': - return bundledResult.definitions; - case 'missing-file': - throw new FallbackNotFoundError(); - case 'missing-entry': - throw new FallbackEntryNotFoundError(); - case 'unexpected-error': - throw new Error( - '@vercel/flags-core: Failed to read bundled definitions: ' + - String(bundledResult.error), - ); - } + return this.bundledSource.getRaw(); } // --------------------------------------------------------------------------- - // Stream management + // Stream initialization // --------------------------------------------------------------------------- /** @@ -450,13 +515,9 @@ export class FlagNetworkDataSource implements DataSource { * Returns true if stream connected successfully within timeout. */ private async tryInitializeStream(): Promise { - let streamPromise: Promise; - if (this.options.stream.initTimeoutMs <= 0) { - // No timeout - wait indefinitely try { - streamPromise = this.startStream(); - await streamPromise; + await this.streamSource.start(); return true; } catch { return false; @@ -473,15 +534,17 @@ export class FlagNetworkDataSource implements DataSource { }); try { - streamPromise = this.startStream(); - const result = await Promise.race([streamPromise, timeoutPromise]); + const result = await Promise.race([ + this.streamSource.start(), + timeoutPromise, + ]); clearTimeout(timeoutId!); if (result === 'timeout') { console.warn( '@vercel/flags-core: Stream initialization timeout, falling back', ); - // Don't abort stream - let it continue trying in background + // Don't stop stream - let it continue trying in background return false; } @@ -492,74 +555,8 @@ export class FlagNetworkDataSource implements DataSource { } } - /** - * Starts the stream connection with callbacks for data and disconnect. - */ - private startStream(): Promise { - if (this.streamPromise) return this.streamPromise; - - this.streamAbortController = new AbortController(); - this.isStreamConnected = false; - this.hasWarnedAboutStaleData = false; - - try { - const streamPromise = connectStream( - { - host: this.host, - sdkKey: this.options.sdkKey, - abortController: this.streamAbortController, - fetch: this.options.fetch, - }, - { - onMessage: (newData) => { - if (this.isNewerData(newData)) { - this.data = newData; - } - this.isStreamConnected = true; - this.hasWarnedAboutStaleData = false; - - // Stream is working - stop polling if it's running - if (this.pollingIntervalId) { - this.stopPolling(); - } - }, - onDisconnect: () => { - this.isStreamConnected = false; - - // Fall back to polling if enabled and not already polling. - // Skip during initialization — initialize() manages its own - // fallback chain and will start polling itself if needed. - if ( - this.options.polling.enabled && - !this.pollingIntervalId && - !this.isInitializing - ) { - this.startPolling(); - } - }, - }, - ); - - this.streamPromise = streamPromise; - return streamPromise; - } catch (error) { - this.streamPromise = undefined; - this.streamAbortController = undefined; - throw error; - } - } - - /** - * Stops the stream connection. - */ - private stopStream(): void { - this.streamAbortController?.abort(); - this.streamAbortController = undefined; - this.streamPromise = undefined; - } - // --------------------------------------------------------------------------- - // Polling management + // Polling initialization // --------------------------------------------------------------------------- /** @@ -567,17 +564,13 @@ export class FlagNetworkDataSource implements DataSource { * Returns true if first poll succeeded within timeout. */ private async tryInitializePolling(): Promise { - this.pollingAbortController = new AbortController(); - - // Perform initial poll - const pollPromise = this.performPoll(); + const pollPromise = this.pollingSource.poll(); if (this.options.polling.initTimeoutMs <= 0) { - // No timeout - wait indefinitely try { await pollPromise; if (this.data) { - this.startPollingInterval(); + this.pollingSource.startInterval(); return true; } return false; @@ -607,7 +600,7 @@ export class FlagNetworkDataSource implements DataSource { } if (this.data) { - this.startPollingInterval(); + this.pollingSource.startInterval(); return true; } return false; @@ -617,65 +610,6 @@ export class FlagNetworkDataSource implements DataSource { } } - /** - * Starts polling (initial poll + interval). - */ - private startPolling(): void { - if (this.pollingIntervalId) return; - - this.pollingAbortController = new AbortController(); - - // Perform initial poll - void this.performPoll(); - - // Start interval - this.startPollingInterval(); - } - - /** - * Starts the polling interval (without initial poll). - */ - private startPollingInterval(): void { - if (this.pollingIntervalId) return; - - this.pollingIntervalId = setInterval( - () => void this.performPoll(), - this.options.polling.intervalMs, - ); - } - - /** - * Stops polling. - */ - private stopPolling(): void { - if (this.pollingIntervalId) { - clearInterval(this.pollingIntervalId); - this.pollingIntervalId = undefined; - } - this.pollingAbortController?.abort(); - this.pollingAbortController = undefined; - } - - /** - * Performs a single poll request. - */ - private async performPoll(): Promise { - if (this.pollingAbortController?.signal.aborted) return; - - try { - const data = await fetchDatafile( - this.host, - this.options.sdkKey, - this.options.fetch, - ); - if (this.isNewerData(data)) { - this.data = data; - } - } catch (error) { - console.error('@vercel/flags-core: Poll failed:', error); - } - } - // --------------------------------------------------------------------------- // Background updates // --------------------------------------------------------------------------- @@ -686,9 +620,13 @@ export class FlagNetworkDataSource implements DataSource { */ private startBackgroundUpdates(): void { if (this.options.stream.enabled) { - void this.startStream(); + void this.streamSource.start(); + this.transition('streaming'); } else if (this.options.polling.enabled) { - this.startPolling(); + this.pollingSource.startInterval(); + this.transition('polling'); + } else { + this.transition('degraded'); } } @@ -702,45 +640,43 @@ export class FlagNetworkDataSource implements DataSource { private async initializeForBuildStep(): Promise { if (this.data) return; - if (this.bundledDefinitionsPromise) { - const bundledResult = await this.bundledDefinitionsPromise; - if (bundledResult?.state === 'ok' && bundledResult.definitions) { - this.data = bundledResult.definitions; - return; - } + const bundled = await this.bundledSource.tryLoad(); + if (bundled) { + this.data = bundled; + return; } - this.data = await fetchDatafile( + const fetched = await fetchDatafile( this.host, this.options.sdkKey, this.options.fetch, ); + this.data = tagData(fetched, 'fetched'); } /** * Retrieves data during build steps. */ private async getDataForBuildStep(): Promise< - [DatafileInput, Metrics['source'], Metrics['cacheStatus']] + [TaggedData, Metrics['cacheStatus']] > { if (this.data) { - return [this.data, 'in-memory', 'HIT']; + return [this.data, 'HIT']; } - if (this.bundledDefinitionsPromise) { - const bundledResult = await this.bundledDefinitionsPromise; - if (bundledResult?.state === 'ok' && bundledResult.definitions) { - this.data = bundledResult.definitions; - return [this.data, 'embedded', 'MISS']; - } + const bundled = await this.bundledSource.tryLoad(); + if (bundled) { + this.data = bundled; + return [this.data, 'MISS']; } - this.data = await fetchDatafile( + const fetched = await fetchDatafile( this.host, this.options.sdkKey, this.options.fetch, ); - return [this.data, 'remote', 'MISS']; + this.data = tagData(fetched, 'fetched'); + return [this.data, 'MISS']; } // --------------------------------------------------------------------------- @@ -751,52 +687,56 @@ export class FlagNetworkDataSource implements DataSource { * Returns data from the in-memory cache. */ private getDataFromCache( - cachedData?: DatafileInput, - ): [DatafileInput, Metrics['source'], Metrics['cacheStatus']] { + cachedData?: TaggedData, + ): [TaggedData, Metrics['cacheStatus']] { const data = cachedData ?? this.data!; this.warnIfDisconnected(); - const cacheStatus = this.isStreamConnected ? 'HIT' : 'STALE'; - return [data, 'in-memory', cacheStatus]; + const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; + return [data, cacheStatus]; } /** * Retrieves data using the fallback chain. */ private async getDataWithFallbacks(): Promise< - [DatafileInput, Metrics['source'], Metrics['cacheStatus']] + [TaggedData, Metrics['cacheStatus']] > { // Try stream with timeout if (this.options.stream.enabled) { + this.transition('initializing:stream'); const streamSuccess = await this.tryInitializeStream(); if (streamSuccess && this.data) { - return [this.data, 'in-memory', 'MISS']; + this.transition('streaming'); + return [this.data, 'MISS']; } } // Try polling with timeout if (this.options.polling.enabled) { + this.transition('initializing:polling'); const pollingSuccess = await this.tryInitializePolling(); if (pollingSuccess && this.data) { - return [this.data, 'remote', 'MISS']; + this.transition('polling'); + return [this.data, 'MISS']; } } + this.transition('initializing:fallback'); + // Use provided datafile if (this.options.datafile) { - this.data = this.options.datafile; - return [this.data, 'in-memory', 'STALE']; + this.data = tagData(this.options.datafile, 'provided'); + this.transition('degraded'); + return [this.data, 'STALE']; } // Use bundled definitions - if (this.bundledDefinitionsPromise) { - const bundledResult = await this.bundledDefinitionsPromise; - if (bundledResult?.state === 'ok' && bundledResult.definitions) { - console.warn( - '@vercel/flags-core: Using bundled definitions as fallback', - ); - this.data = bundledResult.definitions; - return [this.data, 'embedded', 'STALE']; - } + const bundled = await this.bundledSource.tryLoad(); + if (bundled) { + console.warn('@vercel/flags-core: Using bundled definitions as fallback'); + this.data = bundled; + this.transition('degraded'); + return [this.data, 'STALE']; } throw new Error( @@ -809,16 +749,9 @@ export class FlagNetworkDataSource implements DataSource { * Initializes from bundled definitions. */ private async initializeFromBundled(): Promise { - if (!this.bundledDefinitionsPromise) { - throw new Error( - '@vercel/flags-core: No flag definitions available. ' + - 'Ensure streaming/polling is enabled or provide a datafile.', - ); - } - - const bundledResult = await this.bundledDefinitionsPromise; - if (bundledResult?.state === 'ok' && bundledResult.definitions) { - this.data = bundledResult.definitions; + const bundled = await this.bundledSource.tryLoad(); + if (bundled) { + this.data = bundled; return; } @@ -828,18 +761,9 @@ export class FlagNetworkDataSource implements DataSource { ); } - /** - * Parses a configUpdatedAt value (number or string) into a numeric timestamp. - * Returns undefined if the value is missing or cannot be parsed. - */ - private static parseConfigUpdatedAt(value: unknown): number | undefined { - if (typeof value === 'number') return value; - if (typeof value === 'string') { - const parsed = Number(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; - } + // --------------------------------------------------------------------------- + // Data comparison + // --------------------------------------------------------------------------- /** * Checks if the incoming data is newer than the current in-memory data. @@ -855,12 +779,8 @@ export class FlagNetworkDataSource implements DataSource { private isNewerData(incoming: DatafileInput): boolean { if (!this.data) return true; - const currentTs = FlagNetworkDataSource.parseConfigUpdatedAt( - this.data.configUpdatedAt, - ); - const incomingTs = FlagNetworkDataSource.parseConfigUpdatedAt( - incoming.configUpdatedAt, - ); + const currentTs = parseConfigUpdatedAt(this.data.configUpdatedAt); + const incomingTs = parseConfigUpdatedAt(incoming.configUpdatedAt); if (currentTs === undefined || incomingTs === undefined) { return true; @@ -873,7 +793,7 @@ export class FlagNetworkDataSource implements DataSource { * Logs a warning if returning cached data while stream is disconnected. */ private warnIfDisconnected(): void { - if (!this.isStreamConnected && !this.hasWarnedAboutStaleData) { + if (!this.isConnected && !this.hasWarnedAboutStaleData) { this.hasWarnedAboutStaleData = true; console.warn( '@vercel/flags-core: Returning in-memory flag definitions while stream is disconnected. Data may be stale.', diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts new file mode 100644 index 00000000..f4253f5a --- /dev/null +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -0,0 +1,84 @@ +import { fetchDatafile } from './fetch-datafile'; +import type { TaggedData } from './tagged-data'; +import { tagData } from './tagged-data'; +import { TypedEmitter } from './typed-emitter'; + +export type PollingSourceConfig = { + host: string; + sdkKey: string; + intervalMs: number; + fetch?: typeof globalThis.fetch; +}; + +export type PollingSourceEvents = { + data: (data: TaggedData) => void; + error: (error: Error) => void; +}; + +/** + * Manages interval-based polling for flag data. + * Wraps fetchDatafile() and emits typed events. + */ +export class PollingSource extends TypedEmitter { + private config: PollingSourceConfig; + private intervalId: ReturnType | undefined; + private abortController: AbortController | undefined; + + constructor(config: PollingSourceConfig) { + super(); + this.config = config; + } + + /** + * Perform a single poll request. + * Emits 'data' on success, 'error' on failure. + */ + async poll(): Promise { + if (this.abortController?.signal.aborted) return; + + try { + const data = await fetchDatafile( + this.config.host, + this.config.sdkKey, + this.config.fetch ?? globalThis.fetch, + ); + const tagged = tagData(data, 'poll'); + this.emit('data', tagged); + } catch (error) { + const err = + error instanceof Error ? error : new Error('Unknown poll error'); + this.emit('error', err); + } + } + + /** + * Start interval-based polling. + * Performs an initial poll immediately, then polls at the configured interval. + */ + startInterval(): void { + if (this.intervalId) return; + + this.abortController = new AbortController(); + + // Initial poll + void this.poll(); + + // Start interval + this.intervalId = setInterval( + () => void this.poll(), + this.config.intervalMs, + ); + } + + /** + * Stop interval-based polling. + */ + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + this.abortController?.abort(); + this.abortController = undefined; + } +} diff --git a/packages/vercel-flags-core/src/data-source/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts similarity index 100% rename from packages/vercel-flags-core/src/data-source/stream-connection.test.ts rename to packages/vercel-flags-core/src/controller/stream-connection.test.ts diff --git a/packages/vercel-flags-core/src/data-source/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts similarity index 100% rename from packages/vercel-flags-core/src/data-source/stream-connection.ts rename to packages/vercel-flags-core/src/controller/stream-connection.ts diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts new file mode 100644 index 00000000..0acb5b7d --- /dev/null +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -0,0 +1,79 @@ +import { connectStream } from './stream-connection'; +import type { TaggedData } from './tagged-data'; +import { tagData } from './tagged-data'; +import { TypedEmitter } from './typed-emitter'; + +export type StreamSourceConfig = { + host: string; + sdkKey: string; + fetch?: typeof globalThis.fetch; +}; + +export type StreamSourceEvents = { + data: (data: TaggedData) => void; + connected: () => void; + disconnected: () => void; +}; + +/** + * Manages a streaming connection to the flags service. + * Wraps connectStream() and emits typed events. + */ +export class StreamSource extends TypedEmitter { + private config: StreamSourceConfig; + private abortController: AbortController | undefined; + private promise: Promise | undefined; + + constructor(config: StreamSourceConfig) { + super(); + this.config = config; + } + + /** + * Start the stream connection. + * Returns a promise that resolves when the first datafile message arrives. + * If already started, returns the existing promise. + */ + start(): Promise { + if (this.promise) return this.promise; + + this.abortController = new AbortController(); + + try { + const promise = connectStream( + { + host: this.config.host, + sdkKey: this.config.sdkKey, + abortController: this.abortController, + fetch: this.config.fetch, + }, + { + onMessage: (newData) => { + const tagged = tagData(newData, 'stream'); + this.emit('data', tagged); + this.emit('connected'); + }, + onDisconnect: () => { + this.emit('disconnected'); + }, + }, + ); + + this.promise = promise; + return promise; + } catch (error) { + this.promise = undefined; + this.abortController = undefined; + throw error; + } + } + + /** + * Stop the stream connection. + */ + stop(): void { + this.abortController?.abort(); + this.abortController = undefined; + this.promise = undefined; + } +} diff --git a/packages/vercel-flags-core/src/controller/tagged-data.ts b/packages/vercel-flags-core/src/controller/tagged-data.ts new file mode 100644 index 00000000..a04c9d9a --- /dev/null +++ b/packages/vercel-flags-core/src/controller/tagged-data.ts @@ -0,0 +1,38 @@ +import type { DatafileInput, Metrics } from '../types'; + +/** + * Internal origin tracking for how data was obtained. + * This flows with the data from point of origin through to metrics. + */ +export type DataOrigin = 'stream' | 'poll' | 'bundled' | 'provided' | 'fetched'; + +/** + * DatafileInput with origin metadata attached at the point of arrival. + * Internal only — stripped before returning to consumers. + */ +export type TaggedData = DatafileInput & { + _origin: DataOrigin; +}; + +/** + * Tags a DatafileInput with its origin. + */ +export function tagData(data: DatafileInput, origin: DataOrigin): TaggedData { + return Object.assign(data, { _origin: origin }); +} + +/** + * Maps internal DataOrigin to the public Metrics.source value. + */ +export function originToMetricsSource(origin: DataOrigin): Metrics['source'] { + switch (origin) { + case 'stream': + case 'poll': + case 'provided': + return 'in-memory'; + case 'fetched': + return 'remote'; + case 'bundled': + return 'embedded'; + } +} diff --git a/packages/vercel-flags-core/src/controller/typed-emitter.ts b/packages/vercel-flags-core/src/controller/typed-emitter.ts new file mode 100644 index 00000000..9c3a59d1 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/typed-emitter.ts @@ -0,0 +1,34 @@ +/** + * Lightweight typed event emitter base class. + * Each source module extends this to emit typed events. + */ +export class TypedEmitter< + Events extends Record void>, +> { + private handlers = new Map>(); + + on(event: E, handler: Events[E]): void { + let set = this.handlers.get(event); + if (!set) { + set = new Set(); + this.handlers.set(event, set); + } + set.add(handler as Events[keyof Events]); + } + + off(event: E, handler: Events[E]): void { + this.handlers.get(event)?.delete(handler as Events[keyof Events]); + } + + protected emit( + event: E, + ...args: Parameters + ): void { + const set = this.handlers.get(event); + if (set) { + for (const handler of set) { + (handler as (...a: any[]) => void)(...args); + } + } + } +} diff --git a/packages/vercel-flags-core/src/data-source/in-memory-data-source.ts b/packages/vercel-flags-core/src/data-source/in-memory-data-source.ts deleted file mode 100644 index 05807bb5..00000000 --- a/packages/vercel-flags-core/src/data-source/in-memory-data-source.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Datafile, DatafileInput, DataSource, Packed } from '../types'; - -const RESOLVED_VOID = Promise.resolve(); - -export class InMemoryDataSource implements DataSource { - private data: DatafileInput; - private cachedDatafile: Datafile | undefined; - - constructor({ - data, - projectId, - environment, - }: { data: Packed.Data; projectId: string; environment: string }) { - this.data = { - ...data, - projectId, - environment, - }; - } - - getDatafile(): Promise { - return Promise.resolve(this.getDatafileSync()); - } - - initialize(): Promise { - return RESOLVED_VOID; - } - - shutdown(): void {} - - read(): Promise { - return Promise.resolve(this.getDatafileSync()); - } - - private getDatafileSync(): Datafile { - if (!this.cachedDatafile) { - this.cachedDatafile = Object.assign(this.data, { - metrics: { - readMs: 0, - source: 'in-memory' as const, - cacheStatus: 'HIT' as const, - connectionState: 'connected' as const, - }, - }) satisfies Datafile; - } - return this.cachedDatafile; - } -} diff --git a/packages/vercel-flags-core/src/index.common.ts b/packages/vercel-flags-core/src/index.common.ts index ed10c6ba..ffcebe51 100644 --- a/packages/vercel-flags-core/src/index.common.ts +++ b/packages/vercel-flags-core/src/index.common.ts @@ -1,7 +1,11 @@ export { - FlagNetworkDataSource, - type FlagNetworkDataSourceOptions, -} from './data-source/flag-network-data-source'; + Controller, + /** @deprecated Use `Controller` instead */ + Controller as FlagNetworkDataSource, + type ControllerOptions, + /** @deprecated Use `ControllerOptions` instead */ + type ControllerOptions as FlagNetworkDataSourceOptions, +} from './controller'; export { FallbackEntryNotFoundError, FallbackNotFoundError, diff --git a/packages/vercel-flags-core/src/index.make.test.ts b/packages/vercel-flags-core/src/index.make.test.ts index 135f612c..efdeb289 100644 --- a/packages/vercel-flags-core/src/index.make.test.ts +++ b/packages/vercel-flags-core/src/index.make.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { createCreateRawClient } from './create-raw-client'; import { make } from './index.make'; -// Mock the FlagNetworkDataSource to avoid real network calls -vi.mock('./data-source/flag-network-data-source', () => ({ - FlagNetworkDataSource: vi.fn().mockImplementation(({ sdkKey }) => ({ +// Mock the Controller to avoid real network calls +vi.mock('./controller', () => ({ + Controller: vi.fn().mockImplementation(({ sdkKey }) => ({ sdkKey, read: vi.fn().mockResolvedValue({ projectId: 'test', @@ -17,7 +17,7 @@ vi.mock('./data-source/flag-network-data-source', () => ({ })), })); -import { FlagNetworkDataSource } from './data-source/flag-network-data-source'; +import { Controller } from './controller'; function createMockCreateRawClient(): ReturnType { return vi.fn().mockImplementation(({ dataSource }) => ({ @@ -62,7 +62,7 @@ describe('make', () => { const client = createClient('vf_test_key'); - expect(FlagNetworkDataSource).toHaveBeenCalledWith({ + expect(Controller).toHaveBeenCalledWith({ sdkKey: 'vf_test_key', }); expect(createRawClient).toHaveBeenCalled(); @@ -77,7 +77,7 @@ describe('make', () => { 'flags:edgeConfigId=ecfg_123&edgeConfigToken=token&sdkKey=vf_conn_key'; const client = createClient(connectionString); - expect(FlagNetworkDataSource).toHaveBeenCalledWith({ + expect(Controller).toHaveBeenCalledWith({ sdkKey: 'vf_conn_key', }); expect(client).toBeDefined(); @@ -167,7 +167,7 @@ describe('make', () => { const { flagsClient } = make(createRawClient); const _ = flagsClient.evaluate; - expect(FlagNetworkDataSource).toHaveBeenCalledWith({ + expect(Controller).toHaveBeenCalledWith({ sdkKey: 'vf_env_key', }); }); @@ -180,7 +180,7 @@ describe('make', () => { const { flagsClient } = make(createRawClient); const _ = flagsClient.evaluate; - expect(FlagNetworkDataSource).toHaveBeenCalledWith({ + expect(Controller).toHaveBeenCalledWith({ sdkKey: 'vf_flags_key', }); }); @@ -213,7 +213,7 @@ describe('make', () => { // Access with first key const _ = flagsClient.evaluate; - expect(FlagNetworkDataSource).toHaveBeenCalledWith({ + expect(Controller).toHaveBeenCalledWith({ sdkKey: 'vf_first_key', }); @@ -223,7 +223,7 @@ describe('make', () => { // Access again with new key const __ = flagsClient.initialize; - expect(FlagNetworkDataSource).toHaveBeenCalledWith({ + expect(Controller).toHaveBeenCalledWith({ sdkKey: 'vf_second_key', }); }); diff --git a/packages/vercel-flags-core/src/index.make.ts b/packages/vercel-flags-core/src/index.make.ts index 422b984e..692859f5 100644 --- a/packages/vercel-flags-core/src/index.make.ts +++ b/packages/vercel-flags-core/src/index.make.ts @@ -2,18 +2,15 @@ * Factory functions for exports of index.default.ts and index.next-js.ts */ +import { Controller, type ControllerOptions } from './controller'; import type { createCreateRawClient } from './create-raw-client'; -import { - FlagNetworkDataSource, - type FlagNetworkDataSourceOptions, -} from './data-source/flag-network-data-source'; import type { FlagsClient } from './types'; import { parseSdkKeyFromFlagsConnectionString } from './utils/sdk-keys'; /** * Options for createClient */ -export type CreateClientOptions = Omit; +export type CreateClientOptions = Omit; export function make( createRawClient: ReturnType, @@ -45,7 +42,7 @@ export function make( } // sdk key contains the environment - const dataSource = new FlagNetworkDataSource({ sdkKey, ...options }); + const dataSource = new Controller({ sdkKey, ...options }); return createRawClient({ dataSource, origin: { provider: 'vercel', sdkKey }, diff --git a/packages/vercel-flags-core/src/openfeature.test.ts b/packages/vercel-flags-core/src/openfeature.test.ts index 2f5a7f10..07d68044 100644 --- a/packages/vercel-flags-core/src/openfeature.test.ts +++ b/packages/vercel-flags-core/src/openfeature.test.ts @@ -2,16 +2,39 @@ import { StandardResolutionReasons } from '@openfeature/server-sdk'; import { describe, expect, it } from 'vitest'; import * as fns from './client-fns'; import { createCreateRawClient } from './create-raw-client'; -import { InMemoryDataSource } from './data-source/in-memory-data-source'; import { VercelProvider } from './openfeature.default'; -import type { Packed } from './types'; +import type { Datafile, DataSource, Packed } from './types'; + +function createStaticDataSource(opts: { + data: Packed.Data; + projectId: string; + environment: string; +}): DataSource { + const datafile: Datafile = { + ...opts.data, + projectId: opts.projectId, + environment: opts.environment, + metrics: { + readMs: 0, + source: 'in-memory', + cacheStatus: 'HIT', + connectionState: 'connected', + }, + }; + return { + initialize: () => Promise.resolve(), + read: () => Promise.resolve(datafile), + getDatafile: () => Promise.resolve(datafile), + shutdown: () => {}, + }; +} const createRawClient = createCreateRawClient(fns); describe('VercelProvider', () => { describe('constructor', () => { it('should accept a FlagsClient', () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', @@ -35,7 +58,7 @@ describe('VercelProvider', () => { describe('resolveBooleanEvaluation', () => { it('should resolve a boolean flag', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: { 'boolean-flag': { @@ -62,7 +85,7 @@ describe('VercelProvider', () => { }); it('should return default value when flag is not found', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', @@ -82,7 +105,7 @@ describe('VercelProvider', () => { }); it('should use fallthrough outcome for active flags', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: { 'active-flag': { @@ -115,7 +138,7 @@ describe('VercelProvider', () => { describe('resolveStringEvaluation', () => { it('should resolve a string flag', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: { 'string-flag': { @@ -142,7 +165,7 @@ describe('VercelProvider', () => { }); it('should return default value when flag is not found', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', @@ -164,7 +187,7 @@ describe('VercelProvider', () => { describe('resolveNumberEvaluation', () => { it('should resolve a number flag', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: { 'number-flag': { @@ -191,7 +214,7 @@ describe('VercelProvider', () => { }); it('should return default value when flag is not found', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', @@ -213,7 +236,7 @@ describe('VercelProvider', () => { describe('resolveObjectEvaluation', () => { it('should resolve an object flag', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: { 'object-flag': { @@ -240,7 +263,7 @@ describe('VercelProvider', () => { }); it('should return default value when flag is not found', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', @@ -262,7 +285,7 @@ describe('VercelProvider', () => { describe('initialize', () => { it('should initialize without errors', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', @@ -276,7 +299,7 @@ describe('VercelProvider', () => { describe('onClose', () => { it('should close without errors', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', @@ -290,7 +313,7 @@ describe('VercelProvider', () => { describe('context passing', () => { it('should pass evaluation context to the client', async () => { - const dataSource = new InMemoryDataSource({ + const dataSource = createStaticDataSource({ data: { definitions: { 'context-flag': { From 069041ea49cb1ac53ae3284f01b5b3a41d5da2cd Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 09:04:44 +0200 Subject: [PATCH 02/65] remove retries --- .../src/controller/fetch-datafile.ts | 78 +++++++------------ .../vercel-flags-core/src/controller/index.ts | 21 +---- 2 files changed, 30 insertions(+), 69 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.ts index fe7eca0e..51ce3653 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.ts @@ -1,66 +1,40 @@ import { version } from '../../package.json'; import type { BundledDefinitions } from '../types'; -import { sleep } from '../utils/sleep'; -export const DEFAULT_FETCH_TIMEOUT_MS = 10_000; -export const MAX_FETCH_RETRIES = 3; -export const FETCH_RETRY_BASE_DELAY_MS = 500; +const DEFAULT_FETCH_TIMEOUT_MS = 10_000; /** - * Fetches the datafile from the flags service with retry logic. - * - * Implements exponential backoff with jitter for transient failures. - * Does not retry 4xx errors (except 429) as they indicate client errors. + * Fetches the datafile from the flags service. */ export async function fetchDatafile( host: string, sdkKey: string, fetchFn: typeof globalThis.fetch, ): Promise { - let lastError: Error | undefined; - - for (let attempt = 0; attempt < MAX_FETCH_RETRIES; attempt++) { - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - DEFAULT_FETCH_TIMEOUT_MS, - ); - - let shouldRetry = true; - try { - const res = await fetchFn(`${host}/v1/datafile`, { - headers: { - Authorization: `Bearer ${sdkKey}`, - 'User-Agent': `VercelFlagsCore/${version}`, - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!res.ok) { - // Don't retry 4xx errors (except 429) - if (res.status >= 400 && res.status < 500 && res.status !== 429) { - shouldRetry = false; - } - throw new Error(`Failed to fetch data: ${res.statusText}`); - } - - return res.json() as Promise; - } catch (error) { - clearTimeout(timeoutId); - lastError = - error instanceof Error ? error : new Error('Unknown fetch error'); - - if (!shouldRetry) throw lastError; - - if (attempt < MAX_FETCH_RETRIES - 1) { - const delay = - FETCH_RETRY_BASE_DELAY_MS * 2 ** attempt + Math.random() * 500; - await sleep(delay); - } + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + DEFAULT_FETCH_TIMEOUT_MS, + ); + + try { + const res = await fetchFn(`${host}/v1/datafile`, { + headers: { + Authorization: `Bearer ${sdkKey}`, + 'User-Agent': `VercelFlagsCore/${version}`, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + throw new Error(`Failed to fetch data: ${res.statusText}`); } - } - throw lastError ?? new Error('Failed to fetch data after retries'); + return res.json() as Promise; + } catch (error) { + clearTimeout(timeoutId); + throw error instanceof Error ? error : new Error('Unknown fetch error'); + } } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 15c5a30f..d05aafbe 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -8,21 +8,16 @@ import type { StreamOptions, } from '../types'; import { type TrackReadOptions, UsageTracker } from '../utils/usage-tracker'; - -export { BundledSource } from './bundled-source'; - import { BundledSource } from './bundled-source'; import { fetchDatafile } from './fetch-datafile'; - -export { PollingSource } from './polling-source'; - import { PollingSource } from './polling-source'; - -export { StreamSource } from './stream-source'; - import { StreamSource } from './stream-source'; import { originToMetricsSource, type TaggedData, tagData } from './tagged-data'; +export { BundledSource } from './bundled-source'; +export { PollingSource } from './polling-source'; +export { StreamSource } from './stream-source'; + const FLAGS_HOST = 'https://flags.vercel.com'; const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; const DEFAULT_POLLING_INTERVAL_MS = 30_000; @@ -330,14 +325,6 @@ export class Controller implements DataSource { return this.state === 'streaming'; } - private get isInitializing(): boolean { - return ( - this.state === 'initializing:stream' || - this.state === 'initializing:polling' || - this.state === 'initializing:fallback' - ); - } - // --------------------------------------------------------------------------- // Public API (DataSource interface) // --------------------------------------------------------------------------- From 909e327127198d891afae127311475dd149235f9 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 09:16:44 +0200 Subject: [PATCH 03/65] simplify --- .../vercel-flags-core/src/controller/index.ts | 182 +----------------- .../src/controller/normalized-options.ts | 121 ++++++++++++ .../src/controller/polling-source.ts | 6 +- .../vercel-flags-core/src/controller/utils.ts | 12 ++ 4 files changed, 146 insertions(+), 175 deletions(-) create mode 100644 packages/vercel-flags-core/src/controller/normalized-options.ts create mode 100644 packages/vercel-flags-core/src/controller/utils.ts diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index d05aafbe..99099ab4 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -4,96 +4,28 @@ import type { DatafileInput, DataSource, Metrics, - PollingOptions, - StreamOptions, } from '../types'; import { type TrackReadOptions, UsageTracker } from '../utils/usage-tracker'; import { BundledSource } from './bundled-source'; import { fetchDatafile } from './fetch-datafile'; +import { + type ControllerOptions, + type NormalizedOptions, + normalizeOptions, +} from './normalized-options'; import { PollingSource } from './polling-source'; import { StreamSource } from './stream-source'; import { originToMetricsSource, type TaggedData, tagData } from './tagged-data'; +import { parseConfigUpdatedAt } from './utils'; export { BundledSource } from './bundled-source'; export { PollingSource } from './polling-source'; export { StreamSource } from './stream-source'; -const FLAGS_HOST = 'https://flags.vercel.com'; -const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; -const DEFAULT_POLLING_INTERVAL_MS = 30_000; -const DEFAULT_POLLING_INIT_TIMEOUT_MS = 3_000; - -/** - * Configuration options for Controller - */ -export type ControllerOptions = { - /** SDK key for authentication (must start with "vf_") */ - sdkKey: string; - - /** - * Initial datafile to use immediately - * - At runtime: used while waiting for stream/poll, then updated in background - * - At build step: used as primary source (skips network) - */ - datafile?: DatafileInput; - - /** - * Configure streaming connection (runtime only, ignored during build step) - * - `true`: Enable with default options (initTimeoutMs: 3000) - * - `false`: Disable streaming - * - `{ initTimeoutMs: number }`: Enable with custom timeout - * @default true - */ - stream?: boolean | StreamOptions; - - /** - * Configure polling fallback (runtime only, ignored during build step) - * - `true`: Enable with default options (intervalMs: 30000, initTimeoutMs: 3000) - * - `false`: Disable polling - * - `{ intervalMs: number, initTimeoutMs: number }`: Enable with custom options - * @default true - */ - polling?: boolean | PollingOptions; - - /** - * Override build step detection - * - `true`: Treat as build step (use datafile/bundled only, no network) - * - `false`: Treat as runtime (try stream/poll first) - * @default auto-detected via CI=1 or NEXT_PHASE=phase-production-build - */ - buildStep?: boolean; - - /** - * Custom fetch function for making HTTP requests. - * Useful for testing (e.g. resolving to a different IP). - * @default globalThis.fetch - */ - fetch?: typeof globalThis.fetch; - - /** - * Custom source modules for dependency injection (testing). - * When provided, these replace the default source instances. - */ - sources?: { - stream?: StreamSource; - polling?: PollingSource; - bundled?: BundledSource; - }; -}; - // --------------------------------------------------------------------------- // Internal types // --------------------------------------------------------------------------- -type NormalizedOptions = { - sdkKey: string; - datafile: DatafileInput | undefined; - stream: { enabled: boolean; initTimeoutMs: number }; - polling: { enabled: boolean; intervalMs: number; initTimeoutMs: number }; - buildStep: boolean; - fetch: typeof globalThis.fetch; -}; - /** * Explicit states for the controller state machine. */ @@ -109,69 +41,6 @@ type State = | 'build:ready' | 'shutdown'; -// --------------------------------------------------------------------------- -// Option normalization -// --------------------------------------------------------------------------- - -function normalizeOptions(options: ControllerOptions): NormalizedOptions { - const autoDetectedBuildStep = - process.env.CI === '1' || - process.env.NEXT_PHASE === 'phase-production-build'; - const buildStep = options.buildStep ?? autoDetectedBuildStep; - - let stream: NormalizedOptions['stream']; - if (options.stream === undefined || options.stream === true) { - stream = { enabled: true, initTimeoutMs: DEFAULT_STREAM_INIT_TIMEOUT_MS }; - } else if (options.stream === false) { - stream = { enabled: false, initTimeoutMs: 0 }; - } else { - stream = { enabled: true, initTimeoutMs: options.stream.initTimeoutMs }; - } - - let polling: NormalizedOptions['polling']; - if (options.polling === undefined || options.polling === true) { - polling = { - enabled: true, - intervalMs: DEFAULT_POLLING_INTERVAL_MS, - initTimeoutMs: DEFAULT_POLLING_INIT_TIMEOUT_MS, - }; - } else if (options.polling === false) { - polling = { enabled: false, intervalMs: 0, initTimeoutMs: 0 }; - } else { - polling = { - enabled: true, - intervalMs: options.polling.intervalMs, - initTimeoutMs: options.polling.initTimeoutMs, - }; - } - - return { - sdkKey: options.sdkKey, - datafile: options.datafile, - stream, - polling, - buildStep, - fetch: options.fetch ?? globalThis.fetch, - }; -} - -// --------------------------------------------------------------------------- -// Utilities -// --------------------------------------------------------------------------- - -/** - * Parses a configUpdatedAt value (number or string) into a numeric timestamp. - * Returns undefined if the value is missing or cannot be parsed. - */ -function parseConfigUpdatedAt(value: unknown): number | undefined { - if (typeof value === 'number') return value; - if (typeof value === 'string') { - const parsed = Number(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; -} - // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- @@ -194,7 +63,6 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { */ export class Controller implements DataSource { private options: NormalizedOptions; - private host = FLAGS_HOST; // State machine private state: State = 'idle'; @@ -207,9 +75,6 @@ export class Controller implements DataSource { private pollingSource: PollingSource; private bundledSource: BundledSource; - // UI state - private hasWarnedAboutStaleData: boolean = false; - // Usage tracking private usageTracker: UsageTracker; private isFirstGetData: boolean = true; @@ -229,21 +94,10 @@ export class Controller implements DataSource { // Create source modules (or use injected ones for testing) this.streamSource = - options.sources?.stream ?? - new StreamSource({ - host: this.host, - sdkKey: this.options.sdkKey, - fetch: this.options.fetch, - }); + options.sources?.stream ?? new StreamSource(this.options); this.pollingSource = - options.sources?.polling ?? - new PollingSource({ - host: this.host, - sdkKey: this.options.sdkKey, - intervalMs: this.options.polling.intervalMs, - fetch: this.options.fetch, - }); + options.sources?.polling ?? new PollingSource(this.options); this.bundledSource = options.sources?.bundled ?? new BundledSource(this.options.sdkKey); @@ -256,10 +110,7 @@ export class Controller implements DataSource { this.data = tagData(this.options.datafile, 'provided'); } - this.usageTracker = new UsageTracker({ - sdkKey: this.options.sdkKey, - host: this.host, - }); + this.usageTracker = new UsageTracker(this.options); } // --------------------------------------------------------------------------- @@ -272,7 +123,6 @@ export class Controller implements DataSource { if (this.isNewerData(data)) { this.data = data; } - this.hasWarnedAboutStaleData = false; }); this.streamSource.on('connected', () => { @@ -434,7 +284,6 @@ export class Controller implements DataSource { ? tagData(this.options.datafile, 'provided') : undefined; this.transition('shutdown'); - this.hasWarnedAboutStaleData = false; await this.usageTracker.flush(); } @@ -677,7 +526,6 @@ export class Controller implements DataSource { cachedData?: TaggedData, ): [TaggedData, Metrics['cacheStatus']] { const data = cachedData ?? this.data!; - this.warnIfDisconnected(); const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; return [data, cacheStatus]; } @@ -776,18 +624,6 @@ export class Controller implements DataSource { return incomingTs >= currentTs; } - /** - * Logs a warning if returning cached data while stream is disconnected. - */ - private warnIfDisconnected(): void { - if (!this.isConnected && !this.hasWarnedAboutStaleData) { - this.hasWarnedAboutStaleData = true; - console.warn( - '@vercel/flags-core: Returning in-memory flag definitions while stream is disconnected. Data may be stale.', - ); - } - } - // --------------------------------------------------------------------------- // Usage tracking // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts new file mode 100644 index 00000000..e5d37a96 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -0,0 +1,121 @@ +import type { DatafileInput, PollingOptions, StreamOptions } from '../types'; +import type { BundledSource } from './bundled-source'; +import type { PollingSource } from './polling-source'; +import type { StreamSource } from './stream-source'; + +const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; +const DEFAULT_POLLING_INTERVAL_MS = 30_000; +const DEFAULT_POLLING_INIT_TIMEOUT_MS = 3_000; + +/** + * Configuration options for Controller + */ +export type ControllerOptions = { + /** SDK key for authentication (must start with "vf_") */ + sdkKey: string; + + /** + * Initial datafile to use immediately + * - At runtime: used while waiting for stream/poll, then updated in background + * - At build step: used as primary source (skips network) + */ + datafile?: DatafileInput; + + /** + * Configure streaming connection (runtime only, ignored during build step) + * - `true`: Enable with default options (initTimeoutMs: 3000) + * - `false`: Disable streaming + * - `{ initTimeoutMs: number }`: Enable with custom timeout + * @default true + */ + stream?: boolean | StreamOptions; + + /** + * Configure polling fallback (runtime only, ignored during build step) + * - `true`: Enable with default options (intervalMs: 30000, initTimeoutMs: 3000) + * - `false`: Disable polling + * - `{ intervalMs: number, initTimeoutMs: number }`: Enable with custom options + * @default true + */ + polling?: boolean | PollingOptions; + + /** + * Override build step detection + * - `true`: Treat as build step (use datafile/bundled only, no network) + * - `false`: Treat as runtime (try stream/poll first) + * @default auto-detected via CI=1 or NEXT_PHASE=phase-production-build + */ + buildStep?: boolean; + + /** + * Custom fetch function for making HTTP requests. + * Useful for testing (e.g. resolving to a different IP). + * @default globalThis.fetch + */ + fetch?: typeof globalThis.fetch; + + /** + * Custom source modules for dependency injection (testing). + * When provided, these replace the default source instances. + */ + sources?: { + stream?: StreamSource; + polling?: PollingSource; + bundled?: BundledSource; + }; +}; + +export type NormalizedOptions = { + sdkKey: string; + datafile: DatafileInput | undefined; + stream: { enabled: boolean; initTimeoutMs: number }; + polling: { enabled: boolean; intervalMs: number; initTimeoutMs: number }; + buildStep: boolean; + fetch: typeof globalThis.fetch; + host: string; +}; + +export function normalizeOptions( + options: ControllerOptions, +): NormalizedOptions { + const autoDetectedBuildStep = + process.env.CI === '1' || + process.env.NEXT_PHASE === 'phase-production-build'; + const buildStep = options.buildStep ?? autoDetectedBuildStep; + + let stream: NormalizedOptions['stream']; + if (options.stream === undefined || options.stream === true) { + stream = { enabled: true, initTimeoutMs: DEFAULT_STREAM_INIT_TIMEOUT_MS }; + } else if (options.stream === false) { + stream = { enabled: false, initTimeoutMs: 0 }; + } else { + stream = { enabled: true, initTimeoutMs: options.stream.initTimeoutMs }; + } + + let polling: NormalizedOptions['polling']; + if (options.polling === undefined || options.polling === true) { + polling = { + enabled: true, + intervalMs: DEFAULT_POLLING_INTERVAL_MS, + initTimeoutMs: DEFAULT_POLLING_INIT_TIMEOUT_MS, + }; + } else if (options.polling === false) { + polling = { enabled: false, intervalMs: 0, initTimeoutMs: 0 }; + } else { + polling = { + enabled: true, + intervalMs: options.polling.intervalMs, + initTimeoutMs: options.polling.initTimeoutMs, + }; + } + + return { + sdkKey: options.sdkKey, + datafile: options.datafile, + stream, + polling, + buildStep, + fetch: options.fetch ?? globalThis.fetch, + host: 'https://flags.vercel.com', + }; +} diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index f4253f5a..9ca92c5b 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -6,7 +6,9 @@ import { TypedEmitter } from './typed-emitter'; export type PollingSourceConfig = { host: string; sdkKey: string; - intervalMs: number; + polling: { + intervalMs: number; + }; fetch?: typeof globalThis.fetch; }; @@ -66,7 +68,7 @@ export class PollingSource extends TypedEmitter { // Start interval this.intervalId = setInterval( () => void this.poll(), - this.config.intervalMs, + this.config.polling.intervalMs, ); } diff --git a/packages/vercel-flags-core/src/controller/utils.ts b/packages/vercel-flags-core/src/controller/utils.ts new file mode 100644 index 00000000..6db03f41 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/utils.ts @@ -0,0 +1,12 @@ +/** + * Parses a configUpdatedAt value (number or string) into a numeric timestamp. + * Returns undefined if the value is missing or cannot be parsed. + */ +export function parseConfigUpdatedAt(value: unknown): number | undefined { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isNaN(parsed) ? undefined : parsed; + } + return undefined; +} From cccbb8c36e738b119610576cd94a25e998df2de0 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 09:22:51 +0200 Subject: [PATCH 04/65] simplify options --- .../src/controller/fetch-datafile.ts | 14 +++++++------- .../vercel-flags-core/src/controller/index.ts | 15 ++++----------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.ts index 51ce3653..a5d84022 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.ts @@ -6,11 +6,11 @@ const DEFAULT_FETCH_TIMEOUT_MS = 10_000; /** * Fetches the datafile from the flags service. */ -export async function fetchDatafile( - host: string, - sdkKey: string, - fetchFn: typeof globalThis.fetch, -): Promise { +export async function fetchDatafile(options: { + host: string; + sdkKey: string; + fetch: typeof globalThis.fetch; +}): Promise { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), @@ -18,9 +18,9 @@ export async function fetchDatafile( ); try { - const res = await fetchFn(`${host}/v1/datafile`, { + const res = await options.fetch(`${options.host}/v1/datafile`, { headers: { - Authorization: `Bearer ${sdkKey}`, + Authorization: `Bearer ${options.sdkKey}`, 'User-Agent': `VercelFlagsCore/${version}`, }, signal: controller.signal, diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 99099ab4..40786699 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -19,6 +19,7 @@ import { originToMetricsSource, type TaggedData, tagData } from './tagged-data'; import { parseConfigUpdatedAt } from './utils'; export { BundledSource } from './bundled-source'; +export type { ControllerOptions } from './normalized-options'; export { PollingSource } from './polling-source'; export { StreamSource } from './stream-source'; @@ -309,11 +310,7 @@ export class Controller implements DataSource { [result, cacheStatus] = this.getDataFromCache(); source = originToMetricsSource(result._origin); } else { - const fetched = await fetchDatafile( - this.host, - this.options.sdkKey, - this.options.fetch, - ); + const fetched = await fetchDatafile(this.options); const tagged = tagData(fetched, 'fetched'); if (this.isNewerData(tagged)) { this.data = tagged; @@ -482,11 +479,7 @@ export class Controller implements DataSource { return; } - const fetched = await fetchDatafile( - this.host, - this.options.sdkKey, - this.options.fetch, - ); + const fetched = await fetchDatafile(this.options); this.data = tagData(fetched, 'fetched'); } @@ -507,7 +500,7 @@ export class Controller implements DataSource { } const fetched = await fetchDatafile( - this.host, + this.options.host, this.options.sdkKey, this.options.fetch, ); From db00431c717e13100e6aeae9b18a6f660dad0a32 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 09:26:14 +0200 Subject: [PATCH 05/65] before --- packages/vercel-flags-core/src/controller/index.ts | 6 +----- .../vercel-flags-core/src/controller/polling-source.ts | 8 ++------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 40786699..686679bf 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -499,11 +499,7 @@ export class Controller implements DataSource { return [this.data, 'MISS']; } - const fetched = await fetchDatafile( - this.options.host, - this.options.sdkKey, - this.options.fetch, - ); + const fetched = await fetchDatafile(this.options); this.data = tagData(fetched, 'fetched'); return [this.data, 'MISS']; } diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 9ca92c5b..cf4d6aca 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -9,7 +9,7 @@ export type PollingSourceConfig = { polling: { intervalMs: number; }; - fetch?: typeof globalThis.fetch; + fetch: typeof globalThis.fetch; }; export type PollingSourceEvents = { @@ -39,11 +39,7 @@ export class PollingSource extends TypedEmitter { if (this.abortController?.signal.aborted) return; try { - const data = await fetchDatafile( - this.config.host, - this.config.sdkKey, - this.config.fetch ?? globalThis.fetch, - ); + const data = await fetchDatafile(this.config); const tagged = tagData(data, 'poll'); this.emit('data', tagged); } catch (error) { From 991a98d766951e78d602d07cdd866c728f443a5a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 15:16:50 +0200 Subject: [PATCH 06/65] rename DataSource to Controller --- packages/vercel-flags-core/src/client-map.ts | 9 - ...ent-fns.test.ts => controller-fns.test.ts} | 130 +++++++------- .../src/{client-fns.ts => controller-fns.ts} | 14 +- .../src/controller-instance-map.ts | 9 + .../src/controller/index.test.ts | 64 +------ .../vercel-flags-core/src/controller/index.ts | 4 +- .../src/create-raw-client.test.ts | 168 +++++++++--------- .../src/create-raw-client.ts | 35 ++-- .../vercel-flags-core/src/index.default.ts | 2 +- packages/vercel-flags-core/src/index.make.ts | 4 +- .../vercel-flags-core/src/index.next-js.ts | 2 +- .../vercel-flags-core/src/openfeature.test.ts | 60 +++---- packages/vercel-flags-core/src/types.ts | 2 +- 13 files changed, 229 insertions(+), 274 deletions(-) delete mode 100644 packages/vercel-flags-core/src/client-map.ts rename packages/vercel-flags-core/src/{client-fns.test.ts => controller-fns.test.ts} (80%) rename packages/vercel-flags-core/src/{client-fns.ts => controller-fns.ts} (84%) create mode 100644 packages/vercel-flags-core/src/controller-instance-map.ts diff --git a/packages/vercel-flags-core/src/client-map.ts b/packages/vercel-flags-core/src/client-map.ts deleted file mode 100644 index 9e5b5524..00000000 --- a/packages/vercel-flags-core/src/client-map.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { DataSource } from './types'; - -export type ClientInstance = { - dataSource: DataSource; - initialized: boolean; - initPromise: Promise | null; -}; - -export const clientMap = new Map(); diff --git a/packages/vercel-flags-core/src/client-fns.test.ts b/packages/vercel-flags-core/src/controller-fns.test.ts similarity index 80% rename from packages/vercel-flags-core/src/client-fns.test.ts rename to packages/vercel-flags-core/src/controller-fns.test.ts index 1a575277..5b6554fb 100644 --- a/packages/vercel-flags-core/src/client-fns.test.ts +++ b/packages/vercel-flags-core/src/controller-fns.test.ts @@ -4,9 +4,9 @@ import { getFallbackDatafile, initialize, shutdown, -} from './client-fns'; -import { clientMap } from './client-map'; -import type { BundledDefinitions, DataSource, Packed } from './types'; +} from './controller-fns'; +import { controllerInstanceMap } from './controller-instance-map'; +import type { BundledDefinitions, ControllerInterface, Packed } from './types'; import { ErrorCode, ResolutionReason } from './types'; // Mock the internalReportValue function @@ -16,7 +16,9 @@ vi.mock('./lib/report-value', () => ({ import { internalReportValue } from './lib/report-value'; -function createMockDataSource(overrides?: Partial): DataSource { +function createMockController( + overrides?: Partial, +): ControllerInterface { return { read: vi.fn().mockResolvedValue({ projectId: 'test-project', @@ -66,34 +68,34 @@ describe('client-fns', () => { const CLIENT_ID = 99; beforeEach(() => { - clientMap.clear(); + controllerInstanceMap.clear(); vi.clearAllMocks(); }); afterEach(() => { - clientMap.clear(); + controllerInstanceMap.clear(); }); describe('initialize', () => { - it('should call dataSource.initialize()', async () => { - const dataSource = createMockDataSource(); - clientMap.set(CLIENT_ID, { - dataSource, + it('should call controller.initialize()', async () => { + const controller = createMockController(); + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); await initialize(CLIENT_ID); - expect(dataSource.initialize).toHaveBeenCalledTimes(1); + expect(controller.initialize).toHaveBeenCalledTimes(1); }); - it('should return the result from dataSource.initialize()', async () => { - const dataSource = createMockDataSource({ + it('should return the result from controller.initialize()', async () => { + const controller = createMockController({ initialize: vi.fn().mockResolvedValue('init-result'), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -109,25 +111,25 @@ describe('client-fns', () => { }); describe('shutdown', () => { - it('should call dataSource.shutdown()', async () => { - const dataSource = createMockDataSource(); - clientMap.set(CLIENT_ID, { - dataSource, + it('should call controller.shutdown()', async () => { + const controller = createMockController(); + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); await shutdown(CLIENT_ID); - expect(dataSource.shutdown).toHaveBeenCalledTimes(1); + expect(controller.shutdown).toHaveBeenCalledTimes(1); }); - it('should return the result from dataSource.shutdown()', async () => { - const dataSource = createMockDataSource({ + it('should return the result from controller.shutdown()', async () => { + const controller = createMockController({ shutdown: vi.fn().mockResolvedValue('shutdown-result'), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -143,7 +145,7 @@ describe('client-fns', () => { }); describe('getFallbackDatafile', () => { - it('should call dataSource.getFallbackDatafile() if it exists', async () => { + it('should call controller.getFallbackDatafile() if it exists', async () => { const mockFallback: BundledDefinitions = { projectId: 'test', definitions: {}, @@ -153,11 +155,11 @@ describe('client-fns', () => { revision: 1, }; const getFallbackDatafileFn = vi.fn().mockResolvedValue(mockFallback); - const dataSource = createMockDataSource({ + const controller = createMockController({ getFallbackDatafile: getFallbackDatafileFn, }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -167,7 +169,7 @@ describe('client-fns', () => { expect(getFallbackDatafileFn).toHaveBeenCalledTimes(1); }); - it('should return the result from dataSource.getFallbackDatafile()', async () => { + it('should return the result from controller.getFallbackDatafile()', async () => { const mockFallback: BundledDefinitions = { projectId: 'test', definitions: {}, @@ -176,11 +178,11 @@ describe('client-fns', () => { digest: 'a', revision: 1, }; - const dataSource = createMockDataSource({ + const controller = createMockController({ getFallbackDatafile: vi.fn().mockResolvedValue(mockFallback), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -190,12 +192,12 @@ describe('client-fns', () => { expect(result).toEqual(mockFallback); }); - it('should throw if dataSource does not have getFallbackDatafile', () => { - const dataSource = createMockDataSource(); + it('should throw if controller does not have getFallbackDatafile', () => { + const controller = createMockController(); // Remove getFallbackDatafile - delete (dataSource as Partial).getFallbackDatafile; - clientMap.set(CLIENT_ID, { - dataSource, + delete (controller as Partial).getFallbackDatafile; + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -212,7 +214,7 @@ describe('client-fns', () => { describe('evaluate', () => { it('should return FLAG_NOT_FOUND error when flag does not exist', async () => { - const dataSource = createMockDataSource({ + const controller = createMockController({ read: vi.fn().mockResolvedValue( mockDatafile({ projectId: 'test', @@ -222,8 +224,8 @@ describe('client-fns', () => { }), ), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -241,7 +243,7 @@ describe('client-fns', () => { }); it('should use defaultValue when flag is not found', async () => { - const dataSource = createMockDataSource({ + const controller = createMockController({ read: vi.fn().mockResolvedValue( mockDatafile({ projectId: 'test', @@ -251,8 +253,8 @@ describe('client-fns', () => { }), ), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -268,7 +270,7 @@ describe('client-fns', () => { environments: { production: 0 }, variants: [true], }; - const dataSource = createMockDataSource({ + const controller = createMockController({ read: vi.fn().mockResolvedValue( mockDatafile({ projectId: 'test', @@ -278,8 +280,8 @@ describe('client-fns', () => { }), ), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -297,7 +299,7 @@ describe('client-fns', () => { environments: { production: 0 }, variants: ['variant-a'], }; - const dataSource = createMockDataSource({ + const controller = createMockController({ read: vi.fn().mockResolvedValue( mockDatafile({ projectId: 'my-project-id', @@ -307,8 +309,8 @@ describe('client-fns', () => { }), ), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -331,7 +333,7 @@ describe('client-fns', () => { environments: { production: 0 }, variants: [true], }; - const dataSource = createMockDataSource({ + const controller = createMockController({ read: vi.fn().mockResolvedValue( mockDatafile({ projectId: undefined, @@ -341,8 +343,8 @@ describe('client-fns', () => { }), ), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -353,7 +355,7 @@ describe('client-fns', () => { }); it('should not include outcomeType in report when result is error', async () => { - const dataSource = createMockDataSource({ + const controller = createMockController({ read: vi.fn().mockResolvedValue( mockDatafile({ projectId: 'test', @@ -363,8 +365,8 @@ describe('client-fns', () => { }), ), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -387,7 +389,7 @@ describe('client-fns', () => { }, variants: ['default', 'targeted'], }; - const dataSource = createMockDataSource({ + const controller = createMockController({ read: vi.fn().mockResolvedValue( mockDatafile({ projectId: 'test', @@ -397,8 +399,8 @@ describe('client-fns', () => { }), ), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -420,7 +422,7 @@ describe('client-fns', () => { }, variants: ['value'], }; - const dataSource = createMockDataSource({ + const controller = createMockController({ read: vi.fn().mockResolvedValue( mockDatafile({ projectId: 'test', @@ -430,8 +432,8 @@ describe('client-fns', () => { }), ), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); @@ -447,7 +449,7 @@ describe('client-fns', () => { }); it('should work with different value types', async () => { - const dataSource = createMockDataSource({ + const controller = createMockController({ read: vi.fn().mockResolvedValue( mockDatafile({ projectId: 'test', @@ -474,8 +476,8 @@ describe('client-fns', () => { }), ), }); - clientMap.set(CLIENT_ID, { - dataSource, + controllerInstanceMap.set(CLIENT_ID, { + controller, initialized: false, initPromise: null, }); diff --git a/packages/vercel-flags-core/src/client-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts similarity index 84% rename from packages/vercel-flags-core/src/client-fns.ts rename to packages/vercel-flags-core/src/controller-fns.ts index 5c00562c..f0ac4020 100644 --- a/packages/vercel-flags-core/src/client-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -1,23 +1,23 @@ -import { clientMap } from './client-map'; +import { controllerInstanceMap } from './controller-instance-map'; import { evaluate as evalFlag } from './evaluate'; import { internalReportValue } from './lib/report-value'; import type { BundledDefinitions, EvaluationResult, Packed } from './types'; import { ErrorCode, ResolutionReason } from './types'; export function initialize(id: number): Promise { - return clientMap.get(id)!.dataSource.initialize(); + return controllerInstanceMap.get(id)!.controller.initialize(); } export function shutdown(id: number): void | Promise { - return clientMap.get(id)!.dataSource.shutdown(); + return controllerInstanceMap.get(id)!.controller.shutdown(); } export function getDatafile(id: number) { - return clientMap.get(id)!.dataSource.getDatafile(); + return controllerInstanceMap.get(id)!.controller.getDatafile(); } export function getFallbackDatafile(id: number): Promise { - const ds = clientMap.get(id)!.dataSource; + const ds = controllerInstanceMap.get(id)!.controller; if (ds.getFallbackDatafile) return ds.getFallbackDatafile(); throw new Error('flags: This data source does not support fallbacks'); } @@ -28,8 +28,8 @@ export async function evaluate>( defaultValue?: T, entities?: E, ): Promise> { - const ds = clientMap.get(id)!.dataSource; - const datafile = await ds.read(); + const controller = controllerInstanceMap.get(id)!.controller; + const datafile = await controller.read(); const flagDefinition = datafile.definitions[flagKey] as Packed.FlagDefinition; if (flagDefinition === undefined) { diff --git a/packages/vercel-flags-core/src/controller-instance-map.ts b/packages/vercel-flags-core/src/controller-instance-map.ts new file mode 100644 index 00000000..245a6948 --- /dev/null +++ b/packages/vercel-flags-core/src/controller-instance-map.ts @@ -0,0 +1,9 @@ +import type { ControllerInterface } from './types'; + +export type ControllerInstance = { + controller: ControllerInterface; + initialized: boolean; + initPromise: Promise | null; +}; + +export const controllerInstanceMap = new Map(); diff --git a/packages/vercel-flags-core/src/controller/index.test.ts b/packages/vercel-flags-core/src/controller/index.test.ts index 82a2108d..6a1154e6 100644 --- a/packages/vercel-flags-core/src/controller/index.test.ts +++ b/packages/vercel-flags-core/src/controller/index.test.ts @@ -164,15 +164,15 @@ describe('Controller', () => { }), ); - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - const result = await dataSource.read(); + const controller = new Controller({ sdkKey: 'vf_test_key' }); + const result = await controller.read(); expect(result).toMatchObject(definitions); expect(result.metrics.source).toBe('in-memory'); expect(result.metrics.cacheStatus).toBe('MISS'); expect(result.metrics.connectionState).toBe('connected'); - await dataSource.shutdown(); + await controller.shutdown(); await assertIngestRequest('vf_test_key', [{ type: 'FLAGS_CONFIG_READ' }]); }); @@ -338,64 +338,6 @@ describe('Controller', () => { await dataSource.shutdown(); }); - it('should warn when returning in-memory data while stream is disconnected', async () => { - const definitions = { - projectId: 'test-project', - definitions: { flag: true }, - }; - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - // First, successfully connect and get data - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse( - createNdjsonStream([{ type: 'datafile', data: definitions }]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - await dataSource.read(); - - // Verify no warning on first successful read (stream is connected) - expect(warnSpy).not.toHaveBeenCalled(); - - // Now simulate stream disconnection by changing handler to error - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse(null, { status: 500 }); - }), - ); - - // Wait for the stream to close and try to reconnect (and fail) - await vi.waitFor( - () => { - expect(errorSpy).toHaveBeenCalled(); - }, - { timeout: 3000 }, - ); - - // Next read should warn about potentially stale data - await dataSource.read(); - - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Returning in-memory flag definitions'), - ); - - // Should only warn once - warnSpy.mockClear(); - await dataSource.read(); - expect(warnSpy).not.toHaveBeenCalled(); - - await dataSource.shutdown(); - - warnSpy.mockRestore(); - errorSpy.mockRestore(); - }, 10000); - describe('constructor validation', () => { it('should throw for missing SDK key', () => { expect(() => new Controller({ sdkKey: '' })).toThrow( diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 686679bf..872a3c51 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -1,8 +1,8 @@ import type { BundledDefinitions, + ControllerInterface, Datafile, DatafileInput, - DataSource, Metrics, } from '../types'; import { type TrackReadOptions, UsageTracker } from '../utils/usage-tracker'; @@ -62,7 +62,7 @@ type State = * - If stream reconnects while polling → stop polling * - If stream disconnects → start polling (if enabled) */ -export class Controller implements DataSource { +export class Controller implements ControllerInterface { private options: NormalizedOptions; // State machine diff --git a/packages/vercel-flags-core/src/create-raw-client.test.ts b/packages/vercel-flags-core/src/create-raw-client.test.ts index 3522cacd..28cf2013 100644 --- a/packages/vercel-flags-core/src/create-raw-client.test.ts +++ b/packages/vercel-flags-core/src/create-raw-client.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { clientMap } from './client-map'; +import { controllerInstanceMap } from './controller-instance-map'; import { createCreateRawClient } from './create-raw-client'; -import type { BundledDefinitions, DataSource } from './types'; +import type { BundledDefinitions, ControllerInterface } from './types'; -function createMockDataSource(overrides?: Partial): DataSource { +function createMockController( + overrides?: Partial, +): ControllerInterface { return { read: vi.fn().mockResolvedValue({ projectId: 'test-project', @@ -62,62 +64,62 @@ function createMockFns() { describe('createCreateRawClient', () => { beforeEach(() => { - clientMap.clear(); + controllerInstanceMap.clear(); }); afterEach(() => { - clientMap.clear(); + controllerInstanceMap.clear(); }); describe('client creation', () => { - it('should add dataSource to clientMap on creation', () => { + it('should add controller to controllerInstanceMap on creation', () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - expect(clientMap.size).toBe(0); + expect(controllerInstanceMap.size).toBe(0); - createRawClient({ dataSource }); + createRawClient({ controller }); - expect(clientMap.size).toBe(1); + expect(controllerInstanceMap.size).toBe(1); }); - it('should store the correct dataSource in clientMap', () => { + it('should store the correct controller in controllerInstanceMap', () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const initialSize = clientMap.size; - createRawClient({ dataSource }); + const initialSize = controllerInstanceMap.size; + createRawClient({ controller }); - // The dataSource should be stored in the map - expect(clientMap.size).toBe(initialSize + 1); + // The controller should be stored in the map + expect(controllerInstanceMap.size).toBe(initialSize + 1); // Find the entry that was just added - const entries = Array.from(clientMap.entries()); + const entries = Array.from(controllerInstanceMap.entries()); const lastEntry = entries[entries.length - 1]; - expect(lastEntry?.[1].dataSource).toBe(dataSource); + expect(lastEntry?.[1].controller).toBe(controller); }); it('should assign incrementing IDs to each client', () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const ds1 = createMockDataSource(); - const ds2 = createMockDataSource(); - const ds3 = createMockDataSource(); + const ds1 = createMockController(); + const ds2 = createMockController(); + const ds3 = createMockController(); - const initialSize = clientMap.size; + const initialSize = controllerInstanceMap.size; - createRawClient({ dataSource: ds1 }); - createRawClient({ dataSource: ds2 }); - createRawClient({ dataSource: ds3 }); + createRawClient({ controller: ds1 }); + createRawClient({ controller: ds2 }); + createRawClient({ controller: ds3 }); - expect(clientMap.size).toBe(initialSize + 3); - // Each dataSource should be stored under a different key - const entries = Array.from(clientMap.entries()).slice(-3); - expect(entries?.[0]?.[1].dataSource).toBe(ds1); - expect(entries?.[1]?.[1].dataSource).toBe(ds2); - expect(entries?.[2]?.[1].dataSource).toBe(ds3); + expect(controllerInstanceMap.size).toBe(initialSize + 3); + // Each controller should be stored under a different key + const entries = Array.from(controllerInstanceMap.entries()).slice(-3); + expect(entries?.[0]?.[1].controller).toBe(ds1); + expect(entries?.[1]?.[1].controller).toBe(ds2); + expect(entries?.[2]?.[1].controller).toBe(ds3); // IDs should be incrementing expect(entries?.[1]?.[0]).toBe(entries![0]![0] + 1); expect(entries?.[2]?.[0]).toBe(entries![1]![0] + 1); @@ -128,9 +130,9 @@ describe('createCreateRawClient', () => { it('should call fns.initialize with the client ID', async () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); await client.initialize(); expect(fns.initialize).toHaveBeenCalledTimes(1); @@ -138,35 +140,35 @@ describe('createCreateRawClient', () => { expect(fns.initialize).toHaveBeenCalledWith(expect.any(Number)); }); - it('should re-add dataSource to clientMap if removed', async () => { + it('should re-add controller to controllerInstanceMap if removed', async () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); // Simulate removal from map (e.g., after shutdown) - clientMap.clear(); - expect(clientMap.size).toBe(0); + controllerInstanceMap.clear(); + expect(controllerInstanceMap.size).toBe(0); await client.initialize(); // Should be re-added - expect(clientMap.size).toBe(1); + expect(controllerInstanceMap.size).toBe(1); }); - it('should not duplicate if already in clientMap', async () => { + it('should not duplicate if already in controllerInstanceMap', async () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); - expect(clientMap.size).toBe(1); + expect(controllerInstanceMap.size).toBe(1); await client.initialize(); - expect(clientMap.size).toBe(1); + expect(controllerInstanceMap.size).toBe(1); }); it('should deduplicate concurrent initialize() calls', async () => { @@ -176,9 +178,9 @@ describe('createCreateRawClient', () => { () => new Promise((resolve) => setTimeout(resolve, 50)), ); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); await Promise.all([ client.initialize(), @@ -195,9 +197,9 @@ describe('createCreateRawClient', () => { () => new Promise((resolve) => setTimeout(resolve, 50)), ); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); await Promise.all([ client.evaluate('flag-a'), @@ -215,9 +217,9 @@ describe('createCreateRawClient', () => { .mockRejectedValueOnce(new Error('init failed')) .mockResolvedValueOnce(undefined); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); await expect(client.initialize()).rejects.toThrow('init failed'); await client.initialize(); @@ -230,27 +232,27 @@ describe('createCreateRawClient', () => { it('should call fns.shutdown with the client ID', async () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); await client.shutdown(); expect(fns.shutdown).toHaveBeenCalledTimes(1); expect(fns.shutdown).toHaveBeenCalledWith(expect.any(Number)); }); - it('should remove dataSource from clientMap after shutdown', async () => { + it('should remove controller from controllerInstanceMap after shutdown', async () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); - expect(clientMap.size).toBe(1); + expect(controllerInstanceMap.size).toBe(1); await client.shutdown(); - expect(clientMap.size).toBe(0); + expect(controllerInstanceMap.size).toBe(0); }); }); @@ -258,9 +260,9 @@ describe('createCreateRawClient', () => { it('should call fns.getFallbackDatafile with the client ID', async () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); await client.getFallbackDatafile(); expect(fns.getFallbackDatafile).toHaveBeenCalledTimes(1); @@ -279,9 +281,9 @@ describe('createCreateRawClient', () => { } satisfies BundledDefinitions; fns.getFallbackDatafile.mockResolvedValue(mockFallback); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const result = await client.getFallbackDatafile(); expect(result).toEqual(mockFallback); @@ -293,9 +295,9 @@ describe('createCreateRawClient', () => { new Error('Fallback not supported'), ); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); await expect(client.getFallbackDatafile()).rejects.toThrow( 'Fallback not supported', @@ -307,9 +309,9 @@ describe('createCreateRawClient', () => { it('should call fns.evaluate with correct arguments', async () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); await client.evaluate('my-flag', false, { user: { id: '123' } }); expect(fns.evaluate).toHaveBeenCalledTimes(1); @@ -330,9 +332,9 @@ describe('createCreateRawClient', () => { }; fns.evaluate.mockResolvedValue(expectedResult); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const result = await client.evaluate('my-flag'); expect(result).toEqual(expectedResult); @@ -342,9 +344,9 @@ describe('createCreateRawClient', () => { const fns = createMockFns(); fns.evaluate.mockResolvedValue({ value: 42, reason: 'static' }); const createRawClient = createCreateRawClient(fns); - const dataSource = createMockDataSource(); + const controller = createMockController(); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const result = await client.evaluate('numeric-flag', 0); expect(result.value).toBe(42); @@ -356,26 +358,26 @@ describe('createCreateRawClient', () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const ds1 = createMockDataSource(); - const ds2 = createMockDataSource(); + const ds1 = createMockController(); + const ds2 = createMockController(); - const initialSize = clientMap.size; + const initialSize = controllerInstanceMap.size; - const client1 = createRawClient({ dataSource: ds1 }); - const client2 = createRawClient({ dataSource: ds2 }); + const client1 = createRawClient({ controller: ds1 }); + const client2 = createRawClient({ controller: ds2 }); - expect(clientMap.size).toBe(initialSize + 2); + expect(controllerInstanceMap.size).toBe(initialSize + 2); // Shutdown client1 await client1.shutdown(); // client2 should still be in the map - expect(clientMap.size).toBe(initialSize + 1); + expect(controllerInstanceMap.size).toBe(initialSize + 1); // ds2 should still be in the map - const dataSources = Array.from(clientMap.values()).map( - (v) => v.dataSource, + const controllers = Array.from(controllerInstanceMap.values()).map( + (v) => v.controller, ); - expect(dataSources).toContain(ds2); + expect(controllers).toContain(ds2); await client2.shutdown(); }); @@ -383,11 +385,11 @@ describe('createCreateRawClient', () => { const fns = createMockFns(); const createRawClient = createCreateRawClient(fns); - const ds1 = createMockDataSource(); - const ds2 = createMockDataSource(); + const ds1 = createMockController(); + const ds2 = createMockController(); - const client1 = createRawClient({ dataSource: ds1 }); - const client2 = createRawClient({ dataSource: ds2 }); + const client1 = createRawClient({ controller: ds1 }); + const client2 = createRawClient({ controller: ds2 }); await client1.evaluate('flag1'); await client2.evaluate('flag2'); diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index 4ab80b80..05166885 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -4,11 +4,14 @@ import type { getFallbackDatafile, initialize, shutdown, -} from './client-fns'; -import { type ClientInstance, clientMap } from './client-map'; +} from './controller-fns'; +import { + type ControllerInstance, + controllerInstanceMap, +} from './controller-instance-map'; import type { BundledDefinitions, - DataSource, + ControllerInterface, EvaluationResult, FlagsClient, Value, @@ -17,7 +20,7 @@ import type { let idCount = 0; async function performInitialize( - instance: ClientInstance, + instance: ControllerInstance, initFn: () => Promise, ): Promise { try { @@ -38,22 +41,26 @@ export function createCreateRawClient(fns: { getDatafile: typeof getDatafile; }) { return function createRawClient({ - dataSource, + controller, origin, }: { - dataSource: DataSource; + controller: ControllerInterface; origin?: { provider: string; sdkKey: string }; }): FlagsClient { const id = idCount++; - clientMap.set(id, { dataSource, initialized: false, initPromise: null }); + controllerInstanceMap.set(id, { + controller, + initialized: false, + initPromise: null, + }); const api = { origin, initialize: async () => { - let instance = clientMap.get(id); + let instance = controllerInstanceMap.get(id); if (!instance) { - instance = { dataSource, initialized: false, initPromise: null }; - clientMap.set(id, instance); + instance = { controller, initialized: false, initPromise: null }; + controllerInstanceMap.set(id, instance); } // skip if already initialized @@ -69,9 +76,11 @@ export function createCreateRawClient(fns: { }, shutdown: async () => { await fns.shutdown(id); - clientMap.delete(id); + controllerInstanceMap.delete(id); + }, + getDatafile: () => { + return fns.getDatafile(id); }, - getDatafile: () => fns.getDatafile(id), getFallbackDatafile: (): Promise => { return fns.getFallbackDatafile(id); }, @@ -80,7 +89,7 @@ export function createCreateRawClient(fns: { defaultValue?: T, entities?: E, ): Promise> => { - const instance = clientMap.get(id); + const instance = controllerInstanceMap.get(id); if (!instance?.initialized) await api.initialize(); return fns.evaluate(id, flagKey, defaultValue, entities); }, diff --git a/packages/vercel-flags-core/src/index.default.ts b/packages/vercel-flags-core/src/index.default.ts index fe7f0bac..00e5796f 100644 --- a/packages/vercel-flags-core/src/index.default.ts +++ b/packages/vercel-flags-core/src/index.default.ts @@ -10,7 +10,7 @@ * We do not need to repeat the JSDoc on the next-js export. */ -import * as fns from './client-fns'; +import * as fns from './controller-fns'; import { createCreateRawClient } from './create-raw-client'; import { make } from './index.make'; diff --git a/packages/vercel-flags-core/src/index.make.ts b/packages/vercel-flags-core/src/index.make.ts index 692859f5..593f3d1c 100644 --- a/packages/vercel-flags-core/src/index.make.ts +++ b/packages/vercel-flags-core/src/index.make.ts @@ -42,9 +42,9 @@ export function make( } // sdk key contains the environment - const dataSource = new Controller({ sdkKey, ...options }); + const controller = new Controller({ sdkKey, ...options }); return createRawClient({ - dataSource, + controller, origin: { provider: 'vercel', sdkKey }, }); } diff --git a/packages/vercel-flags-core/src/index.next-js.ts b/packages/vercel-flags-core/src/index.next-js.ts index 1e72da38..19422a18 100644 --- a/packages/vercel-flags-core/src/index.next-js.ts +++ b/packages/vercel-flags-core/src/index.next-js.ts @@ -11,7 +11,7 @@ */ import { cacheLife } from 'next/cache'; -import * as fns from './client-fns'; +import * as fns from './controller-fns'; import { createCreateRawClient } from './create-raw-client'; import { make } from './index.make'; diff --git a/packages/vercel-flags-core/src/openfeature.test.ts b/packages/vercel-flags-core/src/openfeature.test.ts index 07d68044..31f4b90e 100644 --- a/packages/vercel-flags-core/src/openfeature.test.ts +++ b/packages/vercel-flags-core/src/openfeature.test.ts @@ -1,15 +1,15 @@ import { StandardResolutionReasons } from '@openfeature/server-sdk'; import { describe, expect, it } from 'vitest'; -import * as fns from './client-fns'; +import * as fns from './controller-fns'; import { createCreateRawClient } from './create-raw-client'; import { VercelProvider } from './openfeature.default'; -import type { Datafile, DataSource, Packed } from './types'; +import type { ControllerInterface, Datafile, Packed } from './types'; -function createStaticDataSource(opts: { +function createStaticController(opts: { data: Packed.Data; projectId: string; environment: string; -}): DataSource { +}): ControllerInterface { const datafile: Datafile = { ...opts.data, projectId: opts.projectId, @@ -34,12 +34,12 @@ const createRawClient = createCreateRawClient(fns); describe('VercelProvider', () => { describe('constructor', () => { it('should accept a FlagsClient', () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); expect(provider.metadata.name).toBe('vercel-nodejs-provider'); @@ -58,7 +58,7 @@ describe('VercelProvider', () => { describe('resolveBooleanEvaluation', () => { it('should resolve a boolean flag', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: { 'boolean-flag': { @@ -71,7 +71,7 @@ describe('VercelProvider', () => { projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveBooleanEvaluation( @@ -85,12 +85,12 @@ describe('VercelProvider', () => { }); it('should return default value when flag is not found', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveBooleanEvaluation( @@ -105,7 +105,7 @@ describe('VercelProvider', () => { }); it('should use fallthrough outcome for active flags', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: { 'active-flag': { @@ -122,7 +122,7 @@ describe('VercelProvider', () => { projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveBooleanEvaluation( @@ -138,7 +138,7 @@ describe('VercelProvider', () => { describe('resolveStringEvaluation', () => { it('should resolve a string flag', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: { 'string-flag': { @@ -151,7 +151,7 @@ describe('VercelProvider', () => { projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveStringEvaluation( @@ -165,12 +165,12 @@ describe('VercelProvider', () => { }); it('should return default value when flag is not found', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveStringEvaluation( @@ -187,7 +187,7 @@ describe('VercelProvider', () => { describe('resolveNumberEvaluation', () => { it('should resolve a number flag', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: { 'number-flag': { @@ -200,7 +200,7 @@ describe('VercelProvider', () => { projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveNumberEvaluation( @@ -214,12 +214,12 @@ describe('VercelProvider', () => { }); it('should return default value when flag is not found', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveNumberEvaluation( @@ -236,7 +236,7 @@ describe('VercelProvider', () => { describe('resolveObjectEvaluation', () => { it('should resolve an object flag', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: { 'object-flag': { @@ -249,7 +249,7 @@ describe('VercelProvider', () => { projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveObjectEvaluation( @@ -263,12 +263,12 @@ describe('VercelProvider', () => { }); it('should return default value when flag is not found', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveObjectEvaluation( @@ -285,12 +285,12 @@ describe('VercelProvider', () => { describe('initialize', () => { it('should initialize without errors', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); await expect(provider.initialize()).resolves.toBeUndefined(); @@ -299,12 +299,12 @@ describe('VercelProvider', () => { describe('onClose', () => { it('should close without errors', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: {}, segments: {} }, projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); await expect(provider.onClose()).resolves.toBeUndefined(); @@ -313,7 +313,7 @@ describe('VercelProvider', () => { describe('context passing', () => { it('should pass evaluation context to the client', async () => { - const dataSource = createStaticDataSource({ + const controller = createStaticController({ data: { definitions: { 'context-flag': { @@ -331,7 +331,7 @@ describe('VercelProvider', () => { projectId: 'test', environment: 'production', }); - const client = createRawClient({ dataSource }); + const client = createRawClient({ controller }); const provider = new VercelProvider(client); const result = await provider.resolveStringEvaluation( diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 902d201e..940cd2e4 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -72,7 +72,7 @@ export type Metrics = { /** * DataSource interface for the Vercel Flags client */ -export interface DataSource { +export interface ControllerInterface { /** * Initialize the data source by fetching the initial file or setting up polling or * subscriptions. From 280ab20a0273b7fb62a7043b7ee7f777d7d81617 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 19:43:57 +0200 Subject: [PATCH 07/65] refactor and better tests --- packages/vercel-flags-core/package.json | 6 +- .../src/controller/bundled-source.ts | 9 +- .../vercel-flags-core/src/controller/index.ts | 7 +- .../src/create-raw-client.ts | 5 + packages/vercel-flags-core/src/manual.test.ts | 177 ++++++++ packages/vercel-flags-core/src/types.ts | 12 + pnpm-lock.yaml | 389 +++++++++++++++++- 7 files changed, 583 insertions(+), 22 deletions(-) create mode 100644 packages/vercel-flags-core/src/manual.test.ts diff --git a/packages/vercel-flags-core/package.json b/packages/vercel-flags-core/package.json index d30fd37f..25348f71 100644 --- a/packages/vercel-flags-core/package.json +++ b/packages/vercel-flags-core/package.json @@ -69,7 +69,9 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.18.2", + "@fetch-mock/vitest": "0.2.18", "@types/node": "20.11.17", + "fetch-mock": "12.6.0", "flags": "workspace:*", "msw": "2.6.4", "next": "16.1.6", @@ -79,9 +81,9 @@ "vitest": "2.1.9" }, "peerDependencies": { - "next": "*", "@openfeature/server-sdk": "1.18.0", - "flags": "*" + "flags": "*", + "next": "*" }, "peerDependenciesMeta": { "@openfeature/server-sdk": { diff --git a/packages/vercel-flags-core/src/controller/bundled-source.ts b/packages/vercel-flags-core/src/controller/bundled-source.ts index 9713e983..d216648e 100644 --- a/packages/vercel-flags-core/src/controller/bundled-source.ts +++ b/packages/vercel-flags-core/src/controller/bundled-source.ts @@ -1,6 +1,6 @@ import { FallbackEntryNotFoundError, FallbackNotFoundError } from '../errors'; import type { BundledDefinitions, BundledDefinitionsResult } from '../types'; -import { readBundledDefinitions } from '../utils/read-bundled-definitions'; +import type { readBundledDefinitions } from '../utils/read-bundled-definitions'; import type { TaggedData } from './tagged-data'; import { tagData } from './tagged-data'; import { TypedEmitter } from './typed-emitter'; @@ -16,10 +16,13 @@ export type BundledSourceEvents = { export class BundledSource extends TypedEmitter { private promise: Promise | undefined; - constructor(sdkKey: string) { + constructor(options: { + sdkKey: string; + readBundledDefinitions: typeof readBundledDefinitions; + }) { super(); // Eagerly start loading bundled definitions - this.promise = readBundledDefinitions(sdkKey); + this.promise = options.readBundledDefinitions(options.sdkKey); } /** diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 872a3c51..352a6499 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -5,6 +5,7 @@ import type { DatafileInput, Metrics, } from '../types'; +import { readBundledDefinitions } from '../utils/read-bundled-definitions'; import { type TrackReadOptions, UsageTracker } from '../utils/usage-tracker'; import { BundledSource } from './bundled-source'; import { fetchDatafile } from './fetch-datafile'; @@ -101,7 +102,11 @@ export class Controller implements ControllerInterface { options.sources?.polling ?? new PollingSource(this.options); this.bundledSource = - options.sources?.bundled ?? new BundledSource(this.options.sdkKey); + options.sources?.bundled ?? + new BundledSource({ + sdkKey: this.options.sdkKey, + readBundledDefinitions, + }); // Wire source events to state machine this.wireSourceEvents(); diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index 05166885..3f8993df 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -93,6 +93,11 @@ export function createCreateRawClient(fns: { if (!instance?.initialized) await api.initialize(); return fns.evaluate(id, flagKey, defaultValue, entities); }, + peek: () => { + const instance = controllerInstanceMap.get(id); + if (!instance) throw new Error(`Instance not found for id ${id}`); + return instance; + }, }; return api; }; diff --git a/packages/vercel-flags-core/src/manual.test.ts b/packages/vercel-flags-core/src/manual.test.ts new file mode 100644 index 00000000..9a29c3f1 --- /dev/null +++ b/packages/vercel-flags-core/src/manual.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { BundledSource, PollingSource, StreamSource } from './controller'; +import type { StreamMessage } from './controller/stream-connection'; +import { + BundledDefinitions, + createClient, + type FlagsClient, +} from './index.default'; +import type { readBundledDefinitions } from './utils/read-bundled-definitions'; + +/** + * Creates a mock NDJSON stream response for testing. + * + * Returns a controller object that lets you gradually push messages + * and a `response` promise suitable for use with a fetch mock. + * + * Usage: + * const stream = createMockStream(); + * fetchMock.mockReturnValueOnce(stream.response); + * stream.push({ type: 'datafile', data: datafile }); + * stream.close(); + */ +function createMockStream() { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController; + + const body = new ReadableStream({ + start(c) { + controller = c; + }, + }); + + return { + response: Promise.resolve(new Response(body, { status: 200 })), + push(message: StreamMessage) { + controller.enqueue(encoder.encode(JSON.stringify(message) + '\n')); + }, + close() { + controller.close(); + }, + }; +} + +const host = 'https://flags.vercel.com'; +const sdkKey = 'vf_fake'; +let clientFetchMock: Mock; +let streamFetchMock: Mock; +let stream: StreamSource; +let pollingFetchMock: Mock; +let polling: PollingSource; +let readBundledDefinitionsMock: Mock; +let bundled: BundledSource; +let client: FlagsClient; + +describe('Manual', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + + clientFetchMock = vi.fn(); + streamFetchMock = vi.fn(); + stream = new StreamSource({ + fetch: streamFetchMock, + host, + sdkKey, + }); + + pollingFetchMock = vi.fn(); + polling = new PollingSource({ + fetch: pollingFetchMock, + host, + sdkKey, + polling: { intervalMs: 1000 }, + }); + + readBundledDefinitionsMock = vi.fn(); + bundled = new BundledSource({ + readBundledDefinitions: readBundledDefinitionsMock, + sdkKey, + }); + + client = createClient(sdkKey, { + buildStep: false, + datafile: undefined, + fetch: clientFetchMock, + sources: { + stream, + polling, + bundled, + }, + }); + }); + + describe('creating a client', () => { + it('should only load the bundled definitions but not stream or poll', () => { + expect(client).toBeDefined(); + expect(streamFetchMock).not.toHaveBeenCalled(); + expect(pollingFetchMock).not.toHaveBeenCalled(); + expect(readBundledDefinitionsMock).toHaveBeenCalledWith(sdkKey); + }); + }); + + describe('initializing the client', () => { + it('should init from the stream', async () => { + const datafile = { + definitions: {}, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }; + + const messageStream = createMockStream(); + streamFetchMock.mockReturnValueOnce(messageStream.response); + messageStream.push({ type: 'datafile', data: datafile }); + + await client.initialize(); + + expect(streamFetchMock).toHaveBeenCalledTimes(1); + }); + + it('should fall back to bundled when stream and poll hangs', async () => { + const datafile: BundledDefinitions = { + definitions: {}, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }; + + // bundled definitions must be set up before creating the client, + // because BundledSource eagerly calls readBundledDefinitions in its constructor. + readBundledDefinitionsMock.mockReturnValue( + Promise.resolve({ + state: 'ok' as const, + definitions: datafile, + }), + ); + bundled = new BundledSource({ + readBundledDefinitions: readBundledDefinitionsMock, + sdkKey, + }); + client = createClient(sdkKey, { + buildStep: false, + datafile: undefined, + fetch: clientFetchMock, + sources: { stream, polling, bundled }, + }); + + // stream opens but never sends initial data + const messageStream = createMockStream(); + streamFetchMock.mockReturnValueOnce(messageStream.response); + + // polling request starts but never resolves + const neverResolving = new Promise(() => {}); + pollingFetchMock.mockReturnValue(neverResolving); + + const initPromise = client.initialize(); + + // Advance past the stream init timeout (3s) and polling init timeout (3s) + await vi.advanceTimersByTimeAsync(1_000); + expect(pollingFetchMock).toHaveBeenCalledTimes(0); + await vi.advanceTimersByTimeAsync(2_000); + expect(pollingFetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(3_000); + + // wait for init to resolve + await expect(initPromise).resolves.toBeUndefined(); + + expect(streamFetchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 940cd2e4..c93d24de 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -1,3 +1,5 @@ +import { ControllerInstance } from './controller-instance-map'; + /** * Options for stream connection behavior */ @@ -111,6 +113,11 @@ export type Source = { projectSlug: string; }; +export type PeekResult = { + datafile: Datafile; + fallbackDatafile?: BundledDefinitions; +}; + /** * A client for Vercel Flags */ @@ -155,6 +162,11 @@ export type FlagsClient = { * Throws FallbackEntryNotFoundError if the file exists but has no entry for the SDK key. */ getFallbackDatafile(): Promise; + + /** + * Peek offers insights into the client's current state. Used for debugging purposes. Not covered by semver. + */ + peek(): ControllerInstance; }; export type EvaluationParams = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 384b812f..8d08b53c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,94 @@ importers: specifier: ^5.7.3 version: 5.8.2 + examples/shirt-shop-vercel: + dependencies: + '@biomejs/biome': + specifier: ^2.3.13 + version: 2.3.13 + '@flags-sdk/vercel': + specifier: workspace:* + version: link:../../packages/adapter-vercel + '@headlessui/react': + specifier: ^2.2.0 + version: 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@heroicons/react': + specifier: 2.2.0 + version: 2.2.0(react@19.2.4) + '@tailwindcss/aspect-ratio': + specifier: 0.4.2 + version: 0.4.2(tailwindcss@4.1.18) + '@tailwindcss/forms': + specifier: 0.5.10 + version: 0.5.10(tailwindcss@4.1.18) + '@tailwindcss/postcss': + specifier: ^4.0.9 + version: 4.1.18 + '@tailwindcss/typography': + specifier: 0.5.16 + version: 0.5.16(tailwindcss@4.1.18) + '@vercel/analytics': + specifier: 1.5.0 + version: 1.5.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3) + '@vercel/edge': + specifier: 1.2.2 + version: 1.2.2 + '@vercel/edge-config': + specifier: 1.4.3 + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@vercel/flags-core': + specifier: workspace:* + version: link:../../packages/vercel-flags-core + '@vercel/toolbar': + specifier: 0.1.36 + version: 0.1.36(5571e7b359b94065007de485c6157db6) + clsx: + specifier: 2.1.1 + version: 2.1.1 + flags: + specifier: 4.0.1 + version: 4.0.1(@opentelemetry/api@1.9.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + js-xxhash: + specifier: 4.0.0 + version: 4.0.0 + motion: + specifier: 12.12.1 + version: 12.12.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nanoid: + specifier: 5.1.2 + version: 5.1.2 + next: + specifier: 16.1.6 + version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + sonner: + specifier: 2.0.1 + version: 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + devDependencies: + '@types/node': + specifier: ^22.13.5 + version: 22.14.0 + '@types/react': + specifier: ^19.0.10 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.4 + version: 19.2.3(@types/react@19.2.14) + postcss: + specifier: ^8.5.3 + version: 8.5.6 + tailwindcss: + specifier: ^4.0.9 + version: 4.1.18 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + examples/snippets: dependencies: '@radix-ui/react-dialog': @@ -812,7 +900,7 @@ importers: version: 5.2.1 react-dom: specifier: '*' - version: 19.2.0(react@19.3.0-canary-6066c782-20260212) + version: 19.2.0(react@19.3.0-canary-03ca38e6-20260213) devDependencies: '@arethetypeswrong/cli': specifier: 0.18.2 @@ -831,10 +919,10 @@ importers: version: 2.6.4(@types/node@20.11.17)(typescript@5.6.3) next: specifier: 16.1.5 - version: 16.1.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.3.0-canary-6066c782-20260212))(react@19.3.0-canary-6066c782-20260212) + version: 16.1.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.3.0-canary-03ca38e6-20260213))(react@19.3.0-canary-03ca38e6-20260213) react: specifier: canary - version: 19.3.0-canary-6066c782-20260212 + version: 19.3.0-canary-03ca38e6-20260213 tsup: specifier: 8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.6.3)(yaml@2.8.1) @@ -866,9 +954,15 @@ importers: '@arethetypeswrong/cli': specifier: 0.18.2 version: 0.18.2 + '@fetch-mock/vitest': + specifier: 0.2.18 + version: 0.2.18(vitest@2.1.9(@types/node@20.11.17)(lightningcss@1.30.2)(msw@2.6.4(@types/node@20.11.17)(typescript@5.6.3))) '@types/node': specifier: 20.11.17 version: 20.11.17 + fetch-mock: + specifier: 12.6.0 + version: 12.6.0 flags: specifier: workspace:* version: link:../flags @@ -1770,6 +1864,12 @@ packages: resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fetch-mock/vitest@0.2.18': + resolution: {integrity: sha512-s2bG7/MSwVFun5gTzrkZzJSmcdSurTmxt5B+JA/4ALyx0Pfo1al0/MlZPBtZ358Kkjv9CpRlhpyLf6bt4OrtLQ==} + engines: {node: '>=18.11.0'} + peerDependencies: + vitest: '*' + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -4111,6 +4211,9 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/glob-to-regexp@0.4.4': + resolution: {integrity: sha512-nDKoaKJYbnn1MZxUY0cA1bPmmgZbg0cTq7Rh13d0KWYNOiKbqoR+2d89SnRPszGh7ROzSwZ/GOjZ4jPbmmZ6Eg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -4325,6 +4428,9 @@ packages: resolution: {integrity: sha512-1++yncEyIAi68D3UEOlytYb1IUcIulMWdoSzX2h9LuSeeyR7JtaIgR8DcTQ6+DmYOQn+5MCh6LY+UmK6QBByNA==} deprecated: This package is deprecated. You should to use `@vercel/functions` instead. + '@vercel/edge@1.2.2': + resolution: {integrity: sha512-1+y+f6rk0Yc9ss9bRDgz/gdpLimwoRteKHhrcgHvEpjbP1nyT3ByqEMWm2BTcpIO5UtDmIFXc8zdq4LR190PDA==} + '@vercel/functions@1.6.0': resolution: {integrity: sha512-R6FKQrYT5MZs5IE1SqeCJWxMuBdHawFcCZboKKw8p7s+6/mcd55Gx6tWmyKnQTyrSEA04NH73Tc9CbqpEle8RA==} engines: {node: '>= 16'} @@ -5436,6 +5542,10 @@ packages: resolution: {integrity: sha512-hgH6CCb+7+0c8PBlakI2KubG6R+Rb1MhpNcdvqUXZTBwBHf32piwY255diAkAmkGZ6AWlywOU88AkOgP9q8Rdw==} engines: {node: '>=20', pnpm: '>=10'} + fetch-mock@12.6.0: + resolution: {integrity: sha512-oAy0OqAvjAvduqCeWveBix7LLuDbARPqZZ8ERYtBcCURA3gy7EALA3XWq0tCNxsSg+RmmJqyaeeZlOCV9abv6w==} + engines: {node: '>=18.11.0'} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -5461,6 +5571,26 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flags@4.0.1: + resolution: {integrity: sha512-nJNY97LoI+BDNCSnGIEvBAxYkRYeRuMZ3KtdjCj60quGH3cnyjnSQfw9vB/kvb3+wAtdn2sm5t+jO6dy5tpi1w==} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + '@sveltejs/kit': '*' + next: '*' + react: '*' + react-dom: '*' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + flags@4.0.3: resolution: {integrity: sha512-rLkO+Hn6dSEsDZm6lHuXr3GjfHf8N67lhXCFUeSRBjDdb/43ez5Je8DC/K0HzMtl3LcWc7zgF79V/3WzJXVm/w==} peerDependencies: @@ -5667,6 +5797,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -7243,8 +7376,8 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} - react@19.3.0-canary-6066c782-20260212: - resolution: {integrity: sha512-VRF1aVFk2iLHFObfNA5VGgbfJw8/kRsjvxbaPK33F/e1GU+K6RpV8gZvfes9Ih4ZAQgJuMMvXqCcz+hN8EjBhA==} + react@19.3.0-canary-03ca38e6-20260213: + resolution: {integrity: sha512-NNEFSftu7AEeOV6jq5Cu6PZI2kWf1C1AF6DihaPT8WICkmYh45+SphK96o3n9Y3ulHgtSsY4rZhwuVKC36r6Zw==} engines: {node: '>=0.10.0'} react@19.3.0-canary-da641178-20260129: @@ -7306,6 +7439,10 @@ packages: resolution: {integrity: sha512-6IQpFBv6e5vz1QAqI+V4k8P2e/3gRrqfCJ9FI+O1FLQTO+Uz6RXZEZOPmTJ6hlGj7gkERzY5BRCv09whKP96/g==} engines: {node: '>=6'} + regexparam@3.0.0: + resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} + engines: {node: '>=8'} + rehype-harden@1.1.7: resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==} @@ -8985,6 +9122,11 @@ snapshots: '@eslint/core': 0.16.0 levn: 0.4.1 + '@fetch-mock/vitest@0.2.18(vitest@2.1.9(@types/node@20.11.17)(lightningcss@1.30.2)(msw@2.6.4(@types/node@20.11.17)(typescript@5.6.3)))': + dependencies: + fetch-mock: 12.6.0 + vitest: 2.1.9(@types/node@20.11.17)(lightningcss@1.30.2)(msw@2.6.4(@types/node@20.11.17)(typescript@5.6.3)) + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -9020,6 +9162,14 @@ snapshots: react-dom: 19.2.0(react@19.2.0) tabbable: 6.3.0 + '@floating-ui/react@0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tabbable: 6.3.0 + '@floating-ui/utils@0.2.10': {} '@formatjs/intl-localematcher@0.6.2': @@ -9040,10 +9190,24 @@ snapshots: react-dom: 19.2.0(react@19.2.0) use-sync-external-store: 1.6.0(react@19.2.0) + '@headlessui/react@2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/focus': 3.21.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.25.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-virtual': 3.13.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + '@heroicons/react@2.2.0(react@19.2.0)': dependencies: react: 19.2.0 + '@heroicons/react@2.2.0(react@19.2.4)': + dependencies: + react: 19.2.4 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -10655,6 +10819,16 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@react-aria/focus@3.21.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/interactions': 3.25.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.31.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.32.1(react@19.2.4) + '@swc/helpers': 0.5.17 + clsx: 2.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@react-aria/interactions@3.25.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@react-aria/ssr': 3.9.10(react@19.2.0) @@ -10665,11 +10839,26 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@react-aria/interactions@3.25.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/ssr': 3.9.10(react@19.2.4) + '@react-aria/utils': 3.31.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/flags': 3.1.2 + '@react-types/shared': 3.32.1(react@19.2.4) + '@swc/helpers': 0.5.17 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@react-aria/ssr@3.9.10(react@19.2.0)': dependencies: '@swc/helpers': 0.5.17 react: 19.2.0 + '@react-aria/ssr@3.9.10(react@19.2.4)': + dependencies: + '@swc/helpers': 0.5.17 + react: 19.2.4 + '@react-aria/utils@3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@react-aria/ssr': 3.9.10(react@19.2.0) @@ -10681,6 +10870,17 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@react-aria/utils@3.31.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/ssr': 3.9.10(react@19.2.4) + '@react-stately/flags': 3.1.2 + '@react-stately/utils': 3.10.8(react@19.2.4) + '@react-types/shared': 3.32.1(react@19.2.4) + '@swc/helpers': 0.5.17 + clsx: 2.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@react-stately/flags@3.1.2': dependencies: '@swc/helpers': 0.5.17 @@ -10690,10 +10890,19 @@ snapshots: '@swc/helpers': 0.5.17 react: 19.2.0 + '@react-stately/utils@3.10.8(react@19.2.4)': + dependencies: + '@swc/helpers': 0.5.17 + react: 19.2.4 + '@react-types/shared@3.32.1(react@19.2.0)': dependencies: react: 19.2.0 + '@react-types/shared@3.32.1(react@19.2.4)': + dependencies: + react: 19.2.4 + '@reflag/flag-evaluation@1.0.0': dependencies: js-sha256: 0.11.0 @@ -10976,6 +11185,29 @@ snapshots: typescript: 5.8.2 optional: true + '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1))': + dependencies: + '@standard-schema/spec': 1.0.0 + '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + '@types/cookie': 0.6.0 + acorn: 8.15.0 + cookie: 0.6.0 + devalue: 5.6.2 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + sade: 1.8.1 + set-cookie-parser: 3.0.1 + sirv: 3.0.2 + svelte: 5.41.3 + vite: 6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + typescript: 5.9.3 + optional: true + '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1))': dependencies: '@standard-schema/spec': 1.0.0 @@ -11169,11 +11401,20 @@ snapshots: dependencies: tailwindcss: 4.0.15 + '@tailwindcss/aspect-ratio@0.4.2(tailwindcss@4.1.18)': + dependencies: + tailwindcss: 4.1.18 + '@tailwindcss/forms@0.5.10(tailwindcss@4.0.15)': dependencies: mini-svg-data-uri: 1.4.4 tailwindcss: 4.0.15 + '@tailwindcss/forms@0.5.10(tailwindcss@4.1.18)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 4.1.18 + '@tailwindcss/node@4.0.15': dependencies: enhanced-resolve: 5.18.3 @@ -11381,6 +11622,14 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.0.15 + '@tailwindcss/typography@0.5.16(tailwindcss@4.1.18)': + dependencies: + lodash.castarray: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + postcss-selector-parser: 6.0.10 + tailwindcss: 4.1.18 + '@tailwindcss/vite@4.0.15(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2))': dependencies: '@tailwindcss/node': 4.0.15 @@ -11395,6 +11644,12 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@tanstack/react-virtual@3.13.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@tanstack/virtual-core@3.13.12': {} '@tinyhttp/accepts@1.3.0': @@ -11615,6 +11870,8 @@ snapshots: '@types/geojson@7946.0.16': {} + '@types/glob-to-regexp@0.4.4': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -11796,6 +12053,13 @@ snapshots: react: 19.2.0 svelte: 5.41.3 + '@vercel/analytics@1.5.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3)': + optionalDependencies: + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + svelte: 5.41.3 + '@vercel/analytics@1.6.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(svelte@5.41.3)(typescript@5.8.2)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3)': optionalDependencies: '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(svelte@5.41.3)(typescript@5.8.2)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)) @@ -11835,6 +12099,13 @@ snapshots: '@opentelemetry/api': 1.9.0 next: 16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + dependencies: + '@vercel/edge-config-fs': 0.1.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-da641178-20260129))(react@19.3.0-canary-da641178-20260129))': dependencies: '@vercel/edge-config-fs': 0.1.0 @@ -11844,6 +12115,8 @@ snapshots: '@vercel/edge@1.2.1': {} + '@vercel/edge@1.2.2': {} + '@vercel/functions@1.6.0': {} '@vercel/functions@3.3.6': @@ -11892,6 +12165,27 @@ snapshots: transitivePeerDependencies: - debug + '@vercel/microfrontends@1.1.0(5571e7b359b94065007de485c6157db6)': + dependencies: + ajv: 8.17.1 + commander: 12.1.0 + cookie: 0.4.0 + fast-glob: 3.3.3 + http-proxy: 1.18.1 + jsonc-parser: 3.3.1 + nanoid: 3.3.11 + path-to-regexp: 6.2.1 + optionalDependencies: + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + '@vercel/analytics': 1.5.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3) + '@vercel/speed-insights': 1.3.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3) + next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + vite: 6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + transitivePeerDependencies: + - debug + '@vercel/microfrontends@1.1.0(d173fbb08c37b3b6bbf7e6a01a37a15f)': dependencies: ajv: 8.17.1 @@ -11958,6 +12252,14 @@ snapshots: svelte: 5.41.3 optional: true + '@vercel/speed-insights@1.3.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3)': + optionalDependencies: + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + svelte: 5.41.3 + optional: true + '@vercel/speed-insights@1.3.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3)': optionalDependencies: '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) @@ -12009,6 +12311,28 @@ snapshots: - debug - react-dom + '@vercel/toolbar@0.1.36(5571e7b359b94065007de485c6157db6)': + dependencies: + '@tinyhttp/app': 1.3.0 + '@vercel/microfrontends': 1.1.0(5571e7b359b94065007de485c6157db6) + chokidar: 3.6.0 + execa: 5.1.1 + fast-glob: 3.3.3 + find-up: 5.0.0 + get-port: 5.1.1 + jsonc-parser: 3.3.1 + strip-ansi: 6.0.1 + optionalDependencies: + next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + vite: 6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + transitivePeerDependencies: + - '@sveltejs/kit' + - '@vercel/analytics' + - '@vercel/speed-insights' + - debug + - react-dom + '@vercel/toolbar@0.1.36(d173fbb08c37b3b6bbf7e6a01a37a15f)': dependencies: '@tinyhttp/app': 1.3.0 @@ -13174,6 +13498,13 @@ snapshots: dependencies: xml-js: 1.6.11 + fetch-mock@12.6.0: + dependencies: + '@types/glob-to-regexp': 0.4.4 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + regexparam: 3.0.0 + fflate@0.8.2: {} file-entry-cache@8.0.0: @@ -13202,6 +13533,17 @@ snapshots: mlly: 1.8.0 rollup: 4.52.5 + flags@4.0.1(@opentelemetry/api@1.9.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@edge-runtime/cookies': 5.0.2 + jose: 5.2.1 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@22.14.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)) + next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + flags@4.0.3(@opentelemetry/api@1.9.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(svelte@5.41.3)(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1)))(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@edge-runtime/cookies': 5.0.2 @@ -13413,6 +13755,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + glob@10.4.5: dependencies: foreground-child: 3.3.1 @@ -14619,6 +14963,14 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + motion@12.12.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: framer-motion: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -14746,16 +15098,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.3.0-canary-6066c782-20260212))(react@19.3.0-canary-6066c782-20260212): + next@16.1.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.3.0-canary-03ca38e6-20260213))(react@19.3.0-canary-03ca38e6-20260213): dependencies: '@next/env': 16.1.5 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.9.19 caniuse-lite: 1.0.30001751 postcss: 8.4.31 - react: 19.3.0-canary-6066c782-20260212 - react-dom: 19.2.0(react@19.3.0-canary-6066c782-20260212) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.3.0-canary-6066c782-20260212) + react: 19.3.0-canary-03ca38e6-20260213 + react-dom: 19.2.0(react@19.3.0-canary-03ca38e6-20260213) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.3.0-canary-03ca38e6-20260213) optionalDependencies: '@next/swc-darwin-arm64': 16.1.5 '@next/swc-darwin-x64': 16.1.5 @@ -14901,7 +15253,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - optional: true next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-da641178-20260129))(react@19.3.0-canary-da641178-20260129): dependencies: @@ -15346,9 +15697,9 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 - react-dom@19.2.0(react@19.3.0-canary-6066c782-20260212): + react-dom@19.2.0(react@19.3.0-canary-03ca38e6-20260213): dependencies: - react: 19.3.0-canary-6066c782-20260212 + react: 19.3.0-canary-03ca38e6-20260213 scheduler: 0.27.0 react-dom@19.2.4(react@19.2.4): @@ -15466,7 +15817,7 @@ snapshots: react@19.2.4: {} - react@19.3.0-canary-6066c782-20260212: {} + react@19.3.0-canary-03ca38e6-20260213: {} react@19.3.0-canary-da641178-20260129: {} @@ -15540,6 +15891,8 @@ snapshots: regexparam@1.3.0: {} + regexparam@3.0.0: {} + rehype-harden@1.1.7: dependencies: unist-util-visit: 5.0.0 @@ -15842,6 +16195,11 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + sonner@2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -15974,10 +16332,10 @@ snapshots: optionalDependencies: '@babel/core': 7.28.5 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.3.0-canary-6066c782-20260212): + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.3.0-canary-03ca38e6-20260213): dependencies: client-only: 0.0.1 - react: 19.3.0-canary-6066c782-20260212 + react: 19.3.0-canary-03ca38e6-20260213 optionalDependencies: '@babel/core': 7.28.5 @@ -16000,7 +16358,6 @@ snapshots: dependencies: client-only: 0.0.1 react: 19.2.4 - optional: true styled-jsx@5.1.6(react@19.3.0-canary-da641178-20260129): dependencies: From 94724f917abeef23bd22c5710fd6ed5094b73638 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 19:56:32 +0200 Subject: [PATCH 08/65] avoid double polling --- .../vercel-flags-core/src/controller/index.ts | 2 ++ .../src/controller/polling-source.ts | 6 ++-- packages/vercel-flags-core/src/manual.test.ts | 35 ++++++++++++++++++- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 352a6499..7c6e9213 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -149,6 +149,7 @@ export class Controller implements ControllerInterface { // During initialization states, initialize() manages its own fallback chain. if (this.state === 'streaming') { if (this.options.polling.enabled) { + void this.pollingSource.poll(); this.pollingSource.startInterval(); this.transition('polling'); } else { @@ -461,6 +462,7 @@ export class Controller implements ControllerInterface { void this.streamSource.start(); this.transition('streaming'); } else if (this.options.polling.enabled) { + void this.pollingSource.poll(); this.pollingSource.startInterval(); this.transition('polling'); } else { diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index cf4d6aca..e4c10820 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -51,16 +51,14 @@ export class PollingSource extends TypedEmitter { /** * Start interval-based polling. - * Performs an initial poll immediately, then polls at the configured interval. + * Polls at the configured interval. Does not perform an initial poll — + * callers should call poll() first if an immediate poll is needed. */ startInterval(): void { if (this.intervalId) return; this.abortController = new AbortController(); - // Initial poll - void this.poll(); - // Start interval this.intervalId = setInterval( () => void this.poll(), diff --git a/packages/vercel-flags-core/src/manual.test.ts b/packages/vercel-flags-core/src/manual.test.ts index 9a29c3f1..9ae0fb1c 100644 --- a/packages/vercel-flags-core/src/manual.test.ts +++ b/packages/vercel-flags-core/src/manual.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { BundledSource, PollingSource, StreamSource } from './controller'; import type { StreamMessage } from './controller/stream-connection'; import { - BundledDefinitions, + type BundledDefinitions, createClient, type FlagsClient, } from './index.default'; @@ -172,6 +172,39 @@ describe('Manual', () => { await expect(initPromise).resolves.toBeUndefined(); expect(streamFetchMock).toHaveBeenCalledTimes(1); + expect(pollingFetchMock).toHaveBeenCalledTimes(1); + }); + + it('should fall back to polling without double-polling when stream hangs', async () => { + const datafile: BundledDefinitions = { + definitions: {}, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }; + + // stream opens but never sends initial data + const messageStream = createMockStream(); + streamFetchMock.mockReturnValueOnce(messageStream.response); + + // polling returns a valid datafile + pollingFetchMock.mockImplementation(() => + Promise.resolve(Response.json(datafile)), + ); + + const initPromise = client.initialize(); + + // Advance past the stream init timeout (3s) + await vi.advanceTimersByTimeAsync(3_000); + + await initPromise; + + // poll() should only be called once by tryInitializePolling, + // not a second time by startInterval's immediate poll + expect(pollingFetchMock).toHaveBeenCalledTimes(1); }); }); }); From ce621a24b4f38045c46ffab6bdd93e5e0a63965d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 22:29:11 +0200 Subject: [PATCH 09/65] rename --- .../src/{manual.test.ts => black-box.test.ts} | 5 +++++ 1 file changed, 5 insertions(+) rename packages/vercel-flags-core/src/{manual.test.ts => black-box.test.ts} (96%) diff --git a/packages/vercel-flags-core/src/manual.test.ts b/packages/vercel-flags-core/src/black-box.test.ts similarity index 96% rename from packages/vercel-flags-core/src/manual.test.ts rename to packages/vercel-flags-core/src/black-box.test.ts index 9ae0fb1c..6fd33a5e 100644 --- a/packages/vercel-flags-core/src/manual.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -1,3 +1,7 @@ +// extend client with concept of per-request data so we can set overrides? +// extend client with concept of request transaction so a single request is guaranteed consistent flag data? +// could be unexpected if used in a workflow or stream or whatever + import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { BundledSource, PollingSource, StreamSource } from './controller'; import type { StreamMessage } from './controller/stream-connection'; @@ -43,6 +47,7 @@ function createMockStream() { const host = 'https://flags.vercel.com'; const sdkKey = 'vf_fake'; + let clientFetchMock: Mock; let streamFetchMock: Mock; let stream: StreamSource; From f7ba29bc166e6365e7d8ac9cc6ad2b65a4102a88 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 22:29:28 +0200 Subject: [PATCH 10/65] fix --- packages/vercel-flags-core/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index c93d24de..7d2ed029 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -1,4 +1,4 @@ -import { ControllerInstance } from './controller-instance-map'; +import type { ControllerInstance } from './controller-instance-map'; /** * Options for stream connection behavior From 16901debedabdb3eb13a1f82115c1bbf27c41aa8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 22:42:58 +0200 Subject: [PATCH 11/65] simplify test setup --- .../vercel-flags-core/src/black-box.test.ts | 151 ++++++++++-------- 1 file changed, 85 insertions(+), 66 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 6fd33a5e..9e77a19a 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2,14 +2,19 @@ // extend client with concept of request transaction so a single request is guaranteed consistent flag data? // could be unexpected if used in a workflow or stream or whatever -import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest'; import { BundledSource, PollingSource, StreamSource } from './controller'; import type { StreamMessage } from './controller/stream-connection'; -import { - type BundledDefinitions, - createClient, - type FlagsClient, -} from './index.default'; +import { type BundledDefinitions, createClient } from './index.default'; +import type { BundledDefinitionsResult } from './types'; import type { readBundledDefinitions } from './utils/read-bundled-definitions'; /** @@ -48,56 +53,80 @@ function createMockStream() { const host = 'https://flags.vercel.com'; const sdkKey = 'vf_fake'; -let clientFetchMock: Mock; -let streamFetchMock: Mock; -let stream: StreamSource; -let pollingFetchMock: Mock; -let polling: PollingSource; -let readBundledDefinitionsMock: Mock; -let bundled: BundledSource; -let client: FlagsClient; +/** + * Creates a test client with isolated mocks. + * Each test can configure bundled definitions via the optional parameter, + * avoiding side effects from eager BundledSource construction. + */ +function createTestClient(options?: { + bundledResult?: BundledDefinitionsResult; +}) { + const clientFetchMock: Mock = vi.fn(); + const streamFetchMock: Mock = vi.fn(); + const stream = new StreamSource({ + fetch: streamFetchMock, + host, + sdkKey, + }); -describe('Manual', () => { - beforeEach(() => { - vi.resetAllMocks(); - vi.useFakeTimers(); + const pollingFetchMock: Mock = vi.fn(); + const polling = new PollingSource({ + fetch: pollingFetchMock, + host, + sdkKey, + polling: { intervalMs: 1000 }, + }); - clientFetchMock = vi.fn(); - streamFetchMock = vi.fn(); - stream = new StreamSource({ - fetch: streamFetchMock, - host, - sdkKey, - }); + const readBundledDefinitionsMock: Mock = + vi.fn(); + if (options?.bundledResult) { + readBundledDefinitionsMock.mockReturnValue( + Promise.resolve(options.bundledResult), + ); + } + const bundled = new BundledSource({ + readBundledDefinitions: readBundledDefinitionsMock, + sdkKey, + }); - pollingFetchMock = vi.fn(); - polling = new PollingSource({ - fetch: pollingFetchMock, - host, - sdkKey, - polling: { intervalMs: 1000 }, - }); + const client = createClient(sdkKey, { + buildStep: false, + datafile: undefined, + fetch: clientFetchMock, + sources: { + stream, + polling, + bundled, + }, + }); - readBundledDefinitionsMock = vi.fn(); - bundled = new BundledSource({ - readBundledDefinitions: readBundledDefinitionsMock, - sdkKey, - }); + return { + client, + clientFetchMock, + streamFetchMock, + pollingFetchMock, + readBundledDefinitionsMock, + }; +} - client = createClient(sdkKey, { - buildStep: false, - datafile: undefined, - fetch: clientFetchMock, - sources: { - stream, - polling, - bundled, - }, - }); +describe('Manual', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); }); describe('creating a client', () => { it('should only load the bundled definitions but not stream or poll', () => { + const { + client, + streamFetchMock, + pollingFetchMock, + readBundledDefinitionsMock, + } = createTestClient(); + expect(client).toBeDefined(); expect(streamFetchMock).not.toHaveBeenCalled(); expect(pollingFetchMock).not.toHaveBeenCalled(); @@ -107,6 +136,8 @@ describe('Manual', () => { describe('initializing the client', () => { it('should init from the stream', async () => { + const { client, streamFetchMock } = createTestClient(); + const datafile = { definitions: {}, segments: {}, @@ -137,23 +168,8 @@ describe('Manual', () => { revision: 1, }; - // bundled definitions must be set up before creating the client, - // because BundledSource eagerly calls readBundledDefinitions in its constructor. - readBundledDefinitionsMock.mockReturnValue( - Promise.resolve({ - state: 'ok' as const, - definitions: datafile, - }), - ); - bundled = new BundledSource({ - readBundledDefinitions: readBundledDefinitionsMock, - sdkKey, - }); - client = createClient(sdkKey, { - buildStep: false, - datafile: undefined, - fetch: clientFetchMock, - sources: { stream, polling, bundled }, + const { client, streamFetchMock, pollingFetchMock } = createTestClient({ + bundledResult: { state: 'ok', definitions: datafile }, }); // stream opens but never sends initial data @@ -161,8 +177,9 @@ describe('Manual', () => { streamFetchMock.mockReturnValueOnce(messageStream.response); // polling request starts but never resolves - const neverResolving = new Promise(() => {}); - pollingFetchMock.mockReturnValue(neverResolving); + pollingFetchMock.mockImplementation( + () => new Promise(() => {}), + ); const initPromise = client.initialize(); @@ -191,6 +208,8 @@ describe('Manual', () => { revision: 1, }; + const { client, streamFetchMock, pollingFetchMock } = createTestClient(); + // stream opens but never sends initial data const messageStream = createMockStream(); streamFetchMock.mockReturnValueOnce(messageStream.response); From bcb8a8dfa9d387b79a9928fcbd1fe87fd5939968 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 17 Feb 2026 09:29:35 +0200 Subject: [PATCH 12/65] wip --- .../src/black-box-msw.test.ts | 256 ++++++++++++++++++ .../vercel-flags-core/src/controller/index.ts | 2 +- .../src/utils/usage-tracker.test.ts | 28 ++ .../src/utils/usage-tracker.ts | 28 +- 4 files changed, 300 insertions(+), 14 deletions(-) create mode 100644 packages/vercel-flags-core/src/black-box-msw.test.ts diff --git a/packages/vercel-flags-core/src/black-box-msw.test.ts b/packages/vercel-flags-core/src/black-box-msw.test.ts new file mode 100644 index 00000000..34b8bc94 --- /dev/null +++ b/packages/vercel-flags-core/src/black-box-msw.test.ts @@ -0,0 +1,256 @@ +// extend client with concept of per-request data so we can set overrides? +// extend client with concept of request transaction so a single request is guaranteed consistent flag data? +// could be unexpected if used in a workflow or stream or whatever + +import { HttpResponse, http } from 'msw'; +import { setupServer } from 'msw/node'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { StreamMessage } from './controller/stream-connection'; +import { type BundledDefinitions, createClient } from './index.default'; + +vi.mock('./utils/read-bundled-definitions', () => ({ + readBundledDefinitions: vi.fn(() => + Promise.resolve({ definitions: null, state: 'missing-file' }), + ), +})); + +import { readBundledDefinitions } from './utils/read-bundled-definitions'; + +const host = 'https://flags.vercel.com'; +const sdkKey = 'vf_fake'; +const fetchMock = vi.fn(fetch); + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => { + vi.mocked(readBundledDefinitions).mockReset(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: null, + state: 'missing-file', + }); + fetchMock.mockClear(); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +/** + * Creates a mock NDJSON stream response for testing. + * + * Returns a controller object that lets you gradually push messages + * and a Response suitable for use with an MSW handler. + * + * Usage: + * const stream = createMockStream(); + * server.use(http.get(url, () => stream.response)); + * stream.push({ type: 'datafile', data: datafile }); + * stream.close(); + */ +function createMockStream() { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController; + + const body = new ReadableStream({ + start(c) { + controller = c; + }, + }); + + return { + response: new HttpResponse(body, { + status: 200, + headers: { 'Content-Type': 'application/x-ndjson' }, + }), + push(message: StreamMessage) { + controller.enqueue(encoder.encode(`${JSON.stringify(message)}\n`)); + }, + close() { + controller.close(); + }, + }; +} + +describe('Manual', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('creating a client', () => { + it('should only load the bundled definitions but not stream or poll', () => { + let streamRequested = false; + let pollRequested = false; + let usageReported = false; + + server.use( + http.get(`${host}/v1/stream`, () => { + streamRequested = true; + return new HttpResponse(null, { status: 200 }); + }), + http.get(`${host}/v1/datafile`, () => { + pollRequested = true; + return HttpResponse.json({}); + }), + http.get(`${host}/v1/usage`, () => { + usageReported = true; + return HttpResponse.json({}); + }), + ); + + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + }); + + expect(client).toBeDefined(); + expect(streamRequested).toBe(false); + expect(pollRequested).toBe(false); + expect(usageReported).toBe(false); + expect(readBundledDefinitions).toHaveBeenCalledWith(sdkKey); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + }); + + describe('initializing the client', () => { + it('should init from the stream', async () => { + let streamRequested = false; + + const datafile = { + definitions: {}, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }; + + const messageStream = createMockStream(); + + server.use( + http.get(`${host}/v1/stream`, () => { + streamRequested = true; + return messageStream.response; + }), + ); + + messageStream.push({ type: 'datafile', data: datafile }); + + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + }); + + await client.initialize(); + + expect(streamRequested).toBe(true); + + messageStream.close(); + await client.shutdown(); + }); + + it('should fall back to bundled when stream and poll hangs', async () => { + const datafile: BundledDefinitions = { + definitions: {}, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: datafile, + }); + + let pollCount = 0; + + server.use( + // stream opens but never sends initial data + http.get(`${host}/v1/stream`, () => { + return new HttpResponse(new ReadableStream({ start() {} }), { + headers: { 'Content-Type': 'application/x-ndjson' }, + }); + }), + // polling request starts but never resolves + http.get(`${host}/v1/datafile`, () => { + pollCount++; + return new Promise(() => {}); + }), + ); + + const client = createClient(sdkKey, { + buildStep: false, + }); + + const initPromise = client.initialize(); + + // Advance past the stream init timeout (3s) and polling init timeout (3s) + await vi.advanceTimersByTimeAsync(1_000); + expect(pollCount).toBe(0); + await vi.advanceTimersByTimeAsync(2_000); + expect(pollCount).toBe(1); + await vi.advanceTimersByTimeAsync(3_000); + + // wait for init to resolve + await expect(initPromise).resolves.toBeUndefined(); + }); + + it('should fall back to polling without double-polling when stream hangs', async () => { + const datafile: BundledDefinitions = { + definitions: {}, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }; + + let pollCount = 0; + + server.use( + // stream opens but never sends initial data + http.get(`${host}/v1/stream`, () => { + return new HttpResponse(new ReadableStream({ start() {} }), { + headers: { 'Content-Type': 'application/x-ndjson' }, + }); + }), + // polling returns a valid datafile + http.get(`${host}/v1/datafile`, () => { + pollCount++; + return HttpResponse.json(datafile); + }), + ); + + const client = createClient(sdkKey, { + buildStep: false, + }); + + const initPromise = client.initialize(); + + // Advance past the stream init timeout (3s) + await vi.advanceTimersByTimeAsync(3_000); + + await initPromise; + + // poll() should only be called once by tryInitializePolling, + // not a second time by startInterval's immediate poll + expect(pollCount).toBe(1); + }); + }); +}); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 7c6e9213..63e07cae 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -116,7 +116,7 @@ export class Controller implements ControllerInterface { this.data = tagData(this.options.datafile, 'provided'); } - this.usageTracker = new UsageTracker(this.options); + this.usageTracker = options.usageTracker ?? new UsageTracker(this.options); } // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index f9fa032d..9c5d7e10 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -35,6 +35,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); expect(tracker).toBeInstanceOf(UsageTracker); @@ -56,6 +57,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -89,6 +91,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -118,6 +121,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); // Track multiple reads (without request context, so they won't be deduplicated) @@ -147,6 +151,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'my-secret-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -170,6 +175,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -193,6 +199,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -216,6 +223,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); // Flush without tracking anything @@ -240,6 +248,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -264,6 +273,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -293,6 +303,7 @@ describe('UsageTracker', () => { const tracker = new FreshUsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -324,6 +335,7 @@ describe('UsageTracker', () => { const tracker = new FreshUsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -347,6 +359,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -374,6 +387,7 @@ describe('UsageTracker', () => { const tracker = new FreshUsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -404,6 +418,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -429,6 +444,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -474,6 +490,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); // Track multiple times with same context @@ -521,6 +538,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead(); @@ -555,6 +573,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); // Track 50 events (without request context to avoid deduplication) @@ -585,6 +604,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); // Should not throw @@ -610,6 +630,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead({ configOrigin: 'in-memory' }); @@ -638,6 +659,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead({ configOrigin: 'in-memory', cacheStatus: 'HIT' }); @@ -666,6 +688,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead({ configOrigin: 'in-memory', cacheIsFirstRead: true }); @@ -694,6 +717,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead({ configOrigin: 'in-memory', cacheIsBlocking: true }); @@ -722,6 +746,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); tracker.trackRead({ configOrigin: 'in-memory', duration: 150 }); @@ -750,6 +775,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); const timestamp = Date.now(); @@ -782,6 +808,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); const timestamp = Date.now(); @@ -823,6 +850,7 @@ describe('UsageTracker', () => { const tracker = new UsageTracker({ sdkKey: 'test-key', host: 'https://example.com', + fetch, }); // Only pass configOrigin, omit others diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index bcbff405..f604e7ab 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -75,6 +75,7 @@ function getRequestContext(): RequestContext { export interface UsageTrackerOptions { sdkKey: string; host: string; + fetch: typeof fetch; } export interface TrackReadOptions { @@ -96,8 +97,7 @@ export interface TrackReadOptions { * Tracks usage events and batches them for submission to the ingest endpoint. */ export class UsageTracker { - private sdkKey: string; - private host: string; + private options: UsageTrackerOptions; private batcher: EventBatcher = { events: [], resolveWait: null, @@ -105,8 +105,7 @@ export class UsageTracker { }; constructor(options: UsageTrackerOptions) { - this.sdkKey = options.sdkKey; - this.host = options.host; + this.options = options; } /** @@ -211,16 +210,19 @@ export class UsageTracker { this.batcher.events = []; try { - const response = await fetch(`${this.host}/v1/ingest`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.sdkKey}`, - 'User-Agent': `VercelFlagsCore/${version}`, - ...(isDebugMode ? { 'x-vercel-debug-ingest': '1' } : null), + const response = await this.options.fetch( + `${this.options.host}/v1/ingest`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.options.sdkKey}`, + 'User-Agent': `VercelFlagsCore/${version}`, + ...(isDebugMode ? { 'x-vercel-debug-ingest': '1' } : null), + }, + body: JSON.stringify(eventsToSend), }, - body: JSON.stringify(eventsToSend), - }); + ); debugLog( `@vercel/flags-core: Ingest response ${response.status} for ${eventsToSend.length} events on ${response.headers.get('x-vercel-id')}`, From 777a35f4e7ba1f38550168b2d652033ba5a167d9 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 19 Feb 2026 10:23:57 +0200 Subject: [PATCH 13/65] unify --- .../src/black-box-msw.test.ts | 256 ---------- .../vercel-flags-core/src/black-box.test.ts | 458 +++++++++++++----- 2 files changed, 350 insertions(+), 364 deletions(-) delete mode 100644 packages/vercel-flags-core/src/black-box-msw.test.ts diff --git a/packages/vercel-flags-core/src/black-box-msw.test.ts b/packages/vercel-flags-core/src/black-box-msw.test.ts deleted file mode 100644 index 34b8bc94..00000000 --- a/packages/vercel-flags-core/src/black-box-msw.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -// extend client with concept of per-request data so we can set overrides? -// extend client with concept of request transaction so a single request is guaranteed consistent flag data? -// could be unexpected if used in a workflow or stream or whatever - -import { HttpResponse, http } from 'msw'; -import { setupServer } from 'msw/node'; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; -import type { StreamMessage } from './controller/stream-connection'; -import { type BundledDefinitions, createClient } from './index.default'; - -vi.mock('./utils/read-bundled-definitions', () => ({ - readBundledDefinitions: vi.fn(() => - Promise.resolve({ definitions: null, state: 'missing-file' }), - ), -})); - -import { readBundledDefinitions } from './utils/read-bundled-definitions'; - -const host = 'https://flags.vercel.com'; -const sdkKey = 'vf_fake'; -const fetchMock = vi.fn(fetch); - -const server = setupServer(); - -beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); -beforeEach(() => { - vi.mocked(readBundledDefinitions).mockReset(); - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: null, - state: 'missing-file', - }); - fetchMock.mockClear(); -}); -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); - -/** - * Creates a mock NDJSON stream response for testing. - * - * Returns a controller object that lets you gradually push messages - * and a Response suitable for use with an MSW handler. - * - * Usage: - * const stream = createMockStream(); - * server.use(http.get(url, () => stream.response)); - * stream.push({ type: 'datafile', data: datafile }); - * stream.close(); - */ -function createMockStream() { - const encoder = new TextEncoder(); - let controller: ReadableStreamDefaultController; - - const body = new ReadableStream({ - start(c) { - controller = c; - }, - }); - - return { - response: new HttpResponse(body, { - status: 200, - headers: { 'Content-Type': 'application/x-ndjson' }, - }), - push(message: StreamMessage) { - controller.enqueue(encoder.encode(`${JSON.stringify(message)}\n`)); - }, - close() { - controller.close(); - }, - }; -} - -describe('Manual', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('creating a client', () => { - it('should only load the bundled definitions but not stream or poll', () => { - let streamRequested = false; - let pollRequested = false; - let usageReported = false; - - server.use( - http.get(`${host}/v1/stream`, () => { - streamRequested = true; - return new HttpResponse(null, { status: 200 }); - }), - http.get(`${host}/v1/datafile`, () => { - pollRequested = true; - return HttpResponse.json({}); - }), - http.get(`${host}/v1/usage`, () => { - usageReported = true; - return HttpResponse.json({}); - }), - ); - - const client = createClient(sdkKey, { - buildStep: false, - fetch: fetchMock, - }); - - expect(client).toBeDefined(); - expect(streamRequested).toBe(false); - expect(pollRequested).toBe(false); - expect(usageReported).toBe(false); - expect(readBundledDefinitions).toHaveBeenCalledWith(sdkKey); - expect(fetchMock).toHaveBeenCalledTimes(0); - }); - }); - - describe('initializing the client', () => { - it('should init from the stream', async () => { - let streamRequested = false; - - const datafile = { - definitions: {}, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }; - - const messageStream = createMockStream(); - - server.use( - http.get(`${host}/v1/stream`, () => { - streamRequested = true; - return messageStream.response; - }), - ); - - messageStream.push({ type: 'datafile', data: datafile }); - - const client = createClient(sdkKey, { - buildStep: false, - fetch: fetchMock, - }); - - await client.initialize(); - - expect(streamRequested).toBe(true); - - messageStream.close(); - await client.shutdown(); - }); - - it('should fall back to bundled when stream and poll hangs', async () => { - const datafile: BundledDefinitions = { - definitions: {}, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: datafile, - }); - - let pollCount = 0; - - server.use( - // stream opens but never sends initial data - http.get(`${host}/v1/stream`, () => { - return new HttpResponse(new ReadableStream({ start() {} }), { - headers: { 'Content-Type': 'application/x-ndjson' }, - }); - }), - // polling request starts but never resolves - http.get(`${host}/v1/datafile`, () => { - pollCount++; - return new Promise(() => {}); - }), - ); - - const client = createClient(sdkKey, { - buildStep: false, - }); - - const initPromise = client.initialize(); - - // Advance past the stream init timeout (3s) and polling init timeout (3s) - await vi.advanceTimersByTimeAsync(1_000); - expect(pollCount).toBe(0); - await vi.advanceTimersByTimeAsync(2_000); - expect(pollCount).toBe(1); - await vi.advanceTimersByTimeAsync(3_000); - - // wait for init to resolve - await expect(initPromise).resolves.toBeUndefined(); - }); - - it('should fall back to polling without double-polling when stream hangs', async () => { - const datafile: BundledDefinitions = { - definitions: {}, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }; - - let pollCount = 0; - - server.use( - // stream opens but never sends initial data - http.get(`${host}/v1/stream`, () => { - return new HttpResponse(new ReadableStream({ start() {} }), { - headers: { 'Content-Type': 'application/x-ndjson' }, - }); - }), - // polling returns a valid datafile - http.get(`${host}/v1/datafile`, () => { - pollCount++; - return HttpResponse.json(datafile); - }), - ); - - const client = createClient(sdkKey, { - buildStep: false, - }); - - const initPromise = client.initialize(); - - // Advance past the stream init timeout (3s) - await vi.advanceTimersByTimeAsync(3_000); - - await initPromise; - - // poll() should only be called once by tryInitializePolling, - // not a second time by startInterval's immediate poll - expect(pollCount).toBe(1); - }); - }); -}); diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 9e77a19a..0228d13d 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2,20 +2,19 @@ // extend client with concept of request transaction so a single request is guaranteed consistent flag data? // could be unexpected if used in a workflow or stream or whatever -import { - afterEach, - beforeEach, - describe, - expect, - it, - type Mock, - vi, -} from 'vitest'; -import { BundledSource, PollingSource, StreamSource } from './controller'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { StreamMessage } from './controller/stream-connection'; import { type BundledDefinitions, createClient } from './index.default'; -import type { BundledDefinitionsResult } from './types'; -import type { readBundledDefinitions } from './utils/read-bundled-definitions'; +import { readBundledDefinitions } from './utils/read-bundled-definitions'; + +vi.mock('./utils/read-bundled-definitions', () => ({ + readBundledDefinitions: vi.fn(() => + Promise.resolve({ definitions: null, state: 'missing-file' }), + ), +})); + +const sdkKey = 'vf_fake'; +const fetchMock = vi.fn(); /** * Creates a mock NDJSON stream response for testing. @@ -50,94 +49,298 @@ function createMockStream() { }; } -const host = 'https://flags.vercel.com'; -const sdkKey = 'vf_fake'; - -/** - * Creates a test client with isolated mocks. - * Each test can configure bundled definitions via the optional parameter, - * avoiding side effects from eager BundledSource construction. - */ -function createTestClient(options?: { - bundledResult?: BundledDefinitionsResult; -}) { - const clientFetchMock: Mock = vi.fn(); - const streamFetchMock: Mock = vi.fn(); - const stream = new StreamSource({ - fetch: streamFetchMock, - host, - sdkKey, +describe('Manual', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(readBundledDefinitions).mockReset(); + fetchMock.mockReset(); }); - const pollingFetchMock: Mock = vi.fn(); - const polling = new PollingSource({ - fetch: pollingFetchMock, - host, - sdkKey, - polling: { intervalMs: 1000 }, + afterEach(() => { + vi.useRealTimers(); }); - const readBundledDefinitionsMock: Mock = - vi.fn(); - if (options?.bundledResult) { - readBundledDefinitionsMock.mockReturnValue( - Promise.resolve(options.bundledResult), - ); - } - const bundled = new BundledSource({ - readBundledDefinitions: readBundledDefinitionsMock, - sdkKey, - }); + describe('buildStep', () => { + it('uses the datafile if provided, even when bundled definitions exist', async () => { + const passedDatafile: BundledDefinitions = { + definitions: { + flagA: { + environments: { + production: 1, + }, + variants: [false, true], + }, + }, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 2, + digest: 'abc', + revision: 2, + }; - const client = createClient(sdkKey, { - buildStep: false, - datafile: undefined, - fetch: clientFetchMock, - sources: { - stream, - polling, - bundled, - }, - }); + const bundledDatafile: BundledDefinitions = { + definitions: { + flagA: { + environments: { + production: 0, + }, + variants: [false, true], + }, + }, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }; - return { - client, - clientFetchMock, - streamFetchMock, - pollingFetchMock, - readBundledDefinitionsMock, - }; -} + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: bundledDatafile, + }); -describe('Manual', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); + const client = createClient(sdkKey, { + buildStep: true, + fetch: fetchMock, + datafile: passedDatafile, + }); - afterEach(() => { - vi.useRealTimers(); + await expect(client.evaluate('flagA')).resolves.toEqual({ + metrics: { + cacheStatus: 'HIT', + connectionState: 'disconnected', + evaluationMs: 0, + readMs: 0, + source: 'in-memory', + }, + outcomeType: 'value', + reason: 'paused', + // value is expected to be true instead of false, showing + // the passed definition is used instead of the bundled one + value: true, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + + // flush + await client.shutdown(); + + // verify tracking + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), + headers: { + Authorization: 'Bearer vf_fake', + 'Content-Type': 'application/json', + 'User-Agent': 'VercelFlagsCore/1.0.1', + }, + method: 'POST', + }, + ); + expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ + { + payload: { + cacheIsBlocking: false, + cacheIsFirstRead: true, + cacheStatus: 'HIT', + configOrigin: 'in-memory', + configUpdatedAt: 2, + duration: 0, + }, + ts: expect.any(Number), + type: 'FLAGS_CONFIG_READ', + }, + ]); + }); + + it('uses the bundled definitions if no datafile is provided', async () => { + const bundledDefinitions: BundledDefinitions = { + definitions: { + flagA: { + environments: { + production: 1, + }, + variants: [false, true], + }, + }, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 2, + digest: 'abc', + revision: 2, + }; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: bundledDefinitions, + }); + + const client = createClient(sdkKey, { + buildStep: true, + fetch: fetchMock, + }); + + await expect(client.evaluate('flagA')).resolves.toEqual({ + metrics: { + cacheStatus: 'HIT', + connectionState: 'disconnected', + evaluationMs: 0, + readMs: 0, + source: 'embedded', + }, + outcomeType: 'value', + reason: 'paused', + // value is expected to be true instead of false, showing + // the passed definition is used instead of the bundled one + value: true, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + + // flush + await client.shutdown(); + + // verify tracking + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), + headers: { + Authorization: 'Bearer vf_fake', + 'Content-Type': 'application/json', + 'User-Agent': 'VercelFlagsCore/1.0.1', + }, + method: 'POST', + }, + ); + expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ + { + payload: { + cacheIsBlocking: false, + cacheIsFirstRead: true, + cacheStatus: 'HIT', + configOrigin: 'embedded', + configUpdatedAt: 2, + duration: 0, + }, + ts: expect.any(Number), + type: 'FLAGS_CONFIG_READ', + }, + ]); + }); + + it('fetches only once during the build when no datafile and no bundled definitions are provided', async () => { + const definitions: BundledDefinitions = { + definitions: { + flagA: { + environments: { + production: 1, + }, + variants: [false, true], + }, + }, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 2, + digest: 'abc', + revision: 2, + }; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const client = createClient(sdkKey, { + buildStep: true, + fetch: fetchMock, + }); + + fetchMock.mockResolvedValue(Response.json(definitions)); + + await expect(client.evaluate('flagA')).resolves.toEqual({ + metrics: { + cacheStatus: 'HIT', + connectionState: 'disconnected', + evaluationMs: 0, + readMs: 0, + source: 'remote', + }, + outcomeType: 'value', + reason: 'paused', + // value is expected to be true instead of false, showing + // the passed definition is used instead of the bundled one + value: true, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + headers: { + Authorization: 'Bearer vf_fake', + 'User-Agent': 'VercelFlagsCore/1.0.1', + }, + signal: expect.any(AbortSignal), + }, + ); + + // flush + await client.shutdown(); + + // verify tracking + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), + headers: { + Authorization: 'Bearer vf_fake', + 'Content-Type': 'application/json', + 'User-Agent': 'VercelFlagsCore/1.0.1', + }, + method: 'POST', + }, + ); + expect(JSON.parse(fetchMock.mock.calls[1]?.[1]?.body as string)).toEqual([ + { + payload: { + cacheIsBlocking: false, + cacheIsFirstRead: true, + cacheStatus: 'HIT', + configOrigin: 'in-memory', + configUpdatedAt: 2, + duration: 0, + }, + ts: expect.any(Number), + type: 'FLAGS_CONFIG_READ', + }, + ]); + }); }); describe('creating a client', () => { it('should only load the bundled definitions but not stream or poll', () => { - const { - client, - streamFetchMock, - pollingFetchMock, - readBundledDefinitionsMock, - } = createTestClient(); + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + }); expect(client).toBeDefined(); - expect(streamFetchMock).not.toHaveBeenCalled(); - expect(pollingFetchMock).not.toHaveBeenCalled(); - expect(readBundledDefinitionsMock).toHaveBeenCalledWith(sdkKey); + expect(fetchMock).not.toHaveBeenCalled(); + expect(readBundledDefinitions).toHaveBeenCalledWith(sdkKey); }); }); describe('initializing the client', () => { it('should init from the stream', async () => { - const { client, streamFetchMock } = createTestClient(); - const datafile = { definitions: {}, segments: {}, @@ -149,12 +352,29 @@ describe('Manual', () => { }; const messageStream = createMockStream(); - streamFetchMock.mockReturnValueOnce(messageStream.response); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return messageStream.response; + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + }); + + const initPromise = client.initialize(); + messageStream.push({ type: 'datafile', data: datafile }); + await vi.advanceTimersByTimeAsync(0); - await client.initialize(); + await initPromise; - expect(streamFetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![0].toString()).toContain('/v1/stream'); }); it('should fall back to bundled when stream and poll hangs', async () => { @@ -168,33 +388,44 @@ describe('Manual', () => { revision: 1, }; - const { client, streamFetchMock, pollingFetchMock } = createTestClient({ - bundledResult: { state: 'ok', definitions: datafile }, + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: datafile, }); - // stream opens but never sends initial data - const messageStream = createMockStream(); - streamFetchMock.mockReturnValueOnce(messageStream.response); + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + // stream opens but never sends initial data + const body = new ReadableStream({ start() {} }); + return Promise.resolve(new Response(body, { status: 200 })); + } + if (url.includes('/v1/datafile')) { + // polling request starts but never resolves + pollCount++; + return new Promise(() => {}); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); - // polling request starts but never resolves - pollingFetchMock.mockImplementation( - () => new Promise(() => {}), - ); + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + }); const initPromise = client.initialize(); // Advance past the stream init timeout (3s) and polling init timeout (3s) await vi.advanceTimersByTimeAsync(1_000); - expect(pollingFetchMock).toHaveBeenCalledTimes(0); + expect(pollCount).toBe(0); await vi.advanceTimersByTimeAsync(2_000); - expect(pollingFetchMock).toHaveBeenCalledTimes(1); + expect(pollCount).toBe(1); await vi.advanceTimersByTimeAsync(3_000); // wait for init to resolve await expect(initPromise).resolves.toBeUndefined(); - - expect(streamFetchMock).toHaveBeenCalledTimes(1); - expect(pollingFetchMock).toHaveBeenCalledTimes(1); }); it('should fall back to polling without double-polling when stream hangs', async () => { @@ -208,16 +439,27 @@ describe('Manual', () => { revision: 1, }; - const { client, streamFetchMock, pollingFetchMock } = createTestClient(); - - // stream opens but never sends initial data - const messageStream = createMockStream(); - streamFetchMock.mockReturnValueOnce(messageStream.response); + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + // stream opens but never sends initial data + const body = new ReadableStream({ start() {} }); + return Promise.resolve(new Response(body, { status: 200 })); + } + if (url.includes('/v1/datafile')) { + // polling returns a valid datafile + pollCount++; + return Promise.resolve(Response.json(datafile)); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); - // polling returns a valid datafile - pollingFetchMock.mockImplementation(() => - Promise.resolve(Response.json(datafile)), - ); + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + }); const initPromise = client.initialize(); @@ -228,7 +470,7 @@ describe('Manual', () => { // poll() should only be called once by tryInitializePolling, // not a second time by startInterval's immediate poll - expect(pollingFetchMock).toHaveBeenCalledTimes(1); + expect(pollCount).toBe(1); }); }); }); From e2bc416ce881026fa2e8310e7360e0a7abbc7020 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 09:21:17 +0200 Subject: [PATCH 14/65] wip --- packages/vercel-flags-core/README.md | 2 ++ .../vercel-flags-core/src/black-box.test.ts | 35 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index d2a1396f..b84b0862 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -17,6 +17,8 @@ import { createClient } from '@vercel/flags-core'; const client = createClient(process.env.FLAGS!); +await client.initialize(); + const result = await client.evaluate('show-new-feature', false, { user: { id: 'user-123' }, }); diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 0228d13d..cf58bc03 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -244,6 +244,12 @@ describe('Manual', () => { }, variants: [false, true], }, + flagB: { + environments: { + production: 1, + }, + variants: [false, true], + }, }, segments: {}, environment: 'production', @@ -261,11 +267,38 @@ describe('Manual', () => { const client = createClient(sdkKey, { buildStep: true, fetch: fetchMock, + polling: { + initTimeoutMs: 5000, + intervalMs: 1000, + }, + stream: { + initTimeoutMs: 1000, + }, }); fetchMock.mockResolvedValue(Response.json(definitions)); - await expect(client.evaluate('flagA')).resolves.toEqual({ + const [a, b] = await Promise.all([ + client.evaluate('flagA'), + client.evaluate('flagB'), + ]); + + expect(a).toEqual({ + metrics: { + cacheStatus: 'HIT', + connectionState: 'disconnected', + evaluationMs: 0, + readMs: 0, + source: 'remote', + }, + outcomeType: 'value', + reason: 'paused', + // value is expected to be true instead of false, showing + // the passed definition is used instead of the bundled one + value: true, + }); + + expect(b).toEqual({ metrics: { cacheStatus: 'HIT', connectionState: 'disconnected', From ef623bb918d251c65d26c129150d50dbd7a3c5fb Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 09:32:34 +0200 Subject: [PATCH 15/65] only track 1 read per build --- packages/vercel-flags-core/CLAUDE.md | 5 ++- .../vercel-flags-core/src/controller/index.ts | 45 +++++++++++++------ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 25e5a2f9..d1939b22 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -75,6 +75,8 @@ Behavior differs based on environment: 2. **Bundled definitions** - Use `@vercel/flags-definitions` 3. **Fetch** - Last resort network fetch +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`): 1. **Stream** - Real-time updates via SSE, wait up to `initTimeoutMs` 2. **Polling** - Interval-based HTTP requests, wait up to `initTimeoutMs` @@ -152,7 +154,8 @@ pnpm test:integration - Batches flag read events (max 50 events, max 5s wait) - Sends to `flags.vercel.com/v1/ingest` -- Deduplicates by request context +- At runtime: deduplicates by request context (WeakSet in UsageTracker) +- During builds: deduplicates all reads to a single event (buildReadTracked flag in Controller), since there is no request context available - Uses `waitUntil()` from `@vercel/functions` ### Client Management diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 63e07cae..150c565a 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -81,6 +81,10 @@ export class Controller implements ControllerInterface { private usageTracker: UsageTracker; private isFirstGetData: boolean = true; + // Build-step deduplication + private buildDataPromise: Promise | null = null; + private buildReadTracked = false; + constructor(options: ControllerOptions) { if ( !options.sdkKey || @@ -480,18 +484,16 @@ export class Controller implements ControllerInterface { private async initializeForBuildStep(): Promise { if (this.data) return; - const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - this.data = bundled; - return; + if (!this.buildDataPromise) { + this.buildDataPromise = this.loadBuildData(); } - - const fetched = await fetchDatafile(this.options); - this.data = tagData(fetched, 'fetched'); + this.data = await this.buildDataPromise; } /** * Retrieves data during build steps. + * Concurrent callers share a single load promise. The first caller to + * populate `this.data` gets cacheStatus MISS; subsequent callers get HIT. */ private async getDataForBuildStep(): Promise< [TaggedData, Metrics['cacheStatus']] @@ -500,15 +502,28 @@ export class Controller implements ControllerInterface { return [this.data, 'HIT']; } - const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - this.data = bundled; - return [this.data, 'MISS']; + if (!this.buildDataPromise) { + this.buildDataPromise = this.loadBuildData(); } + const data = await this.buildDataPromise; + + if (!this.data) { + this.data = data; + return [data, 'MISS']; + } + return [this.data, 'HIT']; + } + + /** + * Loads data for a build step: provided → bundled → fetch. + */ + private async loadBuildData(): Promise { + const bundled = await this.bundledSource.tryLoad(); + if (bundled) return bundled; + const fetched = await fetchDatafile(this.options); - this.data = tagData(fetched, 'fetched'); - return [this.data, 'MISS']; + return tagData(fetched, 'fetched'); } // --------------------------------------------------------------------------- @@ -626,6 +641,7 @@ export class Controller implements ControllerInterface { /** * Tracks a read operation for usage analytics. + * During build steps, only the first read is tracked. */ private trackRead( startTime: number, @@ -633,6 +649,9 @@ export class Controller implements ControllerInterface { isFirstRead: boolean, source: Metrics['source'], ): void { + if (this.options.buildStep && this.buildReadTracked) return; + if (this.options.buildStep) this.buildReadTracked = true; + const configOrigin: 'in-memory' | 'embedded' = source === 'embedded' ? 'embedded' : 'in-memory'; const trackOptions: TrackReadOptions = { From a8c21bcec24b5c083c3c00b694c19d0072446905 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 10:44:21 +0200 Subject: [PATCH 16/65] make BundledSource lazy --- .../vercel-flags-core/src/black-box.test.ts | 8 +++++-- .../src/controller/bundled-source.ts | 21 ++++++++++--------- .../src/controller/index.test.ts | 6 +++--- .../vercel-flags-core/src/controller/index.ts | 7 +++++++ .../src/utils/usage-tracker.ts | 12 ++++++++--- 5 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index cf58bc03..a758e1ac 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -144,6 +144,7 @@ describe('Manual', () => { expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ { payload: { + cacheAction: 'NONE', cacheIsBlocking: false, cacheIsFirstRead: true, cacheStatus: 'HIT', @@ -222,6 +223,7 @@ describe('Manual', () => { expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ { payload: { + cacheAction: 'NONE', cacheIsBlocking: false, cacheIsFirstRead: true, cacheStatus: 'HIT', @@ -345,6 +347,7 @@ describe('Manual', () => { expect(JSON.parse(fetchMock.mock.calls[1]?.[1]?.body as string)).toEqual([ { payload: { + cacheAction: 'NONE', cacheIsBlocking: false, cacheIsFirstRead: true, cacheStatus: 'HIT', @@ -360,7 +363,7 @@ describe('Manual', () => { }); describe('creating a client', () => { - it('should only load the bundled definitions but not stream or poll', () => { + it('should not load bundled definitions or stream or poll on creation', () => { const client = createClient(sdkKey, { buildStep: false, fetch: fetchMock, @@ -368,7 +371,8 @@ describe('Manual', () => { expect(client).toBeDefined(); expect(fetchMock).not.toHaveBeenCalled(); - expect(readBundledDefinitions).toHaveBeenCalledWith(sdkKey); + // Bundled definitions are loaded lazily, not at construction time + expect(readBundledDefinitions).not.toHaveBeenCalled(); }); }); diff --git a/packages/vercel-flags-core/src/controller/bundled-source.ts b/packages/vercel-flags-core/src/controller/bundled-source.ts index d216648e..b2c4adb8 100644 --- a/packages/vercel-flags-core/src/controller/bundled-source.ts +++ b/packages/vercel-flags-core/src/controller/bundled-source.ts @@ -15,14 +15,17 @@ export type BundledSourceEvents = { */ export class BundledSource extends TypedEmitter { private promise: Promise | undefined; + private options: { + sdkKey: string; + readBundledDefinitions: typeof readBundledDefinitions; + }; constructor(options: { sdkKey: string; readBundledDefinitions: typeof readBundledDefinitions; }) { super(); - // Eagerly start loading bundled definitions - this.promise = options.readBundledDefinitions(options.sdkKey); + this.options = options; } /** @@ -33,7 +36,7 @@ export class BundledSource extends TypedEmitter { async load(): Promise { const result = await this.getResult(); - if (result?.state === 'ok' && result.definitions) { + if (result.state === 'ok' && result.definitions) { const tagged = tagData(result.definitions, 'bundled'); this.emit('data', tagged); return tagged; @@ -52,10 +55,6 @@ export class BundledSource extends TypedEmitter { async getRaw(): Promise { const result = await this.getResult(); - if (!result) { - throw new FallbackNotFoundError(); - } - switch (result.state) { case 'ok': return result.definitions; @@ -76,7 +75,7 @@ export class BundledSource extends TypedEmitter { */ async tryLoad(): Promise { const result = await this.getResult(); - if (result?.state === 'ok' && result.definitions) { + if (result.state === 'ok' && result.definitions) { const tagged = tagData(result.definitions, 'bundled'); this.emit('data', tagged); return tagged; @@ -84,8 +83,10 @@ export class BundledSource extends TypedEmitter { return undefined; } - private async getResult(): Promise { - if (!this.promise) return undefined; + private getResult(): Promise { + if (!this.promise) { + this.promise = this.options.readBundledDefinitions(this.options.sdkKey); + } return this.promise; } } diff --git a/packages/vercel-flags-core/src/controller/index.test.ts b/packages/vercel-flags-core/src/controller/index.test.ts index 6a1154e6..9bbee2ba 100644 --- a/packages/vercel-flags-core/src/controller/index.test.ts +++ b/packages/vercel-flags-core/src/controller/index.test.ts @@ -1332,8 +1332,8 @@ describe('Controller', () => { }); describe('buildStep option', () => { - it('should always load bundled definitions regardless of buildStep', async () => { - // bundled definitions are always loaded as ultimate fallback + it('should not load bundled definitions eagerly at construction time', async () => { + // bundled definitions are loaded lazily, not at construction time const dataSource = new Controller({ sdkKey: 'vf_test_key', buildStep: false, @@ -1341,7 +1341,7 @@ describe('Controller', () => { polling: false, }); - expect(readBundledDefinitions).toHaveBeenCalledWith('vf_test_key'); + expect(readBundledDefinitions).not.toHaveBeenCalled(); await dataSource.shutdown(); }); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 150c565a..2191f567 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -654,9 +654,16 @@ export class Controller implements ControllerInterface { const configOrigin: 'in-memory' | 'embedded' = source === 'embedded' ? 'embedded' : 'in-memory'; + const cacheAction: 'FOLLOWING' | 'REFRESHING' | 'NONE' = + this.state === 'streaming' + ? 'FOLLOWING' + : this.state === 'polling' + ? 'REFRESHING' + : 'NONE'; const trackOptions: TrackReadOptions = { configOrigin, cacheStatus: cacheHadDefinitions ? 'HIT' : 'MISS', + cacheAction, cacheIsBlocking: !cacheHadDefinitions, duration: Date.now() - startTime, }; diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index f604e7ab..8b345cff 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -18,7 +18,8 @@ export interface FlagsConfigReadEvent { region?: string; invocationHost?: string; vercelRequestId?: string; - cacheStatus?: 'HIT' | 'MISS'; + cacheStatus?: 'HIT' | 'MISS' | 'BYPASS'; + cacheAction?: 'REFRESHING' | 'FOLLOWING' | 'NONE'; cacheIsBlocking?: boolean; cacheIsFirstRead?: boolean; duration?: number; @@ -81,8 +82,10 @@ export interface UsageTrackerOptions { export interface TrackReadOptions { /** Whether the config was read from in-memory cache or embedded bundle */ configOrigin: 'in-memory' | 'embedded'; - /** HIT when definitions exist in memory, MISS when not. Omitted for embedded reads. */ - cacheStatus?: 'HIT' | 'MISS'; + /** HIT when definitions exist in memory, MISS when not, BYPASS when using fallback as primary source */ + cacheStatus?: 'HIT' | 'MISS' | 'BYPASS'; + /** FOLLOWING when streaming, REFRESHING when polling, NONE otherwise */ + cacheAction?: 'REFRESHING' | 'FOLLOWING' | 'NONE'; /** True for the very first getData call */ cacheIsFirstRead?: boolean; /** Whether the cache read was blocking */ @@ -149,6 +152,9 @@ export class UsageTracker { if (options.cacheStatus !== undefined) { event.payload.cacheStatus = options.cacheStatus; } + if (options.cacheAction !== undefined) { + event.payload.cacheAction = options.cacheAction; + } if (options.cacheIsFirstRead !== undefined) { event.payload.cacheIsFirstRead = options.cacheIsFirstRead; } From f714662c7cb69aa323c896fe12c38ba5777337cd Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 10:53:33 +0200 Subject: [PATCH 17/65] clean up fallback behavior --- .../vercel-flags-core/src/black-box.test.ts | 118 ++++++++++++++++++ .../vercel-flags-core/src/controller-fns.ts | 25 +++- .../src/create-raw-client.ts | 9 +- 3 files changed, 149 insertions(+), 3 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index a758e1ac..da995dc1 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -362,6 +362,124 @@ describe('Manual', () => { }); }); + describe('failure behavior', () => { + it('should return defaultValue when all data sources fail', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + // No stream, no polling, no datafile, no bundled + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.evaluate('flagA', false); + + expect(result).toEqual({ + value: false, + reason: 'error', + errorMessage: expect.stringContaining('No flag definitions available'), + }); + }); + + it('should throw when all data sources fail and no defaultValue provided', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.evaluate('flagA')).rejects.toThrow( + 'No flag definitions available', + ); + }); + + it('should use bundled definitions when stream and polling are disabled', async () => { + const bundledDefinitions: BundledDefinitions = { + definitions: { + flagA: { + environments: { + production: 1, + }, + variants: [false, true], + }, + }, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 2, + digest: 'abc', + revision: 2, + }; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: bundledDefinitions, + }); + + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.evaluate('flagA'); + + expect(result.value).toBe(true); + expect(result.reason).toBe('paused'); + expect(result.metrics?.source).toBe('embedded'); + }); + + it('should use constructor datafile when stream and polling are disabled', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const datafile: BundledDefinitions = { + definitions: { + flagA: { + environments: { + production: 1, + }, + variants: [false, true], + }, + }, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 2, + digest: 'abc', + revision: 2, + }; + + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + stream: false, + polling: false, + datafile, + }); + + const result = await client.evaluate('flagA'); + + expect(result.value).toBe(true); + expect(result.reason).toBe('paused'); + expect(result.metrics?.source).toBe('in-memory'); + }); + }); + describe('creating a client', () => { it('should not load bundled definitions or stream or poll on creation', () => { const client = createClient(sdkKey, { diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index f0ac4020..39eafe2d 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -1,7 +1,12 @@ import { controllerInstanceMap } from './controller-instance-map'; import { evaluate as evalFlag } from './evaluate'; import { internalReportValue } from './lib/report-value'; -import type { BundledDefinitions, EvaluationResult, Packed } from './types'; +import type { + BundledDefinitions, + Datafile, + EvaluationResult, + Packed, +} from './types'; import { ErrorCode, ResolutionReason } from './types'; export function initialize(id: number): Promise { @@ -29,7 +34,23 @@ export async function evaluate>( entities?: E, ): Promise> { const controller = controllerInstanceMap.get(id)!.controller; - const datafile = await controller.read(); + + let datafile: Datafile; + try { + datafile = await controller.read(); + } catch (error) { + // All data sources failed. Fall back to defaultValue if provided. + if (defaultValue !== undefined) { + return { + value: defaultValue, + reason: ResolutionReason.ERROR, + errorMessage: + error instanceof Error ? error.message : 'Failed to read datafile', + }; + } + throw error; + } + const flagDefinition = datafile.definitions[flagKey] as Packed.FlagDefinition; if (flagDefinition === undefined) { diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index 3f8993df..5f011870 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -90,7 +90,14 @@ export function createCreateRawClient(fns: { entities?: E, ): Promise> => { const instance = controllerInstanceMap.get(id); - if (!instance?.initialized) await api.initialize(); + if (!instance?.initialized) { + try { + await api.initialize(); + } catch { + // Initialization failed — let evaluate() handle the fallback + // chain (last known value → datafile → bundled → defaultValue → throw) + } + } return fns.evaluate(id, flagKey, defaultValue, entities); }, peek: () => { From 43fa287cf98d42bc311c023c52b504dc42c1903c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 10:57:20 +0200 Subject: [PATCH 18/65] add mode --- packages/vercel-flags-core/src/black-box.test.ts | 4 ++++ packages/vercel-flags-core/src/controller-fns.ts | 2 ++ packages/vercel-flags-core/src/controller/index.ts | 14 ++++++++++++++ packages/vercel-flags-core/src/types.ts | 2 ++ 4 files changed, 22 insertions(+) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index da995dc1..1c23a602 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -111,6 +111,7 @@ describe('Manual', () => { metrics: { cacheStatus: 'HIT', connectionState: 'disconnected', + mode: 'build', evaluationMs: 0, readMs: 0, source: 'in-memory', @@ -190,6 +191,7 @@ describe('Manual', () => { metrics: { cacheStatus: 'HIT', connectionState: 'disconnected', + mode: 'build', evaluationMs: 0, readMs: 0, source: 'embedded', @@ -289,6 +291,7 @@ describe('Manual', () => { metrics: { cacheStatus: 'HIT', connectionState: 'disconnected', + mode: 'build', evaluationMs: 0, readMs: 0, source: 'remote', @@ -304,6 +307,7 @@ describe('Manual', () => { metrics: { cacheStatus: 'HIT', connectionState: 'disconnected', + mode: 'build', evaluationMs: 0, readMs: 0, source: 'remote', diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 39eafe2d..4123b4ec 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -65,6 +65,7 @@ export async function evaluate>( source: datafile.metrics.source, cacheStatus: datafile.metrics.cacheStatus, connectionState: datafile.metrics.connectionState, + mode: datafile.metrics.mode, }, }; } @@ -98,6 +99,7 @@ export async function evaluate>( source: datafile.metrics.source, cacheStatus: datafile.metrics.cacheStatus, connectionState: datafile.metrics.connectionState, + mode: datafile.metrics.mode, }, }); } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 2191f567..56c3aeb9 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -186,6 +186,18 @@ export class Controller implements ControllerInterface { return this.state === 'streaming'; } + private get mode(): Metrics['mode'] { + if (this.options.buildStep) return 'build'; + switch (this.state) { + case 'streaming': + return 'streaming'; + case 'polling': + return 'polling'; + default: + return 'offline'; + } + } + // --------------------------------------------------------------------------- // Public API (DataSource interface) // --------------------------------------------------------------------------- @@ -281,6 +293,7 @@ export class Controller implements ControllerInterface { connectionState: this.isConnected ? ('connected' as const) : ('disconnected' as const), + mode: this.mode, }, }) satisfies Datafile; } @@ -338,6 +351,7 @@ export class Controller implements ControllerInterface { connectionState: this.isConnected ? ('connected' as const) : ('disconnected' as const), + mode: this.mode, }, }) satisfies Datafile; } diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 7d2ed029..49511dcf 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -67,6 +67,8 @@ export type Metrics = { cacheStatus: 'HIT' | 'MISS' | 'STALE'; /** Whether the stream is currently connected */ connectionState: 'connected' | 'disconnected'; + /** The current operating mode of the client */ + mode: 'streaming' | 'polling' | 'build' | 'offline'; /** Time in ms for the pure flag evaluation logic (only present on EvaluationResult) */ evaluationMs?: number; }; From fa368887c6f25f6e0ff7d4529ec1b6f99bfaa2a0 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 11:39:48 +0200 Subject: [PATCH 19/65] mutually exclusive streaming & polling --- .../vercel-flags-core/src/black-box.test.ts | 186 +----------- .../src/controller/index.test.ts | 280 +++++++++--------- .../vercel-flags-core/src/controller/index.ts | 181 ++++++----- .../src/create-raw-client.ts | 10 +- 4 files changed, 261 insertions(+), 396 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 1c23a602..50785ef3 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -41,7 +41,7 @@ function createMockStream() { return { response: Promise.resolve(new Response(body, { status: 200 })), push(message: StreamMessage) { - controller.enqueue(encoder.encode(JSON.stringify(message) + '\n')); + controller.enqueue(encoder.encode(`${JSON.stringify(message)}\n`)); }, close() { controller.close(); @@ -239,30 +239,7 @@ describe('Manual', () => { ]); }); - it('fetches only once during the build when no datafile and no bundled definitions are provided', async () => { - const definitions: BundledDefinitions = { - definitions: { - flagA: { - environments: { - production: 1, - }, - variants: [false, true], - }, - flagB: { - environments: { - production: 1, - }, - variants: [false, true], - }, - }, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 2, - digest: 'abc', - revision: 2, - }; - + it('returns defaultValue during build when no datafile and no bundled definitions are provided', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'missing-file', definitions: null, @@ -271,98 +248,21 @@ describe('Manual', () => { const client = createClient(sdkKey, { buildStep: true, fetch: fetchMock, - polling: { - initTimeoutMs: 5000, - intervalMs: 1000, - }, - stream: { - initTimeoutMs: 1000, - }, }); - fetchMock.mockResolvedValue(Response.json(definitions)); - - const [a, b] = await Promise.all([ - client.evaluate('flagA'), - client.evaluate('flagB'), - ]); - - expect(a).toEqual({ - metrics: { - cacheStatus: 'HIT', - connectionState: 'disconnected', - mode: 'build', - evaluationMs: 0, - readMs: 0, - source: 'remote', - }, - outcomeType: 'value', - reason: 'paused', - // value is expected to be true instead of false, showing - // the passed definition is used instead of the bundled one - value: true, - }); + // With defaultValue, evaluate should return it as an error result + const result = await client.evaluate('flagA', false); - expect(b).toEqual({ - metrics: { - cacheStatus: 'HIT', - connectionState: 'disconnected', - mode: 'build', - evaluationMs: 0, - readMs: 0, - source: 'remote', - }, - outcomeType: 'value', - reason: 'paused', - // value is expected to be true instead of false, showing - // the passed definition is used instead of the bundled one - value: true, + expect(result).toEqual({ + value: false, + reason: 'error', + errorMessage: expect.stringContaining( + 'No flag definitions available during build', + ), }); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledWith( - 'https://flags.vercel.com/v1/datafile', - { - headers: { - Authorization: 'Bearer vf_fake', - 'User-Agent': 'VercelFlagsCore/1.0.1', - }, - signal: expect.any(AbortSignal), - }, - ); - - // flush - await client.shutdown(); - - // verify tracking - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock).toHaveBeenLastCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), - headers: { - Authorization: 'Bearer vf_fake', - 'Content-Type': 'application/json', - 'User-Agent': 'VercelFlagsCore/1.0.1', - }, - method: 'POST', - }, - ); - expect(JSON.parse(fetchMock.mock.calls[1]?.[1]?.body as string)).toEqual([ - { - payload: { - cacheAction: 'NONE', - cacheIsBlocking: false, - cacheIsFirstRead: true, - cacheStatus: 'HIT', - configOrigin: 'in-memory', - configUpdatedAt: 2, - duration: 0, - }, - ts: expect.any(Number), - type: 'FLAGS_CONFIG_READ', - }, - ]); + // No network requests should have been made (no fetching during build) + expect(fetchMock).not.toHaveBeenCalled(); }); }); @@ -536,7 +436,7 @@ describe('Manual', () => { expect(fetchMock.mock.calls[0]![0].toString()).toContain('/v1/stream'); }); - it('should fall back to bundled when stream and poll hangs', async () => { + it('should fall back to bundled when stream hangs', async () => { const datafile: BundledDefinitions = { definitions: {}, segments: {}, @@ -552,8 +452,6 @@ describe('Manual', () => { definitions: datafile, }); - let pollCount = 0; - fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/stream')) { @@ -561,57 +459,6 @@ describe('Manual', () => { const body = new ReadableStream({ start() {} }); return Promise.resolve(new Response(body, { status: 200 })); } - if (url.includes('/v1/datafile')) { - // polling request starts but never resolves - pollCount++; - return new Promise(() => {}); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - buildStep: false, - fetch: fetchMock, - }); - - const initPromise = client.initialize(); - - // Advance past the stream init timeout (3s) and polling init timeout (3s) - await vi.advanceTimersByTimeAsync(1_000); - expect(pollCount).toBe(0); - await vi.advanceTimersByTimeAsync(2_000); - expect(pollCount).toBe(1); - await vi.advanceTimersByTimeAsync(3_000); - - // wait for init to resolve - await expect(initPromise).resolves.toBeUndefined(); - }); - - it('should fall back to polling without double-polling when stream hangs', async () => { - const datafile: BundledDefinitions = { - definitions: {}, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }; - - let pollCount = 0; - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - // stream opens but never sends initial data - const body = new ReadableStream({ start() {} }); - return Promise.resolve(new Response(body, { status: 200 })); - } - if (url.includes('/v1/datafile')) { - // polling returns a valid datafile - pollCount++; - return Promise.resolve(Response.json(datafile)); - } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); @@ -625,11 +472,8 @@ describe('Manual', () => { // Advance past the stream init timeout (3s) await vi.advanceTimersByTimeAsync(3_000); - await initPromise; - - // poll() should only be called once by tryInitializePolling, - // not a second time by startInterval's immediate poll - expect(pollCount).toBe(1); + // Should fall back directly to bundled — no polling attempted + await expect(initPromise).resolves.toBeUndefined(); }); }); }); diff --git a/packages/vercel-flags-core/src/controller/index.test.ts b/packages/vercel-flags-core/src/controller/index.test.ts index 9bbee2ba..ab193bff 100644 --- a/packages/vercel-flags-core/src/controller/index.test.ts +++ b/packages/vercel-flags-core/src/controller/index.test.ts @@ -451,34 +451,20 @@ describe('Controller', () => { }); describe('build step behavior', () => { - it('should fall back to HTTP fetch when bundled definitions missing during build', async () => { + it('should throw when bundled definitions missing during build', async () => { process.env.CI = '1'; - const fetchedDefinitions = { - projectId: 'fetched', - definitions: { flag: true }, - environment: 'production', - }; - // Bundled definitions not available vi.mocked(readBundledDefinitions).mockResolvedValue({ definitions: null, state: 'missing-file', }); - server.use( - http.get('https://flags.vercel.com/v1/datafile', () => { - return HttpResponse.json(fetchedDefinitions); - }), - ); - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - const result = await dataSource.read(); - expect(result).toMatchObject(fetchedDefinitions); - expect(result.metrics.source).toBe('remote'); - expect(result.metrics.cacheStatus).toBe('MISS'); - expect(result.metrics.connectionState).toBe('disconnected'); + await expect(dataSource.read()).rejects.toThrow( + 'No flag definitions available during build', + ); await dataSource.shutdown(); }); @@ -877,79 +863,73 @@ describe('Controller', () => { }); describe('stream/polling coordination', () => { - it('should stop polling when stream connects', async () => { + it('should fall back to bundled when stream times out (skip polling)', async () => { let pollCount = 0; - let streamDataSent = false; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: { + projectId: 'bundled', + definitions: {}, + segments: {}, + environment: 'production', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }, + }); server.use( - http.get('https://flags.vercel.com/v1/stream', async ({ request }) => { - // Wait a bit to let polling start first - await new Promise((r) => setTimeout(r, 200)); - return new HttpResponse( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - })}\n`, - ), - ); - streamDataSent = true; - // Keep stream open - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); + http.get('https://flags.vercel.com/v1/stream', async () => { + // Stream opens but never sends data (will timeout) + return new HttpResponse(new ReadableStream({ start() {} }), { + headers: { 'Content-Type': 'application/x-ndjson' }, + }); }), http.get('https://flags.vercel.com/v1/datafile', () => { pollCount++; return HttpResponse.json({ projectId: 'polled', - definitions: { count: pollCount }, + definitions: {}, environment: 'production', }); }), ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const dataSource = new Controller({ sdkKey: 'vf_test_key', - stream: { initTimeoutMs: 100 }, // Short timeout to trigger polling fallback + stream: { initTimeoutMs: 100 }, polling: { intervalMs: 50, initTimeoutMs: 5000 }, }); - // This should initially get data from polling (stream times out) - await dataSource.read(); - - // Wait for stream data to be sent - await vi.waitFor( - () => { - expect(streamDataSent).toBe(true); - }, - { timeout: 2000 }, - ); - - // Record poll count at this point - const pollCountAfterStreamConnect = pollCount; - - // Wait for what would be several poll intervals - await new Promise((r) => setTimeout(r, 200)); + const result = await dataSource.read(); - // Polling should have stopped - count should not have increased much - // (there might be 1-2 more polls in flight when stream connected) - expect(pollCount).toBeGreaterThan(0); - expect(pollCount).toBeLessThanOrEqual(pollCountAfterStreamConnect + 2); + // Should have fallen back to bundled, not polling + expect(result.projectId).toBe('bundled'); + expect(pollCount).toBe(0); await dataSource.shutdown(); + warnSpy.mockRestore(); }); - it('should fall back to polling when stream fails', async () => { + it('should fall back to bundled when stream fails (skip polling)', async () => { let pollCount = 0; + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: { + projectId: 'bundled', + definitions: {}, + segments: {}, + environment: 'production', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }, + }); + server.use( http.get('https://flags.vercel.com/v1/stream', () => { return new HttpResponse(null, { status: 500 }); @@ -958,7 +938,7 @@ describe('Controller', () => { pollCount++; return HttpResponse.json({ projectId: 'polled', - definitions: { count: pollCount }, + definitions: {}, environment: 'production', }); }), @@ -976,9 +956,9 @@ describe('Controller', () => { const result = await dataSource.read(); - // Should have gotten data from polling - expect(result.projectId).toBe('polled'); - expect(pollCount).toBeGreaterThanOrEqual(1); + // Should have fallen back to bundled, not polling + expect(result.projectId).toBe('bundled'); + expect(pollCount).toBe(0); await dataSource.shutdown(); @@ -1125,6 +1105,19 @@ describe('Controller', () => { it('should not start polling from stream disconnect during initialization', async () => { let pollCount = 0; + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: { + projectId: 'bundled', + definitions: {}, + segments: {}, + environment: 'production', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + }, + }); + server.use( http.get('https://flags.vercel.com/v1/stream', () => { // Stream fails immediately, triggering onDisconnect @@ -1151,9 +1144,9 @@ describe('Controller', () => { await dataSource.initialize(); - // Only 1 poll request should have been made (from tryInitializePolling), - // not 2 (onDisconnect should not have started a separate poll) - expect(pollCount).toBe(1); + // Polling should not be tried during init when stream is enabled — + // stream failure falls back directly to bundled definitions + expect(pollCount).toBe(0); await dataSource.shutdown(); errorSpy.mockRestore(); @@ -1162,64 +1155,71 @@ describe('Controller', () => { }); describe('getDatafile', () => { - it('should fetch from network when called without initialize', async () => { - const remoteDefinitions = { - projectId: 'remote', + it('should return bundled definitions when called without initialize', async () => { + const bundledDefinitions: BundledDefinitions = { + projectId: 'bundled', definitions: { flag: true }, environment: 'production', + configUpdatedAt: 1, + digest: 'a', + revision: 1, }; - server.use( - http.get('https://flags.vercel.com/v1/datafile', () => { - return HttpResponse.json(remoteDefinitions); - }), - ); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: bundledDefinitions, + state: 'ok', + }); const dataSource = new Controller({ sdkKey: 'vf_test_key' }); const result = await dataSource.getDatafile(); - expect(result).toMatchObject(remoteDefinitions); - expect(result.metrics.source).toBe('remote'); + expect(result).toMatchObject(bundledDefinitions); + expect(result.metrics.source).toBe('embedded'); expect(result.metrics.cacheStatus).toBe('MISS'); expect(result.metrics.connectionState).toBe('disconnected'); await dataSource.shutdown(); }); - it('should fetch from network even when bundled definitions exist (not in build step)', async () => { - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, + it('should fetch datafile when called without initialize and no bundled definitions', async () => { + const fetchedDefinitions: BundledDefinitions = { + projectId: 'fetched', + definitions: { flag: true }, environment: 'production', configUpdatedAt: 1, digest: 'a', revision: 1, }; - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const remoteDefinitions = { - projectId: 'remote', - definitions: { flag: true }, - environment: 'production', - }; - server.use( http.get('https://flags.vercel.com/v1/datafile', () => { - return HttpResponse.json(remoteDefinitions); + return HttpResponse.json(fetchedDefinitions); }), ); const dataSource = new Controller({ sdkKey: 'vf_test_key' }); const result = await dataSource.getDatafile(); - // Should fetch from network, NOT use bundled definitions - expect(result.projectId).toBe('remote'); + expect(result).toMatchObject(fetchedDefinitions); expect(result.metrics.source).toBe('remote'); expect(result.metrics.cacheStatus).toBe('MISS'); + expect(result.metrics.connectionState).toBe('disconnected'); + + await dataSource.shutdown(); + }); + + it('should throw when called without initialize and all sources fail', async () => { + server.use( + http.get('https://flags.vercel.com/v1/datafile', () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + const dataSource = new Controller({ sdkKey: 'vf_test_key' }); + + await expect(dataSource.getDatafile()).rejects.toThrow( + 'No flag definitions available', + ); await dataSource.shutdown(); }); @@ -1297,19 +1297,20 @@ describe('Controller', () => { await dataSource.shutdown(); }); - it('should fetch fresh data on each call when stream is not connected', async () => { - let fetchCount = 0; + it('should return cached data on repeated calls', async () => { + const bundledDefinitions: BundledDefinitions = { + projectId: 'bundled', + definitions: { version: 1 }, + environment: 'production', + configUpdatedAt: 1, + digest: 'a', + revision: 1, + }; - server.use( - http.get('https://flags.vercel.com/v1/datafile', () => { - fetchCount++; - return HttpResponse.json({ - projectId: 'remote', - definitions: { version: fetchCount }, - environment: 'production', - }); - }), - ); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: bundledDefinitions, + state: 'ok', + }); const dataSource = new Controller({ sdkKey: 'vf_test_key', @@ -1319,13 +1320,12 @@ describe('Controller', () => { const result1 = await dataSource.getDatafile(); expect(result1.definitions).toEqual({ version: 1 }); + expect(result1.metrics.cacheStatus).toBe('MISS'); - // The second call hits the cache since this.data was set by the first call - // and the stream is not connected, so isStreamConnected is false - // which means the else branch fires again, fetching fresh data + // Second call should return cached data const result2 = await dataSource.getDatafile(); - expect(result2.definitions).toEqual({ version: 2 }); - expect(fetchCount).toBe(2); + expect(result2.definitions).toEqual({ version: 1 }); + expect(result2.metrics.cacheStatus).toBe('STALE'); await dataSource.shutdown(); }); @@ -1626,9 +1626,7 @@ describe('Controller', () => { await dataSource.shutdown(); }); - it('should not overwrite newer in-memory data with older poll response', async () => { - let pollCount = 0; - + it('should not overwrite newer in-memory data with older stream message', async () => { const newerDefinitions = { projectId: 'test', definitions: { version: 'newer' }, @@ -1643,7 +1641,7 @@ describe('Controller', () => { configUpdatedAt: 1000, }; - // Stream delivers newer data + // Stream delivers newer data first, then older data server.use( http.get('https://flags.vercel.com/v1/stream', ({ request }) => { return new HttpResponse( @@ -1654,50 +1652,38 @@ describe('Controller', () => { `${JSON.stringify({ type: 'datafile', data: newerDefinitions })}\n`, ), ); - // Stream closes, triggering polling fallback - controller.close(); + // Then send older data + controller.enqueue( + new TextEncoder().encode( + `${JSON.stringify({ type: 'datafile', data: olderDefinitions })}\n`, + ), + ); + request.signal.addEventListener('abort', () => { + controller.close(); + }); }, }), { headers: { 'Content-Type': 'application/x-ndjson' } }, ); }), - // Polling returns older data - http.get('https://flags.vercel.com/v1/datafile', () => { - pollCount++; - return HttpResponse.json(olderDefinitions); - }), ); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const dataSource = new Controller({ sdkKey: 'vf_test_key', stream: true, - polling: { intervalMs: 50, initTimeoutMs: 5000 }, + polling: false, }); - // First read gets newer data from stream + // Read gets newer data from stream const result1 = await dataSource.read(); expect(result1.definitions).toEqual({ version: 'newer' }); - // Wait for stream to disconnect and polling to kick in - await vi.waitFor( - () => { - expect(pollCount).toBeGreaterThanOrEqual(1); - }, - { timeout: 3000 }, - ); - - // Should still have newer data (older poll response was rejected) + // Older stream message should have been rejected const result2 = await dataSource.read(); expect(result2.definitions).toEqual({ version: 'newer' }); await dataSource.shutdown(); - - errorSpy.mockRestore(); - warnSpy.mockRestore(); - }, 10000); + }); it('should accept stream data with equal configUpdatedAt', async () => { const data1 = { diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 56c3aeb9..a8428961 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -55,13 +55,19 @@ type State = * * **Build step** (CI=1 or Next.js build, or buildStep: true): * - Uses datafile (if provided) or bundled definitions - * - No streaming or polling (avoids network during build) + * - No streaming, polling, or fetching * - * **Runtime** (default): - * - Tries stream first, then poll, then datafile, then bundled - * - Stream and polling never run simultaneously - * - If stream reconnects while polling → stop polling - * - If stream disconnects → start polling (if enabled) + * **Runtime — streaming mode** (stream enabled): + * - Uses streaming exclusively + * - Fallback: last known value → constructor datafile → bundled → defaultValue → throw + * - Polling is never started, even if configured + * + * **Runtime — polling mode** (polling enabled, stream disabled): + * - Uses polling exclusively + * - Same fallback chain + * + * **Runtime — offline mode** (neither stream nor polling): + * - Uses constructor datafile → bundled → one-time fetch → defaultValue → throw */ export class Controller implements ControllerInterface { private options: NormalizedOptions; @@ -135,30 +141,9 @@ export class Controller implements ControllerInterface { } }); - this.streamSource.on('connected', () => { - // Stream reconnected while polling → stop polling, transition to streaming - if (this.state === 'polling') { - this.pollingSource.stop(); - this.transition('streaming'); - } - // During normal streaming, just confirm state - else if (this.state === 'streaming') { - // Already in streaming state, no transition needed - } - // During initialization, initialize() handles the transition - }); - this.streamSource.on('disconnected', () => { - // Only react to disconnects when we're in streaming state. - // During initialization states, initialize() manages its own fallback chain. if (this.state === 'streaming') { - if (this.options.polling.enabled) { - void this.pollingSource.poll(); - this.pollingSource.startInterval(); - this.transition('polling'); - } else { - this.transition('degraded'); - } + this.transition('degraded'); } }); @@ -205,8 +190,10 @@ export class Controller implements ControllerInterface { /** * Initializes the data source. * - * Build step: datafile → bundled → fetch - * Runtime: stream → poll → datafile → bundled + * Build step: datafile → bundled (no network) + * Streaming mode: stream → datafile → bundled + * Polling mode (no stream): poll → datafile → bundled + * Offline mode (neither): datafile → bundled → one-time fetch */ async initialize(): Promise { if (this.options.buildStep) { @@ -228,7 +215,7 @@ export class Controller implements ControllerInterface { return; } - // Fallback chain + // Try the configured primary source (stream or poll, never both) if (this.options.stream.enabled) { this.transition('initializing:stream'); const streamSuccess = await this.tryInitializeStream(); @@ -236,9 +223,7 @@ export class Controller implements ControllerInterface { this.transition('streaming'); return; } - } - - if (this.options.polling.enabled) { + } else if (this.options.polling.enabled) { this.transition('initializing:polling'); const pollingSuccess = await this.tryInitializePolling(); if (pollingSuccess) { @@ -247,17 +232,41 @@ export class Controller implements ControllerInterface { } } + // Fallback chain: datafile → bundled → one-time fetch (offline only) this.transition('initializing:fallback'); - // Fall back to provided datafile (already set in constructor if provided) if (this.data) { this.transition('degraded'); return; } - // Fall back to bundled definitions - await this.initializeFromBundled(); - this.transition('degraded'); + const bundled = await this.bundledSource.tryLoad(); + if (bundled) { + this.data = bundled; + this.transition('degraded'); + return; + } + + // Last resort: one-time fetch (only when no stream/poll configured) + if (!this.options.stream.enabled && !this.options.polling.enabled) { + try { + const fetched = await fetchDatafile({ + host: this.options.host, + sdkKey: this.options.sdkKey, + fetch: this.options.fetch, + }); + this.data = tagData(fetched, 'fetched'); + this.transition('degraded'); + return; + } catch { + // fetch failed — fall through to throw + } + } + + throw new Error( + '@vercel/flags-core: No flag definitions available. ' + + 'Bundled definitions not found.', + ); } /** @@ -313,11 +322,8 @@ export class Controller implements ControllerInterface { /** * Returns the datafile with metrics. - * - * During builds this will read from the bundled file if available. - * - * This method never opens a streaming connection, but will read from - * the stream if it is already open. Otherwise it fetches over the network. + * Uses in-memory data if available, otherwise falls back to bundled, + * then to a one-time fetch if called without prior initialization. */ async getDatafile(): Promise { const startTime = Date.now(); @@ -329,18 +335,36 @@ export class Controller implements ControllerInterface { if (this.options.buildStep) { [result, cacheStatus] = await this.getDataForBuildStep(); source = originToMetricsSource(result._origin); - } else if (this.isConnected && this.data) { + } else if (this.data) { [result, cacheStatus] = this.getDataFromCache(); source = originToMetricsSource(result._origin); } else { - const fetched = await fetchDatafile(this.options); - const tagged = tagData(fetched, 'fetched'); - if (this.isNewerData(tagged)) { - this.data = tagged; + // No in-memory data — try bundled, then one-time fetch + const bundled = await this.bundledSource.tryLoad(); + if (bundled) { + this.data = bundled; + result = bundled; + source = 'embedded'; + cacheStatus = 'MISS'; + } else { + // One-time fetch as last resort + try { + const fetched = await fetchDatafile({ + host: this.options.host, + sdkKey: this.options.sdkKey, + fetch: this.options.fetch, + }); + this.data = tagData(fetched, 'fetched'); + result = this.data; + source = 'remote'; + cacheStatus = 'MISS'; + } catch { + throw new Error( + '@vercel/flags-core: No flag definitions available. ' + + 'Initialize the client or provide a datafile.', + ); + } } - result = this.data ?? tagged; - source = 'remote'; - cacheStatus = 'MISS'; } return Object.assign(result, { @@ -419,6 +443,8 @@ export class Controller implements ControllerInterface { /** * Attempts to initialize via polling with timeout. * Returns true if first poll succeeded within timeout. + * + * Only used when streaming is disabled and polling is the primary source. */ private async tryInitializePolling(): Promise { const pollPromise = this.pollingSource.poll(); @@ -530,14 +556,16 @@ export class Controller implements ControllerInterface { } /** - * Loads data for a build step: provided → bundled → fetch. + * Loads data for a build step: bundled definitions only (no network). */ private async loadBuildData(): Promise { const bundled = await this.bundledSource.tryLoad(); if (bundled) return bundled; - const fetched = await fetchDatafile(this.options); - return tagData(fetched, 'fetched'); + throw new Error( + '@vercel/flags-core: No flag definitions available during build. ' + + 'Provide a datafile or bundled definitions.', + ); } // --------------------------------------------------------------------------- @@ -556,12 +584,15 @@ export class Controller implements ControllerInterface { } /** - * Retrieves data using the fallback chain. + * Retrieves data using the fallback chain (called when no cached data exists). + * Streaming mode: stream → datafile → bundled. + * Polling mode: poll → datafile → bundled. + * Offline mode: datafile → bundled → one-time fetch. */ private async getDataWithFallbacks(): Promise< [TaggedData, Metrics['cacheStatus']] > { - // Try stream with timeout + // Try the configured primary source if (this.options.stream.enabled) { this.transition('initializing:stream'); const streamSuccess = await this.tryInitializeStream(); @@ -569,10 +600,7 @@ export class Controller implements ControllerInterface { this.transition('streaming'); return [this.data, 'MISS']; } - } - - // Try polling with timeout - if (this.options.polling.enabled) { + } else if (this.options.polling.enabled) { this.transition('initializing:polling'); const pollingSuccess = await this.tryInitializePolling(); if (pollingSuccess && this.data) { @@ -581,16 +609,15 @@ export class Controller implements ControllerInterface { } } + // Fallback chain: datafile → bundled → one-time fetch this.transition('initializing:fallback'); - // Use provided datafile if (this.options.datafile) { this.data = tagData(this.options.datafile, 'provided'); this.transition('degraded'); return [this.data, 'STALE']; } - // Use bundled definitions const bundled = await this.bundledSource.tryLoad(); if (bundled) { console.warn('@vercel/flags-core: Using bundled definitions as fallback'); @@ -599,25 +626,25 @@ export class Controller implements ControllerInterface { return [this.data, 'STALE']; } - throw new Error( - '@vercel/flags-core: No flag definitions available. ' + - 'Ensure streaming/polling is enabled or provide a datafile.', - ); - } - - /** - * Initializes from bundled definitions. - */ - private async initializeFromBundled(): Promise { - const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - this.data = bundled; - return; + // Last resort: one-time fetch (only when no stream/poll configured) + if (!this.options.stream.enabled && !this.options.polling.enabled) { + try { + const fetched = await fetchDatafile({ + host: this.options.host, + sdkKey: this.options.sdkKey, + fetch: this.options.fetch, + }); + this.data = tagData(fetched, 'fetched'); + this.transition('degraded'); + return [this.data, 'MISS']; + } catch { + // fetch failed — fall through to throw + } } throw new Error( '@vercel/flags-core: No flag definitions available. ' + - 'Bundled definitions not found.', + 'Provide a datafile or bundled definitions.', ); } diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index 5f011870..3921f478 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -78,7 +78,15 @@ export function createCreateRawClient(fns: { await fns.shutdown(id); controllerInstanceMap.delete(id); }, - getDatafile: () => { + getDatafile: async () => { + const instance = controllerInstanceMap.get(id); + if (instance?.initPromise) { + try { + await instance.initPromise; + } catch { + // Initialization failed — let getDatafile handle its own fallbacks + } + } return fns.getDatafile(id); }, getFallbackDatafile: (): Promise => { From 00def3de9c4368589260d432d1973157d2ff5e4e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 12:11:04 +0200 Subject: [PATCH 20/65] rely more on black box testing --- .../src/black-box-controller.test.ts | 1860 +++++++++++++++ .../src/controller-fns.test.ts | 501 ----- .../src/controller/index.test.ts | 1993 ----------------- .../src/create-raw-client.test.ts | 404 ---- 4 files changed, 1860 insertions(+), 2898 deletions(-) create mode 100644 packages/vercel-flags-core/src/black-box-controller.test.ts delete mode 100644 packages/vercel-flags-core/src/controller-fns.test.ts delete mode 100644 packages/vercel-flags-core/src/controller/index.test.ts delete mode 100644 packages/vercel-flags-core/src/create-raw-client.test.ts diff --git a/packages/vercel-flags-core/src/black-box-controller.test.ts b/packages/vercel-flags-core/src/black-box-controller.test.ts new file mode 100644 index 00000000..e716dc68 --- /dev/null +++ b/packages/vercel-flags-core/src/black-box-controller.test.ts @@ -0,0 +1,1860 @@ +/** + * Black-box tests for controller behaviors. + * + * These tests verify the SDK's behavior exclusively through the public API + * (createClient → evaluate/getDatafile/getFallbackDatafile/initialize/shutdown). + * This allows internal refactoring without test breakage. + * + * Companion to black-box.test.ts which covers basic happy paths. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StreamMessage } from './controller/stream-connection'; +import { type BundledDefinitions, createClient } from './index.default'; +import { internalReportValue } from './lib/report-value'; +import { readBundledDefinitions } from './utils/read-bundled-definitions'; + +vi.mock('./utils/read-bundled-definitions', () => ({ + readBundledDefinitions: vi.fn(() => + Promise.resolve({ definitions: null, state: 'missing-file' }), + ), +})); + +vi.mock('./lib/report-value', () => ({ + internalReportValue: vi.fn(), +})); + +const sdkKey = 'vf_fake'; +const fetchMock = vi.fn(); + +/** + * Creates a mock NDJSON stream response for testing. + * + * Returns a controller object that lets you gradually push messages + * and a `response` promise suitable for use with a fetch mock. + */ +function createMockStream() { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController; + + const body = new ReadableStream({ + start(c) { + controller = c; + }, + }); + + return { + response: Promise.resolve(new Response(body, { status: 200 })), + push(message: StreamMessage) { + controller.enqueue(encoder.encode(`${JSON.stringify(message)}\n`)); + }, + close() { + controller.close(); + }, + }; +} + +/** A simple bundled definitions fixture */ +function makeBundled( + overrides: Partial = {}, +): BundledDefinitions { + return { + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + ...overrides, + }; +} + +const originalEnv = { ...process.env }; + +describe('Controller (black-box)', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(readBundledDefinitions).mockReset(); + vi.mocked(internalReportValue).mockReset(); + fetchMock.mockReset(); + // Reset env vars that affect build step detection + delete process.env.CI; + delete process.env.NEXT_PHASE; + }); + + afterEach(() => { + vi.useRealTimers(); + process.env = { ...originalEnv }; + }); + + // --------------------------------------------------------------------------- + // Constructor validation + // --------------------------------------------------------------------------- + describe('constructor validation', () => { + it('should throw for missing SDK key', () => { + expect(() => + createClient('', { fetch: fetchMock, stream: false, polling: false }), + ).toThrow('flags: Missing sdkKey'); + }); + + it('should throw for SDK key not starting with vf_', () => { + expect(() => + createClient('invalid_key', { + fetch: fetchMock, + stream: false, + polling: false, + }), + ).toThrow('flags: Missing sdkKey'); + }); + + it('should throw for non-string SDK key', () => { + expect(() => + createClient(123 as unknown as string, { + fetch: fetchMock, + stream: false, + polling: false, + }), + ).toThrow(); + }); + + it('should accept valid SDK key', () => { + expect(() => + createClient('vf_valid_key', { + fetch: fetchMock, + stream: false, + polling: false, + }), + ).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // Build step detection + // --------------------------------------------------------------------------- + describe('build step detection', () => { + it('should detect build step when CI=1', async () => { + process.env.CI = '1'; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const result = await client.evaluate('flagA'); + + expect(result.metrics?.mode).toBe('build'); + expect(result.metrics?.source).toBe('embedded'); + // No network requests should have been made + expect(fetchMock).not.toHaveBeenCalled(); + + await client.shutdown(); + }); + + it('should detect build step when NEXT_PHASE=phase-production-build', async () => { + process.env.NEXT_PHASE = 'phase-production-build'; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const result = await client.evaluate('flagA'); + + expect(result.metrics?.mode).toBe('build'); + expect(result.metrics?.source).toBe('embedded'); + expect(fetchMock).not.toHaveBeenCalled(); + + await client.shutdown(); + }); + + it('should NOT detect build step when neither CI nor NEXT_PHASE is set', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const initPromise = client.initialize(); + + stream.push({ + type: 'datafile', + data: makeBundled({ projectId: 'stream' }), + }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Stream should have been attempted + expect(fetchMock).toHaveBeenCalled(); + const streamCall = fetchMock.mock.calls.find((call) => + call[0]?.toString().includes('/v1/stream'), + ); + expect(streamCall).toBeDefined(); + + stream.close(); + await client.shutdown(); + }); + + it('should override auto-detection with buildStep: false', async () => { + process.env.CI = '1'; // Would normally trigger build step + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + buildStep: false, // Explicitly override CI detection + }); + + const initPromise = client.initialize(); + + stream.push({ + type: 'datafile', + data: makeBundled({ projectId: 'stream' }), + }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + const result = await client.evaluate('flagA'); + + // Should use stream (buildStep: false overrides CI detection) + expect(result.metrics?.mode).toBe('streaming'); + + stream.close(); + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Build step behavior + // --------------------------------------------------------------------------- + describe('build step behavior', () => { + it('should throw when bundled definitions missing during build (no defaultValue)', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + buildStep: true, + }); + + await expect(client.evaluate('flagA')).rejects.toThrow( + 'No flag definitions available during build', + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should cache data after first build step read', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + buildStep: true, + }); + + const first = await client.evaluate('flagA'); + expect(first.metrics?.cacheStatus).toBe('HIT'); + + const second = await client.evaluate('flagA'); + expect(second.metrics?.cacheStatus).toBe('HIT'); + + // readBundledDefinitions should only be called once + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + + await client.shutdown(); + }); + + it('should skip network when buildStep: true even if stream/polling configured', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + buildStep: true, + stream: true, + polling: true, + }); + + const result = await client.evaluate('flagA'); + + expect(result.metrics?.source).toBe('embedded'); + expect(result.metrics?.mode).toBe('build'); + expect(fetchMock).not.toHaveBeenCalled(); + + await client.shutdown(); + }); + + it('should use datafile over bundled in build step', async () => { + const providedDatafile = makeBundled({ + configUpdatedAt: 2, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const bundled = makeBundled({ + configUpdatedAt: 1, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: bundled, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + buildStep: true, + datafile: providedDatafile, + }); + + const result = await client.evaluate('flagA'); + + // value true means variant index 1 (from provided datafile), not 0 (bundled) + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('in-memory'); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Stream behavior + // --------------------------------------------------------------------------- + describe('stream behavior', () => { + it('should handle messages split across chunks', async () => { + const datafile = makeBundled({ projectId: 'test-project' }); + const fullMessage = JSON.stringify({ + type: 'datafile', + data: datafile, + }); + const part1 = fullMessage.slice(0, 20); + const part2 = `${fullMessage.slice(20)}\n`; + + const encoder = new TextEncoder(); + let streamController: ReadableStreamDefaultController; + + const body = new ReadableStream({ + start(c) { + streamController = c; + }, + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(body, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const initPromise = client.initialize(); + + // Send chunks separately + streamController!.enqueue(encoder.encode(part1)); + await vi.advanceTimersByTimeAsync(10); + streamController!.enqueue(encoder.encode(part2)); + await vi.advanceTimersByTimeAsync(0); + + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('in-memory'); + expect(result.metrics?.connectionState).toBe('connected'); + + streamController!.close(); + await client.shutdown(); + }); + + it('should update definitions when new datafile messages arrive', async () => { + const datafile1 = makeBundled({ + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + const datafile2 = makeBundled({ + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: datafile1 }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // First evaluate returns variant 0 (false) + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(false); + + // Push updated definitions + stream.push({ type: 'datafile', data: datafile2 }); + await vi.advanceTimersByTimeAsync(0); + + // Second evaluate returns variant 1 (true) + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(true); + + stream.close(); + await client.shutdown(); + }); + + it('should fall back to bundled when stream times out', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + // Stream opens but never sends data + const body = new ReadableStream({ start() {} }); + return Promise.resolve(new Response(body, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + // Advance past the stream init timeout (3s) + await vi.advanceTimersByTimeAsync(3_000); + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('embedded'); + expect(result.metrics?.connectionState).toBe('disconnected'); + }); + + it('should fall back to bundled when stream errors (4xx)', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + // Suppress expected error logs + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const evalPromise = client.evaluate('flagA'); + + // The 401 aborts the stream but the init promise may hang until timeout + await vi.advanceTimersByTimeAsync(3_000); + + const result = await evalPromise; + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('embedded'); + + errorSpy.mockRestore(); + }); + + it('should use custom initTimeoutMs value', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + const body = new ReadableStream({ start() {} }); + return Promise.resolve(new Response(body, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: { initTimeoutMs: 500 }, + polling: false, + }); + + const initPromise = client.initialize(); + + // Advance only 500ms (custom timeout) + await vi.advanceTimersByTimeAsync(500); + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.metrics?.source).toBe('embedded'); + }); + + it('should disable stream when stream: false', async () => { + const datafile = makeBundled(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + return Promise.resolve( + new Response(JSON.stringify(datafile), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: true, + }); + + await client.initialize(); + await vi.advanceTimersByTimeAsync(0); + + // No stream requests should have been made + const streamCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/stream'), + ); + expect(streamCalls).toHaveLength(0); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Polling behavior + // --------------------------------------------------------------------------- + describe('polling behavior', () => { + it('should use polling when enabled', async () => { + vi.useRealTimers(); // Polling uses real intervals + + let pollCount = 0; + const datafile = makeBundled(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(datafile), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: { intervalMs: 100, initTimeoutMs: 5000 }, + }); + + await client.initialize(); + + expect(pollCount).toBeGreaterThanOrEqual(1); + + // Wait for a few poll intervals + await new Promise((r) => setTimeout(r, 350)); + + expect(pollCount).toBeGreaterThanOrEqual(3); + + await client.shutdown(); + }); + + it('should disable polling when polling: false', async () => { + const datafile = makeBundled(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + }); + + await client.initialize(); + await vi.advanceTimersByTimeAsync(100); + + // No datafile fetch requests should have been made + const pollCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/datafile'), + ); + expect(pollCalls).toHaveLength(0); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Datafile option + // --------------------------------------------------------------------------- + describe('datafile option', () => { + it('should use provided datafile immediately', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const datafile = makeBundled({ projectId: 'provided' }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + datafile, + }); + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('in-memory'); + + stream.close(); + await client.shutdown(); + }); + + it('should work with datafile only (stream and polling disabled)', async () => { + const datafile = makeBundled(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + }); + + await client.initialize(); + const result = await client.evaluate('flagA'); + + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('in-memory'); + + // No network requests + const networkCalls = fetchMock.mock.calls.filter( + (call) => + call[0]?.toString().includes('/v1/stream') || + call[0]?.toString().includes('/v1/datafile'), + ); + expect(networkCalls).toHaveLength(0); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Stream/polling coordination + // --------------------------------------------------------------------------- + describe('stream/polling coordination', () => { + it('should fall back to bundled when stream times out (skip polling)', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled({ projectId: 'bundled' }), + }); + + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + const body = new ReadableStream({ start() {} }); + return Promise.resolve(new Response(body, { status: 200 })); + } + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(makeBundled({ projectId: 'polled' })), { + status: 200, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: { initTimeoutMs: 100 }, + polling: { intervalMs: 50, initTimeoutMs: 5000 }, + }); + + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(100); + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.metrics?.source).toBe('embedded'); + expect(pollCount).toBe(0); + + warnSpy.mockRestore(); + }); + + it('should fall back to bundled when stream fails (skip polling)', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled({ projectId: 'bundled' }), + }); + + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 500 })); + } + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(makeBundled({ projectId: 'polled' })), { + status: 200, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: { initTimeoutMs: 100 }, + polling: { intervalMs: 100, initTimeoutMs: 5000 }, + }); + + const result = await client.evaluate('flagA'); + expect(result.metrics?.source).toBe('embedded'); + expect(pollCount).toBe(0); + + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('should never stream and poll simultaneously when stream is connected', async () => { + const stream = createMockStream(); + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(makeBundled()), { status: 200 }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: true, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Wait to see if any polls happen + await vi.advanceTimersByTimeAsync(200); + + expect(pollCount).toBe(0); + + stream.close(); + await client.shutdown(); + }); + + it('should use datafile immediately while starting background stream', async () => { + vi.useRealTimers(); // Need real timers for delayed stream + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return stream.response; + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const providedDatafile = makeBundled({ + projectId: 'provided', + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + datafile: providedDatafile, + stream: true, + polling: false, + }); + + // Initialize starts background stream connection + await client.initialize(); + + // First evaluate uses provided datafile immediately + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(false); // variant 0 from provided + expect(result1.metrics?.source).toBe('in-memory'); + + // Now push stream data + stream.push({ + type: 'datafile', + data: makeBundled({ + projectId: 'stream', + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }), + }); + + // Wait for stream to deliver + await new Promise((r) => setTimeout(r, 50)); + + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(true); // variant 1 from stream + + stream.close(); + await client.shutdown(); + }); + + it('should not start polling from stream disconnect during initialization', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 500 })); + } + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(makeBundled()), { status: 200 }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: { initTimeoutMs: 5000 }, + polling: { intervalMs: 100, initTimeoutMs: 5000 }, + }); + + await client.initialize(); + + expect(pollCount).toBe(0); + + await client.shutdown(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + }); + + // --------------------------------------------------------------------------- + // getDatafile + // --------------------------------------------------------------------------- + describe('getDatafile', () => { + it('should return bundled definitions when called without initialize', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.getDatafile(); + expect(result.metrics.source).toBe('embedded'); + expect(result.metrics.cacheStatus).toBe('MISS'); + expect(result.metrics.connectionState).toBe('disconnected'); + + await client.shutdown(); + }); + + it('should fetch datafile when called without initialize and no bundled definitions', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const fetchedDatafile = makeBundled({ projectId: 'fetched' }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + return Promise.resolve( + new Response(JSON.stringify(fetchedDatafile), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.getDatafile(); + expect(result.metrics.source).toBe('remote'); + expect(result.metrics.cacheStatus).toBe('MISS'); + + await client.shutdown(); + }); + + it('should throw when called without initialize and all sources fail', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + return Promise.resolve(new Response(null, { status: 500 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.getDatafile()).rejects.toThrow( + 'No flag definitions available', + ); + + await client.shutdown(); + }); + + it('should return cached data when stream is connected', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + + const initPromise = client.initialize(); + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + const result = await client.getDatafile(); + expect(result.metrics.source).toBe('in-memory'); + expect(result.metrics.cacheStatus).toBe('HIT'); + expect(result.metrics.connectionState).toBe('connected'); + + stream.close(); + await client.shutdown(); + }); + + it('should use build step path when CI=1', async () => { + process.env.CI = '1'; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const result = await client.getDatafile(); + + expect(result.metrics.source).toBe('embedded'); + expect(result.metrics.cacheStatus).toBe('MISS'); + + await client.shutdown(); + }); + + it('should return cached data on repeated calls', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result1 = await client.getDatafile(); + expect(result1.metrics.cacheStatus).toBe('MISS'); + + const result2 = await client.getDatafile(); + expect(result2.metrics.cacheStatus).toBe('STALE'); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // getFallbackDatafile + // --------------------------------------------------------------------------- + describe('getFallbackDatafile', () => { + it('should return bundled definitions when available', async () => { + const bundled = makeBundled(); + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: bundled, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.getFallbackDatafile(); + expect(result).toEqual(bundled); + + await client.shutdown(); + }); + + it('should throw FallbackNotFoundError for missing-file state', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.getFallbackDatafile()).rejects.toThrow( + 'Bundled definitions file not found', + ); + + try { + await client.getFallbackDatafile(); + } catch (error) { + expect((error as Error).name).toBe('FallbackNotFoundError'); + } + + await client.shutdown(); + }); + + it('should throw FallbackEntryNotFoundError for missing-entry state', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-entry', + definitions: null, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.getFallbackDatafile()).rejects.toThrow( + 'No bundled definitions found for SDK key', + ); + + try { + await client.getFallbackDatafile(); + } catch (error) { + expect((error as Error).name).toBe('FallbackEntryNotFoundError'); + } + + await client.shutdown(); + }); + + it('should throw for unexpected-error state', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'unexpected-error', + definitions: null, + error: new Error('Some error'), + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.getFallbackDatafile()).rejects.toThrow( + 'Failed to read bundled definitions', + ); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // configUpdatedAt guard + // --------------------------------------------------------------------------- + describe('configUpdatedAt guard', () => { + it('should not overwrite newer data with older stream message', async () => { + vi.useRealTimers(); + + const newerDatafile = makeBundled({ + configUpdatedAt: 2000, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const olderDatafile = makeBundled({ + configUpdatedAt: 1000, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + // Send newer data first + stream.push({ type: 'datafile', data: newerDatafile }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + // Then send older data + stream.push({ type: 'datafile', data: olderDatafile }); + await new Promise((r) => setTimeout(r, 50)); + + // Should still have newer data (older message was rejected) + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = newer + + stream.close(); + await client.shutdown(); + }); + + it('should accept stream data with equal configUpdatedAt', async () => { + vi.useRealTimers(); + + const data1 = makeBundled({ + configUpdatedAt: 1000, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + const data2 = makeBundled({ + configUpdatedAt: 1000, // Same + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: data1 }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + stream.push({ type: 'datafile', data: data2 }); + await new Promise((r) => setTimeout(r, 50)); + + // Should have accepted second data (equal configUpdatedAt) + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = data2 + + stream.close(); + await client.shutdown(); + }); + + it('should accept updates when current data has no configUpdatedAt', async () => { + vi.useRealTimers(); + + const providedDatafile = makeBundled({ + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + // Remove configUpdatedAt to simulate a plain DatafileInput + delete (providedDatafile as Record).configUpdatedAt; + + const streamData = makeBundled({ + configUpdatedAt: 1000, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + datafile: providedDatafile, + polling: false, + }); + + await client.initialize(); + + // Initial evaluate uses provided datafile (variant 0) + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(false); + + // Push stream data with configUpdatedAt + stream.push({ type: 'datafile', data: streamData }); + await new Promise((r) => setTimeout(r, 50)); + + // Should accept stream data + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(true); // variant 1 = stream + + stream.close(); + await client.shutdown(); + }); + + it('should handle configUpdatedAt as string', async () => { + vi.useRealTimers(); + + const newerDatafile = { + ...makeBundled({ + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }), + configUpdatedAt: '2000' as unknown as number, + }; + + const olderDatafile = { + ...makeBundled({ + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }), + configUpdatedAt: '1000' as unknown as number, + }; + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: newerDatafile }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + stream.push({ type: 'datafile', data: olderDatafile }); + await new Promise((r) => setTimeout(r, 50)); + + // Should still have newer data + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = newer + + stream.close(); + await client.shutdown(); + }); + + it('should accept updates when configUpdatedAt is a non-numeric string', async () => { + vi.useRealTimers(); + + const currentData = { + ...makeBundled({ + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }), + configUpdatedAt: 'not-a-number' as unknown as number, + }; + + const newData = makeBundled({ + configUpdatedAt: 1000, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: currentData }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + stream.push({ type: 'datafile', data: newData }); + await new Promise((r) => setTimeout(r, 50)); + + // Should accept update since current configUpdatedAt is unparseable + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = newData + + stream.close(); + await client.shutdown(); + }); + + it('should not overwrite newer in-memory data via getDatafile', async () => { + vi.useRealTimers(); + + const newerDatafile = makeBundled({ + configUpdatedAt: 2000, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: newerDatafile }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + // getDatafile and then evaluate — data should still be newer + await client.getDatafile(); + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = newer + + stream.close(); + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Evaluate behavior + // --------------------------------------------------------------------------- + describe('evaluate behavior', () => { + it('should return FLAG_NOT_FOUND with defaultValue for missing flag', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled(), + buildStep: true, + }); + + const result = await client.evaluate('nonexistent-flag', 'default'); + + expect(result.value).toBe('default'); + expect(result.reason).toBe('error'); + expect(result.errorCode).toBe('FLAG_NOT_FOUND'); + expect(result.errorMessage).toContain( + 'Definition not found for flag "nonexistent-flag"', + ); + }); + + it('should evaluate existing paused flag', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled(), + buildStep: true, + }); + + const result = await client.evaluate('flagA'); + + expect(result.value).toBe(true); + expect(result.reason).toBe('paused'); + }); + + it('should pass entities for targeting evaluation', async () => { + const datafile = makeBundled({ + definitions: { + 'targeted-flag': { + environments: { + production: { + // targets is the packed shorthand for targeting rules + targets: [{}, { user: { id: ['user-123'] } }], + fallthrough: 0, + }, + }, + variants: ['default', 'targeted'], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + const result = await client.evaluate('targeted-flag', 'default', { + user: { id: 'user-123' }, + }); + + expect(result.value).toBe('targeted'); + expect(result.reason).toBe('target_match'); + }); + + it('should use empty entities when not provided', async () => { + const datafile = makeBundled({ + definitions: { + 'targeted-flag': { + environments: { + production: { + targets: [{}, { user: { id: ['user-123'] } }], + fallthrough: 0, + }, + }, + variants: ['default', 'targeted'], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + const result = await client.evaluate('targeted-flag'); + + expect(result.value).toBe('default'); + expect(result.reason).toBe('fallthrough'); + }); + + it('should work with different value types', async () => { + const datafile = makeBundled({ + definitions: { + boolFlag: { + environments: { production: 0 }, + variants: [true], + }, + stringFlag: { + environments: { production: 0 }, + variants: ['hello'], + }, + numberFlag: { + environments: { production: 0 }, + variants: [42], + }, + objectFlag: { + environments: { production: 0 }, + variants: [{ key: 'value' }], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + expect((await client.evaluate('boolFlag')).value).toBe(true); + expect((await client.evaluate('stringFlag')).value).toBe('hello'); + expect((await client.evaluate('numberFlag')).value).toBe(42); + expect((await client.evaluate('objectFlag')).value).toEqual({ + key: 'value', + }); + }); + + it('should call internalReportValue when projectId exists', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled({ projectId: 'my-project-id' }), + buildStep: true, + }); + + await client.evaluate('flagA'); + + expect(internalReportValue).toHaveBeenCalledWith('flagA', true, { + originProjectId: 'my-project-id', + originProvider: 'vercel', + reason: 'paused', + outcomeType: 'value', + }); + }); + + it('should not call internalReportValue when projectId is missing', async () => { + const datafile = makeBundled(); + delete (datafile as Record).projectId; + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + await client.evaluate('flagA'); + + expect(internalReportValue).not.toHaveBeenCalled(); + }); + + it('should not call internalReportValue when result is error', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled({ projectId: 'my-project-id' }), + buildStep: true, + }); + + await client.evaluate('nonexistent-flag', 'default'); + + expect(internalReportValue).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // Concurrent initialization + // --------------------------------------------------------------------------- + describe('concurrent initialization', () => { + it('should deduplicate concurrent initialize() calls', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + + // Call initialize three times concurrently + const p1 = client.initialize(); + const p2 = client.initialize(); + const p3 = client.initialize(); + + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + + await Promise.all([p1, p2, p3]); + + // Stream should have been fetched only once + const streamCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/stream'), + ); + expect(streamCalls).toHaveLength(1); + + stream.close(); + await client.shutdown(); + }); + + it('should deduplicate concurrent evaluate() calls that trigger initialize', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + + // Three concurrent evaluates trigger lazy initialization + const p1 = client.evaluate('flagA'); + const p2 = client.evaluate('flagA'); + const p3 = client.evaluate('flagA'); + + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + // All should have the same value + expect(r1.value).toBe(true); + expect(r2.value).toBe(true); + expect(r3.value).toBe(true); + + // Stream should have been fetched only once + const streamCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/stream'), + ); + expect(streamCalls).toHaveLength(1); + + stream.close(); + await client.shutdown(); + }); + + it('should allow re-initialization after failure', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + let fetchCallCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + fetchCallCount++; + if (fetchCallCount === 1) { + // First fetch fails + return Promise.resolve(new Response(null, { status: 500 })); + } + // Second fetch succeeds + return Promise.resolve( + new Response(JSON.stringify(makeBundled()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + // First initialize fails (no bundled, fetch returns 500) + await expect(client.initialize()).rejects.toThrow(); + + // Second initialize should retry — fetch now succeeds + await client.initialize(); + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Multiple clients + // --------------------------------------------------------------------------- + describe('multiple clients', () => { + it('should maintain independent state for each client', async () => { + const datafileA = makeBundled({ + definitions: { + flagA: { + environments: { production: 0 }, + variants: ['a-value', 'b-value'], + }, + }, + }); + + const datafileB = makeBundled({ + definitions: { + flagA: { + environments: { production: 1 }, + variants: ['a-value', 'b-value'], + }, + }, + }); + + const clientA = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: datafileA, + buildStep: true, + }); + + const clientB = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: datafileB, + buildStep: true, + }); + + const resultA = await clientA.evaluate('flagA'); + const resultB = await clientB.evaluate('flagA'); + + expect(resultA.value).toBe('a-value'); + expect(resultB.value).toBe('b-value'); + + // Shutdown one, other should still work + await clientA.shutdown(); + + const resultB2 = await clientB.evaluate('flagA'); + expect(resultB2.value).toBe('b-value'); + + await clientB.shutdown(); + }); + }); +}); diff --git a/packages/vercel-flags-core/src/controller-fns.test.ts b/packages/vercel-flags-core/src/controller-fns.test.ts deleted file mode 100644 index 5b6554fb..00000000 --- a/packages/vercel-flags-core/src/controller-fns.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - evaluate, - getFallbackDatafile, - initialize, - shutdown, -} from './controller-fns'; -import { controllerInstanceMap } from './controller-instance-map'; -import type { BundledDefinitions, ControllerInterface, Packed } from './types'; -import { ErrorCode, ResolutionReason } from './types'; - -// Mock the internalReportValue function -vi.mock('./lib/report-value', () => ({ - internalReportValue: vi.fn(), -})); - -import { internalReportValue } from './lib/report-value'; - -function createMockController( - overrides?: Partial, -): ControllerInterface { - return { - read: vi.fn().mockResolvedValue({ - projectId: 'test-project', - definitions: {}, - segments: {}, - environment: 'production', - metrics: { - readMs: 0, - source: 'in-memory', - cacheStatus: 'HIT', - }, - }), - getDatafile: vi.fn().mockResolvedValue({ - projectId: 'test-project', - definitions: {}, - segments: {}, - environment: 'production', - metrics: { - readMs: 0, - source: 'in-memory', - cacheStatus: 'HIT', - }, - }), - initialize: vi.fn().mockResolvedValue(undefined), - shutdown: vi.fn().mockResolvedValue(undefined), - ...overrides, - }; -} - -function mockDatafile(data: { - projectId?: string; - definitions: Record; - segments: Record; - environment: string; -}) { - return { - ...data, - metrics: { - readMs: 0, - source: 'in-memory' as const, - cacheStatus: 'HIT' as const, - }, - }; -} - -describe('client-fns', () => { - const CLIENT_ID = 99; - - beforeEach(() => { - controllerInstanceMap.clear(); - vi.clearAllMocks(); - }); - - afterEach(() => { - controllerInstanceMap.clear(); - }); - - describe('initialize', () => { - it('should call controller.initialize()', async () => { - const controller = createMockController(); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - await initialize(CLIENT_ID); - - expect(controller.initialize).toHaveBeenCalledTimes(1); - }); - - it('should return the result from controller.initialize()', async () => { - const controller = createMockController({ - initialize: vi.fn().mockResolvedValue('init-result'), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - const result = await initialize(CLIENT_ID); - - expect(result).toBe('init-result'); - }); - - it('should throw if client ID is not in map', () => { - expect(() => initialize(999)).toThrow(); - }); - }); - - describe('shutdown', () => { - it('should call controller.shutdown()', async () => { - const controller = createMockController(); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - await shutdown(CLIENT_ID); - - expect(controller.shutdown).toHaveBeenCalledTimes(1); - }); - - it('should return the result from controller.shutdown()', async () => { - const controller = createMockController({ - shutdown: vi.fn().mockResolvedValue('shutdown-result'), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - const result = await shutdown(CLIENT_ID); - - expect(result).toBe('shutdown-result'); - }); - - it('should throw if client ID is not in map', () => { - expect(() => shutdown(999)).toThrow(); - }); - }); - - describe('getFallbackDatafile', () => { - it('should call controller.getFallbackDatafile() if it exists', async () => { - const mockFallback: BundledDefinitions = { - projectId: 'test', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - const getFallbackDatafileFn = vi.fn().mockResolvedValue(mockFallback); - const controller = createMockController({ - getFallbackDatafile: getFallbackDatafileFn, - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - await getFallbackDatafile(CLIENT_ID); - - expect(getFallbackDatafileFn).toHaveBeenCalledTimes(1); - }); - - it('should return the result from controller.getFallbackDatafile()', async () => { - const mockFallback: BundledDefinitions = { - projectId: 'test', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - const controller = createMockController({ - getFallbackDatafile: vi.fn().mockResolvedValue(mockFallback), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - const result = await getFallbackDatafile(CLIENT_ID); - - expect(result).toEqual(mockFallback); - }); - - it('should throw if controller does not have getFallbackDatafile', () => { - const controller = createMockController(); - // Remove getFallbackDatafile - delete (controller as Partial).getFallbackDatafile; - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - expect(() => getFallbackDatafile(CLIENT_ID)).toThrow( - 'flags: This data source does not support fallbacks', - ); - }); - - it('should throw if client ID is not in map', () => { - expect(() => getFallbackDatafile(999)).toThrow(); - }); - }); - - describe('evaluate', () => { - it('should return FLAG_NOT_FOUND error when flag does not exist', async () => { - const controller = createMockController({ - read: vi.fn().mockResolvedValue( - mockDatafile({ - projectId: 'test', - definitions: {}, - segments: {}, - environment: 'production', - }), - ), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - const result = await evaluate(CLIENT_ID, 'nonexistent-flag', 'default'); - - expect(result.value).toBe('default'); - expect(result.reason).toBe(ResolutionReason.ERROR); - expect(result.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND); - expect(result.errorMessage).toBe( - 'Definition not found for flag "nonexistent-flag"', - ); - expect(result.metrics).toBeDefined(); - expect(result.metrics!.source).toBe('in-memory'); - }); - - it('should use defaultValue when flag is not found', async () => { - const controller = createMockController({ - read: vi.fn().mockResolvedValue( - mockDatafile({ - projectId: 'test', - definitions: {}, - segments: {}, - environment: 'production', - }), - ), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - const result = await evaluate(CLIENT_ID, 'missing', { fallback: true }); - - expect(result.value).toEqual({ fallback: true }); - }); - - it('should evaluate flag when it exists', async () => { - // A flag with environments: { production: 0 } is "paused" (just returns variant 0) - const flagDefinition: Packed.FlagDefinition = { - environments: { production: 0 }, - variants: [true], - }; - const controller = createMockController({ - read: vi.fn().mockResolvedValue( - mockDatafile({ - projectId: 'test', - definitions: { 'my-flag': flagDefinition }, - segments: {}, - environment: 'production', - }), - ), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - const result = await evaluate(CLIENT_ID, 'my-flag', false); - - expect(result.value).toBe(true); - expect(result.reason).toBe(ResolutionReason.PAUSED); - expect(result.metrics).toBeDefined(); - }); - - it('should call internalReportValue when projectId exists', async () => { - // A flag with environments: { production: 0 } is "paused" - const flagDefinition: Packed.FlagDefinition = { - environments: { production: 0 }, - variants: ['variant-a'], - }; - const controller = createMockController({ - read: vi.fn().mockResolvedValue( - mockDatafile({ - projectId: 'my-project-id', - definitions: { 'my-flag': flagDefinition }, - segments: {}, - environment: 'production', - }), - ), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - await evaluate(CLIENT_ID, 'my-flag', 'default'); - - expect(internalReportValue).toHaveBeenCalledWith( - 'my-flag', - 'variant-a', - expect.objectContaining({ - originProjectId: 'my-project-id', - originProvider: 'vercel', - reason: ResolutionReason.PAUSED, - }), - ); - }); - - it('should not call internalReportValue when projectId is missing', async () => { - const flagDefinition: Packed.FlagDefinition = { - environments: { production: 0 }, - variants: [true], - }; - const controller = createMockController({ - read: vi.fn().mockResolvedValue( - mockDatafile({ - projectId: undefined, - definitions: { 'my-flag': flagDefinition }, - segments: {}, - environment: 'production', - }), - ), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - await evaluate(CLIENT_ID, 'my-flag'); - - expect(internalReportValue).not.toHaveBeenCalled(); - }); - - it('should not include outcomeType in report when result is error', async () => { - const controller = createMockController({ - read: vi.fn().mockResolvedValue( - mockDatafile({ - projectId: 'test', - definitions: {}, - segments: {}, - environment: 'production', - }), - ), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - await evaluate(CLIENT_ID, 'nonexistent'); - - // internalReportValue is not called for FLAG_NOT_FOUND errors - // because there's no projectId in the mock or the code path doesn't report errors - // Let's verify by checking the actual behavior - expect(internalReportValue).not.toHaveBeenCalled(); - }); - - it('should pass entities to evaluation', async () => { - const flagDefinition: Packed.FlagDefinition = { - environments: { - production: { - targets: [{}, { user: { id: ['user-123'] } }], - fallthrough: 0, - }, - }, - variants: ['default', 'targeted'], - }; - const controller = createMockController({ - read: vi.fn().mockResolvedValue( - mockDatafile({ - projectId: 'test', - definitions: { 'targeted-flag': flagDefinition }, - segments: {}, - environment: 'production', - }), - ), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - const result = await evaluate(CLIENT_ID, 'targeted-flag', 'default', { - user: { id: 'user-123' }, - }); - - expect(result.value).toBe('targeted'); - expect(result.reason).toBe(ResolutionReason.TARGET_MATCH); - }); - - it('should use empty entities object when not provided', async () => { - const flagDefinition: Packed.FlagDefinition = { - environments: { - production: { - fallthrough: 0, - }, - }, - variants: ['value'], - }; - const controller = createMockController({ - read: vi.fn().mockResolvedValue( - mockDatafile({ - projectId: 'test', - definitions: { 'my-flag': flagDefinition }, - segments: {}, - environment: 'production', - }), - ), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - // Call without entities - const result = await evaluate(CLIENT_ID, 'my-flag'); - - expect(result.value).toBe('value'); - }); - - it('should throw if client ID is not in map', async () => { - await expect(evaluate(999, 'any-flag')).rejects.toThrow(); - }); - - it('should work with different value types', async () => { - const controller = createMockController({ - read: vi.fn().mockResolvedValue( - mockDatafile({ - projectId: 'test', - definitions: { - 'bool-flag': { - environments: { production: 0 }, - variants: [true], - }, - 'string-flag': { - environments: { production: 0 }, - variants: ['hello'], - }, - 'number-flag': { - environments: { production: 0 }, - variants: [42], - }, - 'object-flag': { - environments: { production: 0 }, - variants: [{ key: 'value' }], - }, - }, - segments: {}, - environment: 'production', - }), - ), - }); - controllerInstanceMap.set(CLIENT_ID, { - controller, - initialized: false, - initPromise: null, - }); - - const boolResult = await evaluate(CLIENT_ID, 'bool-flag'); - expect(boolResult.value).toBe(true); - - const stringResult = await evaluate(CLIENT_ID, 'string-flag'); - expect(stringResult.value).toBe('hello'); - - const numberResult = await evaluate(CLIENT_ID, 'number-flag'); - expect(numberResult.value).toBe(42); - - const objectResult = await evaluate<{ key: string }>( - CLIENT_ID, - 'object-flag', - ); - expect(objectResult.value).toEqual({ key: 'value' }); - }); - }); -}); diff --git a/packages/vercel-flags-core/src/controller/index.test.ts b/packages/vercel-flags-core/src/controller/index.test.ts deleted file mode 100644 index ab193bff..00000000 --- a/packages/vercel-flags-core/src/controller/index.test.ts +++ /dev/null @@ -1,1993 +0,0 @@ -import { HttpResponse, http } from 'msw'; -import { setupServer } from 'msw/node'; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; -import type { BundledDefinitions, DatafileInput } from '../types'; -import { Controller } from '.'; - -// Mock the bundled definitions module -vi.mock('../utils/read-bundled-definitions', () => ({ - readBundledDefinitions: vi.fn(() => - Promise.resolve({ definitions: null, state: 'missing-file' }), - ), -})); - -import { readBundledDefinitions } from '../utils/read-bundled-definitions'; - -let ingestRequests: { body: unknown; headers: Headers }[] = []; - -const server = setupServer( - http.post('https://flags.vercel.com/v1/ingest', async ({ request }) => { - ingestRequests.push({ - body: await request.json(), - headers: request.headers, - }); - return HttpResponse.json({ ok: true }); - }), -); - -const originalEnv = { ...process.env }; - -beforeAll(() => server.listen()); -beforeEach(() => { - ingestRequests = []; - vi.mocked(readBundledDefinitions).mockReset(); - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: null, - state: 'missing-file', - }); - // Reset env vars that affect build step detection - delete process.env.CI; - delete process.env.NEXT_PHASE; -}); -afterEach(() => { - server.resetHandlers(); - // Restore original env - process.env = { ...originalEnv }; -}); -afterAll(() => server.close()); - -function createNdjsonStream(messages: object[], delayMs = 0): ReadableStream { - return new ReadableStream({ - async start(controller) { - for (const message of messages) { - if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); - controller.enqueue( - new TextEncoder().encode(`${JSON.stringify(message)}\n`), - ); - } - controller.close(); - }, - }); -} - -async function assertIngestRequest( - sdkKey: string, - expectedEvents: Array<{ type: string; payload?: object }>, -) { - await vi.waitFor(() => { - expect(ingestRequests.length).toBeGreaterThan(0); - }); - - const request = ingestRequests[0]!; - expect(request.headers.get('Authorization')).toBe(`Bearer ${sdkKey}`); - expect(request.headers.get('Content-Type')).toBe('application/json'); - expect(request.headers.get('User-Agent')).toMatch(/^VercelFlagsCore\//); - - expect(request.body).toEqual( - expectedEvents.map((event) => - expect.objectContaining({ - type: event.type, - ts: expect.any(Number), - payload: event.payload ?? expect.any(Object), - }), - ), - ); -} - -describe('Controller', () => { - // Note: Low-level NDJSON parsing tests (parse datafile, ignore ping, handle split chunks) - // are in stream-connection.test.ts. These tests focus on Controller-specific behavior. - - it('should abort the stream connection when shutdown is called', async () => { - let abortSignalReceived: AbortSignal | undefined; - - server.use( - http.get('https://flags.vercel.com/v1/stream', async ({ request }) => { - abortSignalReceived = request.signal; - - const stream = new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - })}\n`, - ), - ); - - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }); - - return new HttpResponse(stream, { - headers: { 'Content-Type': 'application/x-ndjson' }, - }); - }), - ); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - await dataSource.read(); - - expect(abortSignalReceived).toBeDefined(); - expect(abortSignalReceived!.aborted).toBe(false); - - await dataSource.shutdown(); - - expect(abortSignalReceived!.aborted).toBe(true); - }); - - it('should handle messages split across chunks', async () => { - const definitions = { - projectId: 'test-project', - definitions: { flag: { variants: [1, 2, 3] } }, - }; - - const fullMessage = JSON.stringify({ type: 'datafile', data: definitions }); - const part1 = fullMessage.slice(0, 20); - const part2 = `${fullMessage.slice(20)}\n`; - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse( - new ReadableStream({ - async start(controller) { - controller.enqueue(new TextEncoder().encode(part1)); - await new Promise((r) => setTimeout(r, 10)); - controller.enqueue(new TextEncoder().encode(part2)); - controller.close(); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const controller = new Controller({ sdkKey: 'vf_test_key' }); - const result = await controller.read(); - - expect(result).toMatchObject(definitions); - expect(result.metrics.source).toBe('in-memory'); - expect(result.metrics.cacheStatus).toBe('MISS'); - expect(result.metrics.connectionState).toBe('connected'); - - await controller.shutdown(); - await assertIngestRequest('vf_test_key', [{ type: 'FLAGS_CONFIG_READ' }]); - }); - - it('should update definitions when new datafile messages arrive', async () => { - const definitions1 = { projectId: 'test', definitions: { v: 1 } }; - const definitions2 = { projectId: 'test', definitions: { v: 2 } }; - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse( - createNdjsonStream([ - { type: 'datafile', data: definitions1 }, - { type: 'datafile', data: definitions2 }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - - // First call gets initial data - await dataSource.read(); - - // Wait for stream to process second message, then verify via read - await vi.waitFor(async () => { - const result = await dataSource.read(); - expect(result).toMatchObject(definitions2); - }); - - await dataSource.shutdown(); - }); - - it('should fall back to bundledDefinitions when stream times out', async () => { - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled-project', - definitions: {}, - environment: 'production', - configUpdatedAt: 1000, - digest: 'aa', - revision: 1, - }; - - // Mock bundled definitions to return valid data - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - // Create a stream that never sends data (simulating timeout) - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse( - new ReadableStream({ - start() { - // Never enqueue anything, never close - simulates hanging connection - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - polling: false, // Disable polling to test stream timeout in isolation - }); - - // read should return bundledDefinitions after timeout (3s default) - const startTime = Date.now(); - const result = await dataSource.read(); - const elapsed = Date.now() - startTime; - - // Should have returned bundled definitions with STALE status - expect(result).toMatchObject({ - projectId: 'bundled-project', - definitions: {}, - environment: 'production', - }); - expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('STALE'); - expect(result.metrics.connectionState).toBe('disconnected'); - - // Should have taken roughly 3 seconds (the timeout) - expect(elapsed).toBeGreaterThanOrEqual(2900); - expect(elapsed).toBeLessThan(4000); - - // Don't await shutdown - the stream never closes in this test - dataSource.shutdown(); - }, 10000); - - it('should fall back to bundledDefinitions when stream errors (4xx)', async () => { - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled-project', - definitions: {}, - environment: 'production', - configUpdatedAt: 1000, - digest: 'aa', - revision: 1, - }; - - // Mock bundled definitions to return valid data - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - // Return a 401 error - this will cause the stream to fail permanently - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse(null, { status: 401 }); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - polling: false, // Disable polling to test stream error fallback in isolation - }); - - // Suppress expected error logs for this test - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const result = await dataSource.read(); - - expect(result).toMatchObject({ - projectId: 'bundled-project', - definitions: {}, - environment: 'production', - }); - expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('STALE'); - expect(result.metrics.connectionState).toBe('disconnected'); - - await dataSource.shutdown(); - - errorSpy.mockRestore(); - }); - - it('should include X-Retry-Attempt header in stream requests', async () => { - let capturedHeaders: Headers | null = null; - - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - capturedHeaders = request.headers; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - await dataSource.read(); - - expect(capturedHeaders).not.toBeNull(); - expect(capturedHeaders!.get('X-Retry-Attempt')).toBe('0'); - - await dataSource.shutdown(); - }); - - describe('constructor validation', () => { - it('should throw for missing SDK key', () => { - expect(() => new Controller({ sdkKey: '' })).toThrow( - '@vercel/flags-core: SDK key must be a string starting with "vf_"', - ); - }); - - it('should throw for SDK key not starting with vf_', () => { - expect(() => new Controller({ sdkKey: 'invalid_key' })).toThrow( - '@vercel/flags-core: SDK key must be a string starting with "vf_"', - ); - }); - - it('should throw for non-string SDK key', () => { - expect( - () => new Controller({ sdkKey: 123 as unknown as string }), - ).toThrow( - '@vercel/flags-core: SDK key must be a string starting with "vf_"', - ); - }); - - it('should accept valid SDK key', () => { - expect(() => new Controller({ sdkKey: 'vf_valid_key' })).not.toThrow(); - }); - }); - - describe('build step detection', () => { - it('should detect build step when CI=1', async () => { - process.env.CI = '1'; - - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: { - flag: { variants: [true], environments: {} }, - }, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - const result = await dataSource.read(); - - // Should use bundled definitions without making stream request - expect(result).toMatchObject(bundledDefinitions); - expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('MISS'); - expect(result.metrics.connectionState).toBe('disconnected'); - - await dataSource.shutdown(); - }); - - it('should detect build step when NEXT_PHASE=phase-production-build', async () => { - process.env.NEXT_PHASE = 'phase-production-build'; - - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - const result = await dataSource.read(); - - expect(result).toMatchObject(bundledDefinitions); - expect(result.metrics.source).toBe('embedded'); - - await dataSource.shutdown(); - }); - - it('should NOT detect build step when neither CI nor NEXT_PHASE is set', async () => { - // Neither env var is set (cleared in beforeEach) - let streamRequested = false; - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - streamRequested = true; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - await dataSource.read(); - - expect(streamRequested).toBe(true); - - await dataSource.shutdown(); - }); - }); - - describe('build step behavior', () => { - it('should throw when bundled definitions missing during build', async () => { - process.env.CI = '1'; - - // Bundled definitions not available - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: null, - state: 'missing-file', - }); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - - await expect(dataSource.read()).rejects.toThrow( - 'No flag definitions available during build', - ); - - await dataSource.shutdown(); - }); - - it('should cache data after first build step read', async () => { - process.env.CI = '1'; - - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - - // First read - const firstResult = await dataSource.read(); - expect(firstResult.metrics.cacheStatus).toBe('MISS'); - - // Second read should use cached data - const result = await dataSource.read(); - expect(result).toMatchObject(bundledDefinitions); - expect(result.metrics.cacheStatus).toBe('HIT'); - - // readBundledDefinitions should have been called only during construction - expect(readBundledDefinitions).toHaveBeenCalledTimes(1); - - await dataSource.shutdown(); - }); - }); - - describe('getFallbackDatafile', () => { - it('should return bundled definitions when available', async () => { - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - }); - - const result = await dataSource.getFallbackDatafile(); - expect(result).toEqual(bundledDefinitions); - - await dataSource.shutdown(); - }); - - it('should throw FallbackNotFoundError for missing-file state', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: null, - state: 'missing-file', - }); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - }); - - await expect(dataSource.getFallbackDatafile()).rejects.toThrow( - 'Bundled definitions file not found', - ); - - try { - await dataSource.getFallbackDatafile(); - } catch (error) { - expect((error as Error).name).toBe('FallbackNotFoundError'); - } - - await dataSource.shutdown(); - }); - - it('should throw FallbackEntryNotFoundError for missing-entry state', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: null, - state: 'missing-entry', - }); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - }); - - await expect(dataSource.getFallbackDatafile()).rejects.toThrow( - 'No bundled definitions found for SDK key', - ); - - try { - await dataSource.getFallbackDatafile(); - } catch (error) { - expect((error as Error).name).toBe('FallbackEntryNotFoundError'); - } - - await dataSource.shutdown(); - }); - - it('should throw for unexpected-error state', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: null, - state: 'unexpected-error', - error: new Error('Some error'), - }); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - }); - - await expect(dataSource.getFallbackDatafile()).rejects.toThrow( - 'Failed to read bundled definitions', - ); - - await dataSource.shutdown(); - }); - }); - - describe('custom stream options', () => { - it('should use custom initTimeoutMs value', async () => { - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - // Stream that never responds - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse(new ReadableStream({ start() {} }), { - headers: { 'Content-Type': 'application/x-ndjson' }, - }); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - stream: { initTimeoutMs: 500 }, // Much shorter timeout - polling: false, // Disable polling to test stream timeout directly - }); - - const startTime = Date.now(); - const result = await dataSource.read(); - const elapsed = Date.now() - startTime; - - expect(result).toMatchObject({ - projectId: 'bundled', - definitions: {}, - environment: 'production', - }); - expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('STALE'); - expect(result.metrics.connectionState).toBe('disconnected'); - expect(elapsed).toBeGreaterThanOrEqual(450); - expect(elapsed).toBeLessThan(1500); - - dataSource.shutdown(); - }, 5000); - - it('should disable stream when stream: false', async () => { - let streamRequested = false; - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - streamRequested = true; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - http.get('https://flags.vercel.com/v1/datafile', () => { - return HttpResponse.json({ - projectId: 'polled', - definitions: {}, - environment: 'production', - }); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - stream: false, - polling: true, - }); - - await dataSource.read(); - - expect(streamRequested).toBe(false); - - await dataSource.shutdown(); - }); - }); - - describe('polling options', () => { - it('should use polling when enabled', async () => { - let pollCount = 0; - - server.use( - http.get('https://flags.vercel.com/v1/datafile', () => { - pollCount++; - return HttpResponse.json({ - projectId: 'polled', - definitions: { count: pollCount }, - environment: 'production', - }); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - stream: false, - polling: { intervalMs: 100, initTimeoutMs: 5000 }, - }); - - const result = await dataSource.read(); - - expect(result.projectId).toBe('polled'); - expect(pollCount).toBeGreaterThanOrEqual(1); - - // Wait for a few poll intervals - await new Promise((r) => setTimeout(r, 350)); - - expect(pollCount).toBeGreaterThanOrEqual(3); - - await dataSource.shutdown(); - }); - - it('should disable polling when polling: false', async () => { - let pollCount = 0; - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - http.get('https://flags.vercel.com/v1/datafile', () => { - pollCount++; - return HttpResponse.json({ - projectId: 'polled', - definitions: {}, - environment: 'production', - }); - }), - ); - - const providedDatafile: DatafileInput = { - projectId: 'static-data', - definitions: {}, - environment: 'production', - }; - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - datafile: providedDatafile, - stream: false, - polling: false, - }); - - await dataSource.read(); - - expect(pollCount).toBe(0); - - await dataSource.shutdown(); - }); - }); - - describe('datafile option', () => { - it('should use provided datafile immediately', async () => { - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const providedDatafile: DatafileInput = { - projectId: 'provided', - definitions: {}, - environment: 'production', - }; - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - datafile: providedDatafile, - }); - - // Should immediately return provided datafile - const result = await dataSource.read(); - - expect(result.projectId).toBe('provided'); - expect(result.metrics.source).toBe('in-memory'); - - await dataSource.shutdown(); - }); - - it('should work with datafile only (stream and polling disabled)', async () => { - let streamRequested = false; - let pollRequested = false; - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - streamRequested = true; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - http.get('https://flags.vercel.com/v1/datafile', () => { - pollRequested = true; - return HttpResponse.json({ - projectId: 'polled', - definitions: {}, - environment: 'production', - }); - }), - ); - - const providedDatafile: DatafileInput = { - projectId: 'static-data', - definitions: { myFlag: { variants: [true, false], environments: {} } }, - environment: 'production', - }; - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - datafile: providedDatafile, - stream: false, - polling: false, - }); - - // Initialize and read - await dataSource.initialize(); - const result = await dataSource.read(); - - // Should use provided datafile - expect(result.projectId).toBe('static-data'); - expect(result.definitions).toEqual({ - myFlag: { variants: [true, false], environments: {} }, - }); - - // No network requests should have been made - expect(streamRequested).toBe(false); - expect(pollRequested).toBe(false); - - // Wait to ensure no delayed requests happen - await new Promise((r) => setTimeout(r, 100)); - expect(streamRequested).toBe(false); - expect(pollRequested).toBe(false); - - await dataSource.shutdown(); - }); - }); - - describe('stream/polling coordination', () => { - it('should fall back to bundled when stream times out (skip polling)', async () => { - let pollCount = 0; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: { - projectId: 'bundled', - definitions: {}, - segments: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }, - }); - - server.use( - http.get('https://flags.vercel.com/v1/stream', async () => { - // Stream opens but never sends data (will timeout) - return new HttpResponse(new ReadableStream({ start() {} }), { - headers: { 'Content-Type': 'application/x-ndjson' }, - }); - }), - http.get('https://flags.vercel.com/v1/datafile', () => { - pollCount++; - return HttpResponse.json({ - projectId: 'polled', - definitions: {}, - environment: 'production', - }); - }), - ); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - stream: { initTimeoutMs: 100 }, - polling: { intervalMs: 50, initTimeoutMs: 5000 }, - }); - - const result = await dataSource.read(); - - // Should have fallen back to bundled, not polling - expect(result.projectId).toBe('bundled'); - expect(pollCount).toBe(0); - - await dataSource.shutdown(); - warnSpy.mockRestore(); - }); - - it('should fall back to bundled when stream fails (skip polling)', async () => { - let pollCount = 0; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: { - projectId: 'bundled', - definitions: {}, - segments: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }, - }); - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse(null, { status: 500 }); - }), - http.get('https://flags.vercel.com/v1/datafile', () => { - pollCount++; - return HttpResponse.json({ - projectId: 'polled', - definitions: {}, - environment: 'production', - }); - }), - ); - - // Suppress expected error logs - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - stream: { initTimeoutMs: 100 }, - polling: { intervalMs: 100, initTimeoutMs: 5000 }, - }); - - const result = await dataSource.read(); - - // Should have fallen back to bundled, not polling - expect(result.projectId).toBe('bundled'); - expect(pollCount).toBe(0); - - await dataSource.shutdown(); - - errorSpy.mockRestore(); - warnSpy.mockRestore(); - }); - - it('should never stream and poll simultaneously when stream is connected', async () => { - let streamRequestCount = 0; - let pollRequestCount = 0; - - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - streamRequestCount++; - // Create a stream that stays open (simulating connected stream) - return new HttpResponse( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - })}\n`, - ), - ); - // Keep stream open by not closing controller - // Will be closed when test calls shutdown() - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - http.get('https://flags.vercel.com/v1/datafile', () => { - pollRequestCount++; - return HttpResponse.json({ - projectId: 'polled', - definitions: {}, - environment: 'production', - }); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - stream: true, - polling: false, // Disable polling to test stream-only mode - }); - - await dataSource.read(); - - // Stream should be used, polling should not be triggered - expect(streamRequestCount).toBe(1); - expect(pollRequestCount).toBe(0); - - // Wait to see if any polls happen - await new Promise((r) => setTimeout(r, 200)); - - // Still no polls should have happened - expect(pollRequestCount).toBe(0); - - await dataSource.shutdown(); - }); - - it('should use datafile immediately while starting background stream', async () => { - let streamConnected = false; - let dataUpdated = false; - - server.use( - http.get('https://flags.vercel.com/v1/stream', async ({ request }) => { - // Simulate slow stream connection - await new Promise((r) => setTimeout(r, 200)); - streamConnected = true; - return new HttpResponse( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ - type: 'datafile', - data: { - projectId: 'stream', - definitions: { updated: true }, - }, - })}\n`, - ), - ); - dataUpdated = true; - // Keep stream open - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const providedDatafile: DatafileInput = { - projectId: 'provided', - definitions: {}, - environment: 'production', - }; - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - datafile: providedDatafile, - stream: true, - polling: false, - }); - - // Call initialize to start background updates - await dataSource.initialize(); - - // First read should be immediate (from provided datafile) - const startTime = Date.now(); - const result = await dataSource.read(); - const elapsed = Date.now() - startTime; - - expect(result.projectId).toBe('provided'); - expect(elapsed).toBeLessThan(100); // Should be very fast - expect(streamConnected).toBe(false); // Stream hasn't connected yet - - // Wait for stream to connect and update data - await vi.waitFor( - () => { - expect(dataUpdated).toBe(true); - }, - { timeout: 2000 }, - ); - - // Now read should return stream data - const updatedResult = await dataSource.read(); - expect(updatedResult.definitions).toEqual({ updated: true }); - expect(updatedResult.projectId).toBe('stream'); - - await dataSource.shutdown(); - }); - - it('should not start polling from stream disconnect during initialization', async () => { - let pollCount = 0; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: { - projectId: 'bundled', - definitions: {}, - segments: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }, - }); - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - // Stream fails immediately, triggering onDisconnect - return new HttpResponse(null, { status: 500 }); - }), - http.get('https://flags.vercel.com/v1/datafile', () => { - pollCount++; - return HttpResponse.json({ - projectId: 'polled', - definitions: {}, - environment: 'production', - }); - }), - ); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - stream: { initTimeoutMs: 5000 }, - polling: { intervalMs: 100, initTimeoutMs: 5000 }, - }); - - await dataSource.initialize(); - - // Polling should not be tried during init when stream is enabled — - // stream failure falls back directly to bundled definitions - expect(pollCount).toBe(0); - - await dataSource.shutdown(); - errorSpy.mockRestore(); - warnSpy.mockRestore(); - }); - }); - - describe('getDatafile', () => { - it('should return bundled definitions when called without initialize', async () => { - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: { flag: true }, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - const result = await dataSource.getDatafile(); - - expect(result).toMatchObject(bundledDefinitions); - expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('MISS'); - expect(result.metrics.connectionState).toBe('disconnected'); - - await dataSource.shutdown(); - }); - - it('should fetch datafile when called without initialize and no bundled definitions', async () => { - const fetchedDefinitions: BundledDefinitions = { - projectId: 'fetched', - definitions: { flag: true }, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - server.use( - http.get('https://flags.vercel.com/v1/datafile', () => { - return HttpResponse.json(fetchedDefinitions); - }), - ); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - const result = await dataSource.getDatafile(); - - expect(result).toMatchObject(fetchedDefinitions); - expect(result.metrics.source).toBe('remote'); - expect(result.metrics.cacheStatus).toBe('MISS'); - expect(result.metrics.connectionState).toBe('disconnected'); - - await dataSource.shutdown(); - }); - - it('should throw when called without initialize and all sources fail', async () => { - server.use( - http.get('https://flags.vercel.com/v1/datafile', () => { - return new HttpResponse(null, { status: 500 }); - }), - ); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - - await expect(dataSource.getDatafile()).rejects.toThrow( - 'No flag definitions available', - ); - - await dataSource.shutdown(); - }); - - it('should return cached data when stream is connected', async () => { - const streamDefinitions = { - projectId: 'stream', - definitions: { flag: true }, - }; - - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - return new HttpResponse( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ - type: 'datafile', - data: streamDefinitions, - })}\n`, - ), - ); - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - - // First read via initialize/read to establish stream connection - await dataSource.read(); - - // getDatafile should return cached stream data - const result = await dataSource.getDatafile(); - - expect(result.projectId).toBe('stream'); - expect(result.metrics.source).toBe('in-memory'); - expect(result.metrics.cacheStatus).toBe('HIT'); - expect(result.metrics.connectionState).toBe('connected'); - - await dataSource.shutdown(); - }); - - it('should use getDataForBuildStep when in build step', async () => { - process.env.CI = '1'; - - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ sdkKey: 'vf_test_key' }); - const result = await dataSource.getDatafile(); - - expect(result.projectId).toBe('bundled'); - expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('MISS'); - expect(result.metrics.connectionState).toBe('disconnected'); - - await dataSource.shutdown(); - }); - - it('should return cached data on repeated calls', async () => { - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: { version: 1 }, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - stream: false, - polling: false, - }); - - const result1 = await dataSource.getDatafile(); - expect(result1.definitions).toEqual({ version: 1 }); - expect(result1.metrics.cacheStatus).toBe('MISS'); - - // Second call should return cached data - const result2 = await dataSource.getDatafile(); - expect(result2.definitions).toEqual({ version: 1 }); - expect(result2.metrics.cacheStatus).toBe('STALE'); - - await dataSource.shutdown(); - }); - }); - - describe('buildStep option', () => { - it('should not load bundled definitions eagerly at construction time', async () => { - // bundled definitions are loaded lazily, not at construction time - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - buildStep: false, - stream: false, - polling: false, - }); - - expect(readBundledDefinitions).not.toHaveBeenCalled(); - - await dataSource.shutdown(); - }); - - it('should skip network when buildStep: true', async () => { - let streamRequested = false; - let pollRequested = false; - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - streamRequested = true; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - http.get('https://flags.vercel.com/v1/datafile', () => { - pollRequested = true; - return HttpResponse.json({ - projectId: 'polled', - definitions: {}, - environment: 'production', - }); - }), - ); - - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - buildStep: true, // Force build step behavior - stream: true, // Would normally enable streaming - polling: true, // Would normally enable polling - }); - - const result = await dataSource.read(); - - // Should use bundled definitions, not network - expect(result.projectId).toBe('bundled'); - expect(streamRequested).toBe(false); - expect(pollRequested).toBe(false); - - await dataSource.shutdown(); - }); - - it('should use datafile over bundled in build step', async () => { - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const providedDatafile: DatafileInput = { - projectId: 'provided', - definitions: {}, - environment: 'production', - }; - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - buildStep: true, - datafile: providedDatafile, - }); - - const result = await dataSource.read(); - - // Should prefer provided datafile over bundled - expect(result.projectId).toBe('provided'); - - await dataSource.shutdown(); - }); - - it('should auto-detect build step when CI=1', async () => { - process.env.CI = '1'; - - let streamRequested = false; - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - streamRequested = true; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - // buildStep not specified - should auto-detect from CI=1 - }); - - const result = await dataSource.read(); - - // Should use bundled (build step detected), not stream - expect(result.projectId).toBe('bundled'); - expect(streamRequested).toBe(false); - - await dataSource.shutdown(); - }); - - it('should auto-detect build step when NEXT_PHASE=phase-production-build', async () => { - process.env.NEXT_PHASE = 'phase-production-build'; - - let streamRequested = false; - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - streamRequested = true; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const bundledDefinitions: BundledDefinitions = { - projectId: 'bundled', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - }; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: bundledDefinitions, - state: 'ok', - }); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - // buildStep not specified - should auto-detect from NEXT_PHASE - }); - - const result = await dataSource.read(); - - // Should use bundled (build step detected), not stream - expect(result.projectId).toBe('bundled'); - expect(streamRequested).toBe(false); - - await dataSource.shutdown(); - }); - - it('should override auto-detection with buildStep: false', async () => { - process.env.CI = '1'; // Would normally trigger build step - - server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'stream', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - buildStep: false, // Explicitly override CI detection - }); - - const result = await dataSource.read(); - - // Should use stream (buildStep: false overrides CI detection) - expect(result.projectId).toBe('stream'); - - await dataSource.shutdown(); - }); - }); - - describe('configUpdatedAt guard (never overwrite newer data with older)', () => { - it('should not overwrite newer in-memory data with older stream message', async () => { - const newerDefinitions = { - projectId: 'test', - definitions: { version: 'newer' }, - environment: 'production', - configUpdatedAt: 2000, - }; - - const olderDefinitions = { - projectId: 'test', - definitions: { version: 'older' }, - environment: 'production', - configUpdatedAt: 1000, - }; - - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - return new HttpResponse( - new ReadableStream({ - async start(controller) { - // Send newer data first, then older data - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: newerDefinitions })}\n`, - ), - ); - await new Promise((r) => setTimeout(r, 50)); - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: olderDefinitions })}\n`, - ), - ); - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - polling: false, - }); - - // First read gets the newer data - const result1 = await dataSource.read(); - expect(result1.definitions).toEqual({ version: 'newer' }); - - // Wait for the older message to arrive - await new Promise((r) => setTimeout(r, 100)); - - // Should still have newer data (older message was rejected) - const result2 = await dataSource.read(); - expect(result2.definitions).toEqual({ version: 'newer' }); - - await dataSource.shutdown(); - }); - - it('should not overwrite newer in-memory data with older stream message', async () => { - const newerDefinitions = { - projectId: 'test', - definitions: { version: 'newer' }, - environment: 'production', - configUpdatedAt: 2000, - }; - - const olderDefinitions = { - projectId: 'test', - definitions: { version: 'older' }, - environment: 'production', - configUpdatedAt: 1000, - }; - - // Stream delivers newer data first, then older data - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - return new HttpResponse( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: newerDefinitions })}\n`, - ), - ); - // Then send older data - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: olderDefinitions })}\n`, - ), - ); - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - stream: true, - polling: false, - }); - - // Read gets newer data from stream - const result1 = await dataSource.read(); - expect(result1.definitions).toEqual({ version: 'newer' }); - - // Older stream message should have been rejected - const result2 = await dataSource.read(); - expect(result2.definitions).toEqual({ version: 'newer' }); - - await dataSource.shutdown(); - }); - - it('should accept stream data with equal configUpdatedAt', async () => { - const data1 = { - projectId: 'test', - definitions: { version: 'first' }, - environment: 'production', - configUpdatedAt: 1000, - }; - - const data2 = { - projectId: 'test', - definitions: { version: 'second' }, - environment: 'production', - configUpdatedAt: 1000, // Same configUpdatedAt - }; - - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - return new HttpResponse( - new ReadableStream({ - async start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: data1 })}\n`, - ), - ); - await new Promise((r) => setTimeout(r, 50)); - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: data2 })}\n`, - ), - ); - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - polling: false, - }); - - await dataSource.read(); - - // Wait for second message - await new Promise((r) => setTimeout(r, 100)); - - // Should accept data with equal configUpdatedAt - const result = await dataSource.read(); - expect(result.definitions).toEqual({ version: 'second' }); - - await dataSource.shutdown(); - }); - - it('should accept updates when current data has no configUpdatedAt', async () => { - const providedDatafile: DatafileInput = { - projectId: 'provided', - definitions: { - testFlag: { - environments: { production: 0 }, - variants: [false, true], - }, - }, - environment: 'production', - // No configUpdatedAt - this is a plain DatafileInput - }; - - const streamData: DatafileInput = { - projectId: 'test', - definitions: { - testFlag: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - environment: 'production', - configUpdatedAt: 1000, - }; - - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - return new HttpResponse( - new ReadableStream({ - async start(controller) { - await new Promise((r) => setTimeout(r, 50)); - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: streamData })}\n`, - ), - ); - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - datafile: providedDatafile, - polling: false, - }); - - // Initialize to start background stream updates - await dataSource.initialize(); - - // Initial read returns provided datafile - const result1 = await dataSource.read(); - expect(result1.definitions).toEqual(providedDatafile.definitions); - - // Wait for stream to deliver data - await vi.waitFor( - async () => { - const result = await dataSource.read(); - expect(result.definitions).toEqual(streamData.definitions); - }, - { timeout: 2000 }, - ); - - await dataSource.shutdown(); - }); - - it('should handle configUpdatedAt as string', async () => { - const newerDefinitions = { - projectId: 'test', - definitions: { version: 'newer' }, - environment: 'production', - configUpdatedAt: '2000', - }; - - const olderDefinitions = { - projectId: 'test', - definitions: { version: 'older' }, - environment: 'production', - configUpdatedAt: '1000', - }; - - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - return new HttpResponse( - new ReadableStream({ - async start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: newerDefinitions })}\n`, - ), - ); - await new Promise((r) => setTimeout(r, 50)); - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: olderDefinitions })}\n`, - ), - ); - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - polling: false, - }); - - const result1 = await dataSource.read(); - expect(result1.definitions).toEqual({ version: 'newer' }); - - // Wait for the older message to arrive - await new Promise((r) => setTimeout(r, 100)); - - // Should still have newer data (older message was rejected) - const result2 = await dataSource.read(); - expect(result2.definitions).toEqual({ version: 'newer' }); - - await dataSource.shutdown(); - }); - - it('should accept updates when configUpdatedAt is a non-numeric string', async () => { - const currentData = { - projectId: 'test', - definitions: { version: 'first' }, - environment: 'production', - configUpdatedAt: 'not-a-number', - }; - - const newData = { - projectId: 'test', - definitions: { version: 'second' }, - environment: 'production', - configUpdatedAt: 1000, - }; - - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - return new HttpResponse( - new ReadableStream({ - async start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: currentData })}\n`, - ), - ); - await new Promise((r) => setTimeout(r, 50)); - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: newData })}\n`, - ), - ); - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - polling: false, - }); - - await dataSource.read(); - - // Wait for second message - await new Promise((r) => setTimeout(r, 100)); - - // Should accept update since current configUpdatedAt is unparseable - const result = await dataSource.read(); - expect(result.definitions).toEqual({ version: 'second' }); - - await dataSource.shutdown(); - }); - - it('should not overwrite newer in-memory data via getDatafile', async () => { - const newerDefinitions = { - projectId: 'test', - definitions: { version: 'newer' }, - environment: 'production', - configUpdatedAt: 2000, - }; - - const olderDefinitions = { - projectId: 'test', - definitions: { version: 'older' }, - environment: 'production', - configUpdatedAt: 1000, - }; - - // Stream delivers newer data first - server.use( - http.get('https://flags.vercel.com/v1/stream', ({ request }) => { - return new HttpResponse( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ type: 'datafile', data: newerDefinitions })}\n`, - ), - ); - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - - const dataSource = new Controller({ - sdkKey: 'vf_test_key', - polling: false, - }); - - // Establish stream connection and get newer data - await dataSource.read(); - - // Now change the datafile endpoint to return older data - server.use( - http.get('https://flags.vercel.com/v1/datafile', () => { - return HttpResponse.json(olderDefinitions); - }), - ); - - // getDatafile when stream is connected returns cache, so we need to - // verify via read() that the data wasn't overwritten - const result = await dataSource.read(); - expect(result.definitions).toEqual({ version: 'newer' }); - - await dataSource.shutdown(); - }); - }); -}); diff --git a/packages/vercel-flags-core/src/create-raw-client.test.ts b/packages/vercel-flags-core/src/create-raw-client.test.ts deleted file mode 100644 index 28cf2013..00000000 --- a/packages/vercel-flags-core/src/create-raw-client.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { controllerInstanceMap } from './controller-instance-map'; -import { createCreateRawClient } from './create-raw-client'; -import type { BundledDefinitions, ControllerInterface } from './types'; - -function createMockController( - overrides?: Partial, -): ControllerInterface { - return { - read: vi.fn().mockResolvedValue({ - projectId: 'test-project', - definitions: {}, - segments: {}, - environment: 'production', - metrics: { - readMs: 0, - source: 'in-memory', - cacheStatus: 'HIT', - }, - }), - getDatafile: vi.fn().mockResolvedValue({ - projectId: 'test-project', - definitions: {}, - segments: {}, - environment: 'production', - metrics: { - readMs: 0, - source: 'in-memory', - cacheStatus: 'HIT', - }, - }), - initialize: vi.fn().mockResolvedValue(undefined), - shutdown: vi.fn().mockResolvedValue(undefined), - ...overrides, - }; -} - -function createMockFns() { - return { - initialize: vi.fn().mockResolvedValue(undefined), - shutdown: vi.fn().mockResolvedValue(undefined), - getFallbackDatafile: vi.fn().mockResolvedValue({ - projectId: 'test', - definitions: {}, - environment: 'production', - configUpdatedAt: 1, - digest: 'a', - revision: 1, - } satisfies BundledDefinitions), - evaluate: vi.fn().mockResolvedValue({ value: true, reason: 'static' }), - getDatafile: vi.fn().mockResolvedValue({ - projectId: 'test', - definitions: {}, - segments: {}, - environment: 'production', - metrics: { - readMs: 0, - source: 'in-memory', - cacheStatus: 'HIT', - }, - }), - }; -} - -describe('createCreateRawClient', () => { - beforeEach(() => { - controllerInstanceMap.clear(); - }); - - afterEach(() => { - controllerInstanceMap.clear(); - }); - - describe('client creation', () => { - it('should add controller to controllerInstanceMap on creation', () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - expect(controllerInstanceMap.size).toBe(0); - - createRawClient({ controller }); - - expect(controllerInstanceMap.size).toBe(1); - }); - - it('should store the correct controller in controllerInstanceMap', () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const initialSize = controllerInstanceMap.size; - createRawClient({ controller }); - - // The controller should be stored in the map - expect(controllerInstanceMap.size).toBe(initialSize + 1); - // Find the entry that was just added - const entries = Array.from(controllerInstanceMap.entries()); - const lastEntry = entries[entries.length - 1]; - expect(lastEntry?.[1].controller).toBe(controller); - }); - - it('should assign incrementing IDs to each client', () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - - const ds1 = createMockController(); - const ds2 = createMockController(); - const ds3 = createMockController(); - - const initialSize = controllerInstanceMap.size; - - createRawClient({ controller: ds1 }); - createRawClient({ controller: ds2 }); - createRawClient({ controller: ds3 }); - - expect(controllerInstanceMap.size).toBe(initialSize + 3); - // Each controller should be stored under a different key - const entries = Array.from(controllerInstanceMap.entries()).slice(-3); - expect(entries?.[0]?.[1].controller).toBe(ds1); - expect(entries?.[1]?.[1].controller).toBe(ds2); - expect(entries?.[2]?.[1].controller).toBe(ds3); - // IDs should be incrementing - expect(entries?.[1]?.[0]).toBe(entries![0]![0] + 1); - expect(entries?.[2]?.[0]).toBe(entries![1]![0] + 1); - }); - }); - - describe('initialize', () => { - it('should call fns.initialize with the client ID', async () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - await client.initialize(); - - expect(fns.initialize).toHaveBeenCalledTimes(1); - // The ID passed should be consistent - expect(fns.initialize).toHaveBeenCalledWith(expect.any(Number)); - }); - - it('should re-add controller to controllerInstanceMap if removed', async () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - - // Simulate removal from map (e.g., after shutdown) - controllerInstanceMap.clear(); - expect(controllerInstanceMap.size).toBe(0); - - await client.initialize(); - - // Should be re-added - expect(controllerInstanceMap.size).toBe(1); - }); - - it('should not duplicate if already in controllerInstanceMap', async () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - - expect(controllerInstanceMap.size).toBe(1); - - await client.initialize(); - - expect(controllerInstanceMap.size).toBe(1); - }); - - it('should deduplicate concurrent initialize() calls', async () => { - const fns = createMockFns(); - // Make initialize take some time so concurrent calls overlap - fns.initialize.mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 50)), - ); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - - await Promise.all([ - client.initialize(), - client.initialize(), - client.initialize(), - ]); - - expect(fns.initialize).toHaveBeenCalledTimes(1); - }); - - it('should deduplicate concurrent evaluate() calls that trigger initialize()', async () => { - const fns = createMockFns(); - fns.initialize.mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 50)), - ); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - - await Promise.all([ - client.evaluate('flag-a'), - client.evaluate('flag-b'), - client.evaluate('flag-c'), - ]); - - expect(fns.initialize).toHaveBeenCalledTimes(1); - expect(fns.evaluate).toHaveBeenCalledTimes(3); - }); - - it('should allow re-initialization after failure', async () => { - const fns = createMockFns(); - fns.initialize - .mockRejectedValueOnce(new Error('init failed')) - .mockResolvedValueOnce(undefined); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - - await expect(client.initialize()).rejects.toThrow('init failed'); - await client.initialize(); - - expect(fns.initialize).toHaveBeenCalledTimes(2); - }); - }); - - describe('shutdown', () => { - it('should call fns.shutdown with the client ID', async () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - await client.shutdown(); - - expect(fns.shutdown).toHaveBeenCalledTimes(1); - expect(fns.shutdown).toHaveBeenCalledWith(expect.any(Number)); - }); - - it('should remove controller from controllerInstanceMap after shutdown', async () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - - expect(controllerInstanceMap.size).toBe(1); - - await client.shutdown(); - - expect(controllerInstanceMap.size).toBe(0); - }); - }); - - describe('getFallbackDatafile', () => { - it('should call fns.getFallbackDatafile with the client ID', async () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - await client.getFallbackDatafile(); - - expect(fns.getFallbackDatafile).toHaveBeenCalledTimes(1); - expect(fns.getFallbackDatafile).toHaveBeenCalledWith(expect.any(Number)); - }); - - it('should return the fallback definitions', async () => { - const fns = createMockFns(); - const mockFallback = { - projectId: 'test-project', - definitions: {}, - environment: 'production', - configUpdatedAt: 123, - digest: 'abc', - revision: 2, - } satisfies BundledDefinitions; - fns.getFallbackDatafile.mockResolvedValue(mockFallback); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - const result = await client.getFallbackDatafile(); - - expect(result).toEqual(mockFallback); - }); - - it('should propagate errors from fns.getFallbackDatafile', async () => { - const fns = createMockFns(); - fns.getFallbackDatafile.mockRejectedValue( - new Error('Fallback not supported'), - ); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - - await expect(client.getFallbackDatafile()).rejects.toThrow( - 'Fallback not supported', - ); - }); - }); - - describe('evaluate', () => { - it('should call fns.evaluate with correct arguments', async () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - await client.evaluate('my-flag', false, { user: { id: '123' } }); - - expect(fns.evaluate).toHaveBeenCalledTimes(1); - expect(fns.evaluate).toHaveBeenCalledWith( - expect.any(Number), - 'my-flag', - false, - { user: { id: '123' } }, - ); - }); - - it('should return the evaluation result', async () => { - const fns = createMockFns(); - const expectedResult = { - value: 'variant-a', - reason: 'targeting', - outcomeType: 'value', - }; - fns.evaluate.mockResolvedValue(expectedResult); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - const result = await client.evaluate('my-flag'); - - expect(result).toEqual(expectedResult); - }); - - it('should work with generic types', async () => { - const fns = createMockFns(); - fns.evaluate.mockResolvedValue({ value: 42, reason: 'static' }); - const createRawClient = createCreateRawClient(fns); - const controller = createMockController(); - - const client = createRawClient({ controller }); - const result = await client.evaluate('numeric-flag', 0); - - expect(result.value).toBe(42); - }); - }); - - describe('multiple clients', () => { - it('should maintain independent state for each client', async () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - - const ds1 = createMockController(); - const ds2 = createMockController(); - - const initialSize = controllerInstanceMap.size; - - const client1 = createRawClient({ controller: ds1 }); - const client2 = createRawClient({ controller: ds2 }); - - expect(controllerInstanceMap.size).toBe(initialSize + 2); - - // Shutdown client1 - await client1.shutdown(); - - // client2 should still be in the map - expect(controllerInstanceMap.size).toBe(initialSize + 1); - // ds2 should still be in the map - const controllers = Array.from(controllerInstanceMap.values()).map( - (v) => v.controller, - ); - expect(controllers).toContain(ds2); - await client2.shutdown(); - }); - - it('should use correct ID for each client method call', async () => { - const fns = createMockFns(); - const createRawClient = createCreateRawClient(fns); - - const ds1 = createMockController(); - const ds2 = createMockController(); - - const client1 = createRawClient({ controller: ds1 }); - const client2 = createRawClient({ controller: ds2 }); - - await client1.evaluate('flag1'); - await client2.evaluate('flag2'); - - expect(fns.evaluate).toHaveBeenCalledTimes(2); - // First call should use client1's ID (lower) - const call1Id = fns.evaluate.mock.calls?.[0]?.[0]; - const call2Id = fns.evaluate.mock.calls?.[1]?.[0]; - expect(call1Id).toBeLessThan(call2Id); - }); - }); -}); From 5a8d298f0afc2ff11ba89d978b80db48a96f0a40 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 12:31:25 +0200 Subject: [PATCH 21/65] simplify --- .../vercel-flags-core/src/controller-fns.ts | 10 +- .../src/controller-instance-map.ts | 9 - .../src/controller/bundled-source.ts | 33 ++-- .../vercel-flags-core/src/controller/index.ts | 169 ++++++++++-------- .../src/controller/polling-source.ts | 8 +- .../src/controller/stream-source.ts | 8 +- .../src/controller/tagged-data.ts | 2 +- .../vercel-flags-core/src/controller/utils.ts | 12 -- .../src/create-raw-client.ts | 2 +- packages/vercel-flags-core/src/evaluate.ts | 5 +- packages/vercel-flags-core/src/types.ts | 2 +- packages/vercel-flags-core/src/utils.ts | 24 --- 12 files changed, 131 insertions(+), 153 deletions(-) delete mode 100644 packages/vercel-flags-core/src/controller-instance-map.ts delete mode 100644 packages/vercel-flags-core/src/controller/utils.ts delete mode 100644 packages/vercel-flags-core/src/utils.ts diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 4123b4ec..8387119b 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -1,14 +1,22 @@ -import { controllerInstanceMap } from './controller-instance-map'; import { evaluate as evalFlag } from './evaluate'; import { internalReportValue } from './lib/report-value'; import type { BundledDefinitions, + ControllerInterface, Datafile, EvaluationResult, Packed, } from './types'; import { ErrorCode, ResolutionReason } from './types'; +export type ControllerInstance = { + controller: ControllerInterface; + initialized: boolean; + initPromise: Promise | null; +}; + +export const controllerInstanceMap = new Map(); + export function initialize(id: number): Promise { return controllerInstanceMap.get(id)!.controller.initialize(); } diff --git a/packages/vercel-flags-core/src/controller-instance-map.ts b/packages/vercel-flags-core/src/controller-instance-map.ts deleted file mode 100644 index 245a6948..00000000 --- a/packages/vercel-flags-core/src/controller-instance-map.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ControllerInterface } from './types'; - -export type ControllerInstance = { - controller: ControllerInterface; - initialized: boolean; - initPromise: Promise | null; -}; - -export const controllerInstanceMap = new Map(); diff --git a/packages/vercel-flags-core/src/controller/bundled-source.ts b/packages/vercel-flags-core/src/controller/bundled-source.ts index b2c4adb8..066c64db 100644 --- a/packages/vercel-flags-core/src/controller/bundled-source.ts +++ b/packages/vercel-flags-core/src/controller/bundled-source.ts @@ -1,19 +1,16 @@ import { FallbackEntryNotFoundError, FallbackNotFoundError } from '../errors'; -import type { BundledDefinitions, BundledDefinitionsResult } from '../types'; +import type { + BundledDefinitions, + BundledDefinitionsResult, + DatafileInput, +} from '../types'; import type { readBundledDefinitions } from '../utils/read-bundled-definitions'; -import type { TaggedData } from './tagged-data'; -import { tagData } from './tagged-data'; -import { TypedEmitter } from './typed-emitter'; - -export type BundledSourceEvents = { - data: (data: TaggedData) => void; -}; /** * Manages loading of bundled flag definitions. - * Wraps readBundledDefinitions() and emits typed events. + * Wraps readBundledDefinitions() with caching. */ -export class BundledSource extends TypedEmitter { +export class BundledSource { private promise: Promise | undefined; private options: { sdkKey: string; @@ -24,22 +21,18 @@ export class BundledSource extends TypedEmitter { sdkKey: string; readBundledDefinitions: typeof readBundledDefinitions; }) { - super(); this.options = options; } /** - * Load bundled definitions and return as TaggedData. - * Emits 'data' on success. + * Load bundled definitions. * Throws if bundled definitions are not available. */ - async load(): Promise { + async load(): Promise { const result = await this.getResult(); if (result.state === 'ok' && result.definitions) { - const tagged = tagData(result.definitions, 'bundled'); - this.emit('data', tagged); - return tagged; + return result.definitions; } throw new Error( @@ -73,12 +66,10 @@ export class BundledSource extends TypedEmitter { /** * Check if bundled definitions loaded successfully (without throwing). */ - async tryLoad(): Promise { + async tryLoad(): Promise { const result = await this.getResult(); if (result.state === 'ok' && result.definitions) { - const tagged = tagData(result.definitions, 'bundled'); - this.emit('data', tagged); - return tagged; + return result.definitions; } return undefined; } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index a8428961..9bcfd6ca 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -17,13 +17,29 @@ import { import { PollingSource } from './polling-source'; import { StreamSource } from './stream-source'; import { originToMetricsSource, type TaggedData, tagData } from './tagged-data'; -import { parseConfigUpdatedAt } from './utils'; export { BundledSource } from './bundled-source'; export type { ControllerOptions } from './normalized-options'; export { PollingSource } from './polling-source'; export { StreamSource } from './stream-source'; +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Parses a configUpdatedAt value (number or string) into a numeric timestamp. + * Returns undefined if the value is missing or cannot be parsed. + */ +function parseConfigUpdatedAt(value: unknown): number | undefined { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isNaN(parsed) ? undefined : parsed; + } + return undefined; +} + // --------------------------------------------------------------------------- // Internal types // --------------------------------------------------------------------------- @@ -134,10 +150,10 @@ export class Controller implements ControllerInterface { // --------------------------------------------------------------------------- private wireSourceEvents(): void { - // Stream events + // Stream events — tag on receipt this.streamSource.on('data', (data) => { if (this.isNewerData(data)) { - this.data = data; + this.data = tagData(data, 'stream'); } }); @@ -147,10 +163,10 @@ export class Controller implements ControllerInterface { } }); - // Polling events + // Polling events — tag on receipt this.pollingSource.on('data', (data) => { if (this.isNewerData(data)) { - this.data = data; + this.data = tagData(data, 'poll'); } }); @@ -233,40 +249,7 @@ export class Controller implements ControllerInterface { } // Fallback chain: datafile → bundled → one-time fetch (offline only) - this.transition('initializing:fallback'); - - if (this.data) { - this.transition('degraded'); - return; - } - - const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - this.data = bundled; - this.transition('degraded'); - return; - } - - // Last resort: one-time fetch (only when no stream/poll configured) - if (!this.options.stream.enabled && !this.options.polling.enabled) { - try { - const fetched = await fetchDatafile({ - host: this.options.host, - sdkKey: this.options.sdkKey, - fetch: this.options.fetch, - }); - this.data = tagData(fetched, 'fetched'); - this.transition('degraded'); - return; - } catch { - // fetch failed — fall through to throw - } - } - - throw new Error( - '@vercel/flags-core: No flag definitions available. ' + - 'Bundled definitions not found.', - ); + await this.initializeFromFallbacks(); } /** @@ -274,21 +257,11 @@ export class Controller implements ControllerInterface { */ async read(): Promise { const startTime = Date.now(); - const cachedData = this.data; - const cacheHadDefinitions = cachedData !== undefined; + const cacheHadDefinitions = this.data !== undefined; const isFirstRead = this.isFirstGetData; this.isFirstGetData = false; - let result: TaggedData; - let cacheStatus: Metrics['cacheStatus']; - - if (this.options.buildStep) { - [result, cacheStatus] = await this.getDataForBuildStep(); - } else if (cachedData) { - [result, cacheStatus] = this.getDataFromCache(cachedData); - } else { - [result, cacheStatus] = await this.getDataWithFallbacks(); - } + const [result, cacheStatus] = await this.resolveData(); const readMs = Date.now() - startTime; const source = originToMetricsSource(result._origin); @@ -329,22 +302,19 @@ export class Controller implements ControllerInterface { const startTime = Date.now(); let result: TaggedData; - let source: Metrics['source']; let cacheStatus: Metrics['cacheStatus']; if (this.options.buildStep) { - [result, cacheStatus] = await this.getDataForBuildStep(); - source = originToMetricsSource(result._origin); + [result, cacheStatus] = await this.resolveDataForBuildStep(); } else if (this.data) { - [result, cacheStatus] = this.getDataFromCache(); - source = originToMetricsSource(result._origin); + cacheStatus = this.isConnected ? 'HIT' : 'STALE'; + result = this.data; } else { // No in-memory data — try bundled, then one-time fetch const bundled = await this.bundledSource.tryLoad(); if (bundled) { - this.data = bundled; - result = bundled; - source = 'embedded'; + this.data = tagData(bundled, 'bundled'); + result = this.data; cacheStatus = 'MISS'; } else { // One-time fetch as last resort @@ -356,7 +326,6 @@ export class Controller implements ControllerInterface { }); this.data = tagData(fetched, 'fetched'); result = this.data; - source = 'remote'; cacheStatus = 'MISS'; } catch { throw new Error( @@ -367,6 +336,8 @@ export class Controller implements ControllerInterface { } } + const source = originToMetricsSource(result._origin); + return Object.assign(result, { metrics: { readMs: Date.now() - startTime, @@ -387,6 +358,31 @@ export class Controller implements ControllerInterface { return this.bundledSource.getRaw(); } + // --------------------------------------------------------------------------- + // Data resolution (shared by read() and getDatafile()) + // --------------------------------------------------------------------------- + + /** + * Resolves the current data, using the appropriate strategy for the + * current mode. Returns tagged data and cache status. + * + * Build step: cached → bundled (no network) + * Runtime with cache: return cached data + * Runtime without cache: stream/poll → datafile → bundled → fetch → throw + */ + private async resolveData(): Promise<[TaggedData, Metrics['cacheStatus']]> { + if (this.options.buildStep) { + return this.resolveDataForBuildStep(); + } + + if (this.data) { + const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; + return [this.data, cacheStatus]; + } + + return this.resolveDataWithFallbacks(); + } + // --------------------------------------------------------------------------- // Stream initialization // --------------------------------------------------------------------------- @@ -535,7 +531,7 @@ export class Controller implements ControllerInterface { * Concurrent callers share a single load promise. The first caller to * populate `this.data` gets cacheStatus MISS; subsequent callers get HIT. */ - private async getDataForBuildStep(): Promise< + private async resolveDataForBuildStep(): Promise< [TaggedData, Metrics['cacheStatus']] > { if (this.data) { @@ -560,7 +556,7 @@ export class Controller implements ControllerInterface { */ private async loadBuildData(): Promise { const bundled = await this.bundledSource.tryLoad(); - if (bundled) return bundled; + if (bundled) return tagData(bundled, 'bundled'); throw new Error( '@vercel/flags-core: No flag definitions available during build. ' + @@ -569,18 +565,47 @@ export class Controller implements ControllerInterface { } // --------------------------------------------------------------------------- - // Runtime helpers + // Fallback helpers // --------------------------------------------------------------------------- /** - * Returns data from the in-memory cache. + * Shared fallback chain used by both initialize() and resolveData(). */ - private getDataFromCache( - cachedData?: TaggedData, - ): [TaggedData, Metrics['cacheStatus']] { - const data = cachedData ?? this.data!; - const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; - return [data, cacheStatus]; + private async initializeFromFallbacks(): Promise { + this.transition('initializing:fallback'); + + if (this.data) { + this.transition('degraded'); + return; + } + + const bundled = await this.bundledSource.tryLoad(); + if (bundled) { + this.data = tagData(bundled, 'bundled'); + this.transition('degraded'); + return; + } + + // Last resort: one-time fetch (only when no stream/poll configured) + if (!this.options.stream.enabled && !this.options.polling.enabled) { + try { + const fetched = await fetchDatafile({ + host: this.options.host, + sdkKey: this.options.sdkKey, + fetch: this.options.fetch, + }); + this.data = tagData(fetched, 'fetched'); + this.transition('degraded'); + return; + } catch { + // fetch failed — fall through to throw + } + } + + throw new Error( + '@vercel/flags-core: No flag definitions available. ' + + 'Bundled definitions not found.', + ); } /** @@ -589,7 +614,7 @@ export class Controller implements ControllerInterface { * Polling mode: poll → datafile → bundled. * Offline mode: datafile → bundled → one-time fetch. */ - private async getDataWithFallbacks(): Promise< + private async resolveDataWithFallbacks(): Promise< [TaggedData, Metrics['cacheStatus']] > { // Try the configured primary source @@ -621,7 +646,7 @@ export class Controller implements ControllerInterface { const bundled = await this.bundledSource.tryLoad(); if (bundled) { console.warn('@vercel/flags-core: Using bundled definitions as fallback'); - this.data = bundled; + this.data = tagData(bundled, 'bundled'); this.transition('degraded'); return [this.data, 'STALE']; } diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index e4c10820..34e47c9b 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,6 +1,5 @@ +import type { DatafileInput } from '../types'; import { fetchDatafile } from './fetch-datafile'; -import type { TaggedData } from './tagged-data'; -import { tagData } from './tagged-data'; import { TypedEmitter } from './typed-emitter'; export type PollingSourceConfig = { @@ -13,7 +12,7 @@ export type PollingSourceConfig = { }; export type PollingSourceEvents = { - data: (data: TaggedData) => void; + data: (data: DatafileInput) => void; error: (error: Error) => void; }; @@ -40,8 +39,7 @@ export class PollingSource extends TypedEmitter { try { const data = await fetchDatafile(this.config); - const tagged = tagData(data, 'poll'); - this.emit('data', tagged); + this.emit('data', data); } catch (error) { const err = error instanceof Error ? error : new Error('Unknown poll error'); diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index 0acb5b7d..b3b1458c 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,6 +1,5 @@ +import type { DatafileInput } from '../types'; import { connectStream } from './stream-connection'; -import type { TaggedData } from './tagged-data'; -import { tagData } from './tagged-data'; import { TypedEmitter } from './typed-emitter'; export type StreamSourceConfig = { @@ -10,7 +9,7 @@ export type StreamSourceConfig = { }; export type StreamSourceEvents = { - data: (data: TaggedData) => void; + data: (data: DatafileInput) => void; connected: () => void; disconnected: () => void; }; @@ -49,8 +48,7 @@ export class StreamSource extends TypedEmitter { }, { onMessage: (newData) => { - const tagged = tagData(newData, 'stream'); - this.emit('data', tagged); + this.emit('data', newData); this.emit('connected'); }, onDisconnect: () => { diff --git a/packages/vercel-flags-core/src/controller/tagged-data.ts b/packages/vercel-flags-core/src/controller/tagged-data.ts index a04c9d9a..2d549c03 100644 --- a/packages/vercel-flags-core/src/controller/tagged-data.ts +++ b/packages/vercel-flags-core/src/controller/tagged-data.ts @@ -18,7 +18,7 @@ export type TaggedData = DatafileInput & { * Tags a DatafileInput with its origin. */ export function tagData(data: DatafileInput, origin: DataOrigin): TaggedData { - return Object.assign(data, { _origin: origin }); + return { ...data, _origin: origin }; } /** diff --git a/packages/vercel-flags-core/src/controller/utils.ts b/packages/vercel-flags-core/src/controller/utils.ts deleted file mode 100644 index 6db03f41..00000000 --- a/packages/vercel-flags-core/src/controller/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Parses a configUpdatedAt value (number or string) into a numeric timestamp. - * Returns undefined if the value is missing or cannot be parsed. - */ -export function parseConfigUpdatedAt(value: unknown): number | undefined { - if (typeof value === 'number') return value; - if (typeof value === 'string') { - const parsed = Number(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; -} diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index 3921f478..be81366e 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -8,7 +8,7 @@ import type { import { type ControllerInstance, controllerInstanceMap, -} from './controller-instance-map'; +} from './controller-fns'; import type { BundledDefinitions, ControllerInterface, diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 6afa951f..645491dc 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -7,10 +7,13 @@ import { Packed, ResolutionReason, } from './types'; -import { exhaustivenessCheck } from './utils'; type PathArray = (string | number)[]; +function exhaustivenessCheck(_: never): never { + throw new Error('Exhaustiveness check failed'); +} + function getProperty(obj: any, pathArray: PathArray): any { return pathArray.reduce((acc: any, key: string | number) => { if (acc && key in acc) { diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 49511dcf..853f8996 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -1,4 +1,4 @@ -import type { ControllerInstance } from './controller-instance-map'; +import type { ControllerInstance } from './controller-fns'; /** * Options for stream connection behavior diff --git a/packages/vercel-flags-core/src/utils.ts b/packages/vercel-flags-core/src/utils.ts deleted file mode 100644 index 7db62cec..00000000 --- a/packages/vercel-flags-core/src/utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * This function is used to check for exhaustiveness in switch statements. - * - * @param _ - The value to check. - * - * @example - * Given `type Union = 'a' | 'b' | 'c'`, the following code will not compile: - * ```ts - * switch (union) { - * case 'a': - * return 'a'; - * case 'b': - * return 'b'; - * default: - * exhaustivenessCheck(union); // This will throw an error - * } - * ``` - * This is because `value` has been narrowed to `'c'` by the `default` arm, - * which is not assignable to `never`. If we covered the `'c'` case, the type - * would narrow to `never`, which is assignable to `never` and would not cause an error. - */ -export function exhaustivenessCheck(_: never): never { - throw new Error('Exhaustiveness check failed'); -} From 976b864c241fdb6e99103647ae58fe7ad30c70ce Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 13:27:33 +0200 Subject: [PATCH 22/65] Update CLAUDE.md --- packages/vercel-flags-core/CLAUDE.md | 56 ++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index d1939b22..c9d334f0 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -13,23 +13,47 @@ src/ ├── types.ts # Type definitions ├── errors.ts # Error classes ├── evaluate.ts # Core evaluation logic +├── controller-fns.ts # Controller function wrappers + instance map +├── create-raw-client.ts # Raw client factory (ID-based indirection for 'use cache') ├── controller/ # Controller (state machine) and I/O sources │ ├── index.ts # Controller class │ ├── stream-source.ts # StreamSource (wraps stream-connection) │ ├── polling-source.ts # PollingSource (wraps fetch-datafile) │ ├── bundled-source.ts # BundledSource (wraps read-bundled-definitions) │ ├── stream-connection.ts # Low-level NDJSON stream connection -│ ├── fetch-datafile.ts # HTTP datafile fetch with retry +│ ├── fetch-datafile.ts # HTTP datafile fetch │ ├── tagged-data.ts # Data origin tagging types/helpers +│ ├── normalized-options.ts # Option normalization │ └── typed-emitter.ts # Lightweight typed event emitter ├── openfeature.*.ts # OpenFeature provider ├── utils/ # Utilities │ ├── usage-tracker.ts │ ├── sdk-keys.ts │ └── read-bundled-definitions.ts -└── lib/ # Internal libraries +└── lib/ + └── report-value.ts # Flag evaluation reporting to Vercel request context ``` +## Architecture + +### Data flow + +``` +createClient(sdkKey, options) + → Controller (state machine, owns all data tagging and source coordination) + → StreamSource / PollingSource / BundledSource (emit raw DatafileInput) + → create-raw-client (ID-based indirection for 'use cache' support) + → controller-fns (lookup by ID, evaluate, report) + → FlagsClient (public API) +``` + +### Design principles + +- **Sources emit raw data** — StreamSource, PollingSource, and BundledSource return/emit raw `DatafileInput`. The Controller is solely responsible for tagging data with its origin (`tagData(data, 'stream')` etc.). +- **BundledSource is a plain class** — unlike StreamSource and PollingSource which extend TypedEmitter, BundledSource has no event listeners. The Controller calls its methods directly and uses return values. +- **Tests are black-box** — all behavioral tests go through the public API (`createClient` from `./index.default`). Mock `readBundledDefinitions` and `internalReportValue` as observable I/O. Use `fetchMock` for network assertions. +- **ID-based indirection** — `controller-fns.ts` holds a `controllerInstanceMap` (Map) so that `'use cache'` wrappers in Next.js can pass serializable IDs instead of function references. + ## Key Concepts ### FlagsClient @@ -41,6 +65,7 @@ type FlagsClient = { initialize(): Promise; shutdown(): Promise; getDatafile(): Promise; + getFallbackDatafile(): Promise; evaluate(flagKey, defaultValue?, entities?): Promise>; } ``` @@ -73,7 +98,7 @@ Behavior differs based on environment: **Build step** (CI=1, NEXT_PHASE=phase-production-build, or `buildStep: true`): 1. **Provided datafile** - Use `options.datafile` if provided 2. **Bundled definitions** - Use `@vercel/flags-definitions` -3. **Fetch** - Last resort network fetch +3. **Throw** - No network during build 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). @@ -82,6 +107,7 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu 2. **Polling** - Interval-based HTTP requests, wait up to `initTimeoutMs` 3. **Provided datafile** - Use `options.datafile` if provided 4. **Bundled definitions** - Use `@vercel/flags-definitions` +5. **One-time fetch** - Last resort (only when stream and polling are both disabled) Key behaviors: - Bundled definitions are always loaded as ultimate fallback @@ -105,8 +131,9 @@ Key behaviors: Internal compact format for flag definitions: - Variants stored as indices -- Conditions use enum values -- Entities accessed via arrays (e.g., `['user', 'id']`) +- Conditions use tuples: `[LHS, Comparator, RHS]` (e.g., `[['user', 'id'], Comparator.EQ, 'user-123']`) +- Targets shorthand: `{ user: { id: ['user-123'] } }` +- Entities accessed via path arrays (e.g., `['user', 'id']`) ## Entry Points @@ -139,7 +166,7 @@ pnpm test:integration - Uses fetch with streaming body (NDJSON format) - Reconnects with exponential backoff (base: 1s, max: 60s, max retries: 15) - Default `initTimeoutMs`: 3000ms -- 401 errors abort immediately (invalid SDK key) +- 401 errors abort immediately (invalid SDK key) — does NOT reject the init promise, so the stream timeout must fire for fallback to kick in - On disconnect: falls back to polling if enabled ### Polling @@ -150,6 +177,15 @@ pnpm test:integration - Retries with exponential backoff (base: 500ms, max 3 retries) - Stops automatically when stream reconnects +### Data Origin Tagging + +The Controller tags all data with its origin using `tagData(data, origin)` from `tagged-data.ts`. Origins map to public `metrics.source` values: +- `'stream'`, `'poll'`, `'provided'` → `'in-memory'` +- `'fetched'` → `'remote'` +- `'bundled'` → `'embedded'` + +`tagData` creates a new object (shallow spread) to avoid mutating the input. + ### Usage Tracking - Batches flag read events (max 50 events, max 5s wait) @@ -161,9 +197,13 @@ pnpm test:integration ### Client Management - Each client gets unique incrementing ID -- Stored in `clientMap` for function lookups +- Stored in `controllerInstanceMap` in `controller-fns.ts` - Supports multiple simultaneous clients -- Necessary as we can't pass function to `'use cache'` client-fns +- Necessary as we can't pass functions to `'use cache'` wrappers + +### configUpdatedAt Guard + +The Controller rejects incoming data (from stream or poll) if its `configUpdatedAt` is older than the current in-memory data. This prevents stale updates from overwriting newer data. Accepts the update if either side lacks a `configUpdatedAt`. ### Debug Mode From 218c876dd743d6ee9274d1ced03a14e54794c61f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 14:13:11 +0200 Subject: [PATCH 23/65] various fixes --- .../src/black-box-controller.test.ts | 11 ++++++---- .../vercel-flags-core/src/controller/index.ts | 22 +++++++++++++------ .../src/controller/stream-connection.ts | 4 ++++ .../src/controller/tagged-data.ts | 2 +- packages/vercel-flags-core/src/evaluate.ts | 12 +++++++--- packages/vercel-flags-core/src/types.ts | 4 ++-- .../src/utils/usage-tracker.ts | 17 ++++++++------ 7 files changed, 48 insertions(+), 24 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box-controller.test.ts b/packages/vercel-flags-core/src/black-box-controller.test.ts index e716dc68..2baf2632 100644 --- a/packages/vercel-flags-core/src/black-box-controller.test.ts +++ b/packages/vercel-flags-core/src/black-box-controller.test.ts @@ -401,6 +401,7 @@ describe('Controller (black-box)', () => { it('should update definitions when new datafile messages arrive', async () => { const datafile1 = makeBundled({ + configUpdatedAt: 1, definitions: { flagA: { environments: { production: 0 }, @@ -409,6 +410,7 @@ describe('Controller (black-box)', () => { }, }); const datafile2 = makeBundled({ + configUpdatedAt: 2, definitions: { flagA: { environments: { production: 1 }, @@ -866,11 +868,12 @@ describe('Controller (black-box)', () => { expect(result1.value).toBe(false); // variant 0 from provided expect(result1.metrics?.source).toBe('in-memory'); - // Now push stream data + // Now push stream data (with newer configUpdatedAt) stream.push({ type: 'datafile', data: makeBundled({ projectId: 'stream', + configUpdatedAt: 2, definitions: { flagA: { environments: { production: 1 }, @@ -1234,7 +1237,7 @@ describe('Controller (black-box)', () => { await client.shutdown(); }); - it('should accept stream data with equal configUpdatedAt', async () => { + it('should skip stream data with equal configUpdatedAt', async () => { vi.useRealTimers(); const data1 = makeBundled({ @@ -1279,9 +1282,9 @@ describe('Controller (black-box)', () => { stream.push({ type: 'datafile', data: data2 }); await new Promise((r) => setTimeout(r, 50)); - // Should have accepted second data (equal configUpdatedAt) + // Should have kept first data (equal configUpdatedAt is not newer) const result = await client.evaluate('flagA'); - expect(result.value).toBe(true); // variant 1 = data2 + expect(result.value).toBe(false); // variant 0 = data1 stream.close(); await client.shutdown(); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 9bcfd6ca..9d64be9a 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -157,6 +157,12 @@ export class Controller implements ControllerInterface { } }); + this.streamSource.on('connected', () => { + if (this.state === 'degraded' || this.state === 'initializing:stream') { + this.transition('streaming'); + } + }); + this.streamSource.on('disconnected', () => { if (this.state === 'streaming') { this.transition('degraded'); @@ -267,7 +273,8 @@ export class Controller implements ControllerInterface { const source = originToMetricsSource(result._origin); this.trackRead(startTime, cacheHadDefinitions, isFirstRead, source); - return Object.assign(result, { + return { + ...result, metrics: { readMs, source, @@ -277,7 +284,7 @@ export class Controller implements ControllerInterface { : ('disconnected' as const), mode: this.mode, }, - }) satisfies Datafile; + } satisfies Datafile; } /** @@ -338,7 +345,8 @@ export class Controller implements ControllerInterface { const source = originToMetricsSource(result._origin); - return Object.assign(result, { + return { + ...result, metrics: { readMs: Date.now() - startTime, source, @@ -348,7 +356,7 @@ export class Controller implements ControllerInterface { : ('disconnected' as const), mode: this.mode, }, - }) satisfies Datafile; + } satisfies Datafile; } /** @@ -499,8 +507,8 @@ export class Controller implements ControllerInterface { */ private startBackgroundUpdates(): void { if (this.options.stream.enabled) { + this.transition('initializing:stream'); void this.streamSource.start(); - this.transition('streaming'); } else if (this.options.polling.enabled) { void this.pollingSource.poll(); this.pollingSource.startInterval(); @@ -686,7 +694,7 @@ export class Controller implements ControllerInterface { * - The current data has no configUpdatedAt * - The incoming data has no configUpdatedAt * - * Skips the update only when both have configUpdatedAt and incoming is older. + * Skips the update only when both have configUpdatedAt and incoming is not newer. */ private isNewerData(incoming: DatafileInput): boolean { if (!this.data) return true; @@ -698,7 +706,7 @@ export class Controller implements ControllerInterface { return true; } - return incomingTs >= currentTs; + return incomingTs > currentTs; } // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index f1bf8ee8..faa9cfdc 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -75,7 +75,11 @@ export async function connectStream( if (!response.ok) { if (response.status === 401) { + if (!initialDataReceived) { + rejectInit!(new Error(`stream: unauthorized (401)`)); + } abortController.abort(); + break; } throw new Error(`stream was not ok: ${response.status}`); diff --git a/packages/vercel-flags-core/src/controller/tagged-data.ts b/packages/vercel-flags-core/src/controller/tagged-data.ts index 2d549c03..d86d76ab 100644 --- a/packages/vercel-flags-core/src/controller/tagged-data.ts +++ b/packages/vercel-flags-core/src/controller/tagged-data.ts @@ -18,7 +18,7 @@ export type TaggedData = DatafileInput & { * Tags a DatafileInput with its origin. */ export function tagData(data: DatafileInput, origin: DataOrigin): TaggedData { - return { ...data, _origin: origin }; + return Object.assign(data, { _origin: origin }) as TaggedData; } /** diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 645491dc..19b4f346 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -10,6 +10,8 @@ import { type PathArray = (string | number)[]; +const MAX_REGEX_INPUT_LENGTH = 10_000; + function exhaustivenessCheck(_: never): never { throw new Error('Exhaustiveness check failed'); } @@ -57,10 +59,12 @@ function matchTargetList( targets: Packed.TargetList, params: EvaluationParams, ): boolean { - for (const [kind, attributes] of Object.entries(targets)) { - for (const [attribute, values] of Object.entries(attributes)) { + for (const kind in targets) { + const attributes = targets[kind]!; + for (const attribute in attributes) { const entity = access([kind, attribute], params); - if (isString(entity) && values.includes(entity)) return true; + if (isString(entity) && attributes[attribute]!.includes(entity)) + return true; } } return false; @@ -214,6 +218,7 @@ function matchConditions( case Comparator.REGEX: if ( isString(lhs) && + lhs.length <= MAX_REGEX_INPUT_LENGTH && typeof rhs === 'object' && !Array.isArray(rhs) && rhs?.type === 'regex' @@ -225,6 +230,7 @@ function matchConditions( case Comparator.NOT_REGEX: if ( isString(lhs) && + lhs.length <= MAX_REGEX_INPUT_LENGTH && typeof rhs === 'object' && !Array.isArray(rhs) && rhs?.type === 'regex' diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 853f8996..ce189043 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -46,7 +46,7 @@ export type BundledDefinitions = DatafileInput & { configUpdatedAt: number; /** hash of the data */ digest: string; - /** version number of the dat */ + /** version number of the data */ revision: number; }; @@ -287,7 +287,7 @@ export enum OutcomeType { * - ends with (endsWith) * - does not end with (!endsWith) * - exists (ex) - * - deos not exist (!ex) + * - does not exist (!ex) * - is greater than (gt) * - is greater than or equal to (gte) * - is lower than (lt) diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index 8b345cff..5927b560 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -39,10 +39,6 @@ interface EventBatcher { const MAX_BATCH_SIZE = 50; const MAX_BATCH_WAIT_MS = 5000; -// WeakSet to track request contexts that have already been recorded -// Using WeakSet allows the context objects to be garbage collected -const trackedRequests = new WeakSet(); - interface RequestContext { ctx: object | undefined; headers: Record | undefined; @@ -101,6 +97,7 @@ export interface TrackReadOptions { */ export class UsageTracker { private options: UsageTrackerOptions; + private trackedRequests = new WeakSet(); private batcher: EventBatcher = { events: [], resolveWait: null, @@ -129,8 +126,8 @@ export class UsageTracker { // Skip if we've already tracked this request if (ctx) { - if (trackedRequests.has(ctx)) return; - trackedRequests.add(ctx); + if (this.trackedRequests.has(ctx)) return; + this.trackedRequests.add(ctx); } const event: FlagsConfigReadEvent = { @@ -197,7 +194,11 @@ export class UsageTracker { // Use waitUntil to keep the function alive until flush completes // If `waitUntil` is not available this will be a no-op and leave // a floating promise that will be completed in the background - waitUntil(pending); + try { + waitUntil(pending); + } catch { + // waitUntil is best-effort; falling through leaves a floating promise + } this.batcher.pending = pending; } @@ -239,9 +240,11 @@ export class UsageTracker { '@vercel/flags-core: Failed to send events:', response.statusText, ); + this.batcher.events.unshift(...eventsToSend); } } catch (error) { debugLog('@vercel/flags-core: Error sending events:', error); + this.batcher.events.unshift(...eventsToSend); } } } From bc390a9aa388d24f42238cb03d8f61a663e275c1 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 14:20:31 +0200 Subject: [PATCH 24/65] add tests --- packages/vercel-flags-core/CLAUDE.md | 19 ++- .../src/black-box-controller.test.ts | 34 ++++ .../vercel-flags-core/src/evaluate.test.ts | 107 ++++++++++++ .../src/utils/usage-tracker.test.ts | 154 ++++++++++++++++++ 4 files changed, 308 insertions(+), 6 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index c9d334f0..8bcfd0c4 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -166,8 +166,9 @@ pnpm test:integration - Uses fetch with streaming body (NDJSON format) - Reconnects with exponential backoff (base: 1s, max: 60s, max retries: 15) - Default `initTimeoutMs`: 3000ms -- 401 errors abort immediately (invalid SDK key) — does NOT reject the init promise, so the stream timeout must fire for fallback to kick in -- On disconnect: falls back to polling if enabled +- 401 errors abort immediately (invalid SDK key) and reject the init promise, so fallback kicks in without waiting for the stream timeout +- On disconnect: state transitions to `'degraded'`, falls back to polling if enabled +- On reconnect: Controller listens for `'connected'` event and transitions back to `'streaming'` ### Polling @@ -184,15 +185,16 @@ The Controller tags all data with its origin using `tagData(data, origin)` from - `'fetched'` → `'remote'` - `'bundled'` → `'embedded'` -`tagData` creates a new object (shallow spread) to avoid mutating the input. +`tagData` mutates the input object in-place via `Object.assign` (callers always pass freshly-created data). ### Usage Tracking - Batches flag read events (max 50 events, max 5s wait) - Sends to `flags.vercel.com/v1/ingest` -- At runtime: deduplicates by request context (WeakSet in UsageTracker) +- At runtime: deduplicates by request context (per-instance WeakSet in UsageTracker) - During builds: deduplicates all reads to a single event (buildReadTracked flag in Controller), since there is no request context available -- Uses `waitUntil()` from `@vercel/functions` +- Uses `waitUntil()` from `@vercel/functions` (wrapped in try/catch for resilience) +- On flush failure, events are re-queued for retry ### Client Management @@ -203,7 +205,12 @@ The Controller tags all data with its origin using `tagData(data, origin)` from ### configUpdatedAt Guard -The Controller rejects incoming data (from stream or poll) if its `configUpdatedAt` is older than the current in-memory data. This prevents stale updates from overwriting newer data. Accepts the update if either side lacks a `configUpdatedAt`. +The Controller rejects incoming data (from stream or poll) if its `configUpdatedAt` is older than or equal to the current in-memory data. This prevents stale updates from overwriting newer data. Accepts the update if either side lacks a `configUpdatedAt`. + +### Evaluation Safety + +- Regex comparators (`REGEX`, `NOT_REGEX`) limit input string length to 10,000 characters to prevent ReDoS +- `read()` and `getDatafile()` return new objects with spread (never mutate `this.data`) ### Debug Mode diff --git a/packages/vercel-flags-core/src/black-box-controller.test.ts b/packages/vercel-flags-core/src/black-box-controller.test.ts index 2baf2632..5e017bec 100644 --- a/packages/vercel-flags-core/src/black-box-controller.test.ts +++ b/packages/vercel-flags-core/src/black-box-controller.test.ts @@ -517,6 +517,40 @@ describe('Controller (black-box)', () => { errorSpy.mockRestore(); }); + it('should fast-fail on 401 without waiting for stream timeout', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const evalPromise = client.evaluate('flagA'); + + // Only advance a tiny amount — well under the 3s stream timeout. + // If the 401 fast-fail works, evaluate resolves without the full timeout. + await vi.advanceTimersByTimeAsync(100); + + const result = await evalPromise; + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('embedded'); + + errorSpy.mockRestore(); + }); + it('should use custom initTimeoutMs value', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', diff --git a/packages/vercel-flags-core/src/evaluate.test.ts b/packages/vercel-flags-core/src/evaluate.test.ts index 31cfd632..d30677ff 100644 --- a/packages/vercel-flags-core/src/evaluate.test.ts +++ b/packages/vercel-flags-core/src/evaluate.test.ts @@ -1340,6 +1340,113 @@ describe('evaluate', () => { }); }); + describe('regex input length limit', () => { + it('should return false for REGEX when input exceeds MAX_REGEX_INPUT_LENGTH', () => { + const longString = 'a'.repeat(10_001); + expect( + evaluate({ + definition: { + seed: undefined, + environments: { + production: { + rules: [ + { + conditions: [ + [ + ['user', 'id'], + Comparator.REGEX, + { type: 'regex', pattern: 'a+', flags: '' }, + ], + ], + outcome: 1, + }, + ], + fallthrough: 0, + }, + }, + variants: [false, true], + } satisfies Packed.FlagDefinition, + environment: 'production', + entities: { user: { id: longString } }, + }), + ).toEqual({ + value: false, + reason: ResolutionReason.FALLTHROUGH, + outcomeType: OutcomeType.VALUE, + }); + }); + + it('should return false for NOT_REGEX when input exceeds MAX_REGEX_INPUT_LENGTH', () => { + const longString = 'a'.repeat(10_001); + expect( + evaluate({ + definition: { + seed: undefined, + environments: { + production: { + rules: [ + { + conditions: [ + [ + ['user', 'id'], + Comparator.NOT_REGEX, + { type: 'regex', pattern: 'b+', flags: '' }, + ], + ], + outcome: 1, + }, + ], + fallthrough: 0, + }, + }, + variants: [false, true], + } satisfies Packed.FlagDefinition, + environment: 'production', + entities: { user: { id: longString } }, + }), + ).toEqual({ + value: false, + reason: ResolutionReason.FALLTHROUGH, + outcomeType: OutcomeType.VALUE, + }); + }); + + it('should still match REGEX when input is within limit', () => { + const okString = 'a'.repeat(10_000); + expect( + evaluate({ + definition: { + seed: undefined, + environments: { + production: { + rules: [ + { + conditions: [ + [ + ['user', 'id'], + Comparator.REGEX, + { type: 'regex', pattern: 'a+', flags: '' }, + ], + ], + outcome: 1, + }, + ], + fallthrough: 0, + }, + }, + variants: [false, true], + } satisfies Packed.FlagDefinition, + environment: 'production', + entities: { user: { id: okString } }, + }), + ).toEqual({ + value: true, + reason: ResolutionReason.RULE_MATCH, + outcomeType: OutcomeType.VALUE, + }); + }); + }); + describe('splits', () => { it.each<{ name: string; diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index 9c5d7e10..0f03f1ee 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -558,6 +558,160 @@ describe('UsageTracker', () => { }); }); + describe('cross-instance deduplication', () => { + it('should not deduplicate across separate UsageTracker instances', async () => { + const receivedEvents: unknown[][] = []; + + server.use( + http.post('https://example.com/v1/ingest', async ({ request }) => { + const body = (await request.json()) as unknown[]; + receivedEvents.push(body); + return HttpResponse.json({ ok: true }); + }), + ); + + // Set up a shared request context + const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); + const mockContext = { + headers: { + 'x-vercel-id': 'shared-request-id', + host: 'example.com', + }, + }; + + (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT] = { + get: () => mockContext, + }; + + const tracker1 = new UsageTracker({ + sdkKey: 'key-1', + host: 'https://example.com', + fetch, + }); + + const tracker2 = new UsageTracker({ + sdkKey: 'key-2', + host: 'https://example.com', + fetch, + }); + + // Both trackers track with the same request context + tracker1.trackRead(); + tracker2.trackRead(); + tracker1.flush(); + tracker2.flush(); + + await vi.waitFor(() => { + expect(receivedEvents.length).toBe(2); + }); + + // Each tracker should have sent its own event + expect(receivedEvents[0]).toHaveLength(1); + expect(receivedEvents[1]).toHaveLength(1); + + // Clean up + delete (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT]; + }); + }); + + describe('flush failure retry', () => { + it('should re-queue events on failed flush and send them on next flush', async () => { + let requestCount = 0; + const receivedEvents: unknown[][] = []; + + server.use( + http.post('https://example.com/v1/ingest', async ({ request }) => { + requestCount++; + if (requestCount === 1) { + // First flush fails + return new HttpResponse(null, { status: 500 }); + } + // Second flush succeeds + const body = (await request.json()) as unknown[]; + receivedEvents.push(body); + return HttpResponse.json({ ok: true }); + }), + ); + + const tracker = new UsageTracker({ + sdkKey: 'test-key', + host: 'https://example.com', + fetch, + }); + + tracker.trackRead(); + tracker.flush(); + + // Wait for the first (failing) flush to complete + await vi.waitFor(() => { + expect(requestCount).toBe(1); + }); + + // Events should have been re-queued — a new trackRead triggers + // a new schedule cycle which will include the re-queued events + tracker.trackRead(); + tracker.flush(); + + await vi.waitFor(() => { + expect(receivedEvents.length).toBe(1); + }); + + // Should contain both the re-queued event and the new one + expect(receivedEvents[0]).toHaveLength(2); + }); + + it('should re-queue events on fetch error and send them on next flush', async () => { + let requestCount = 0; + const receivedEvents: unknown[][] = []; + + server.use( + http.post('https://example.com/v1/ingest', async ({ request }) => { + requestCount++; + if (requestCount === 1) { + // First flush throws network error + return HttpResponse.error(); + } + // Second flush succeeds + const body = (await request.json()) as unknown[]; + receivedEvents.push(body); + return HttpResponse.json({ ok: true }); + }), + ); + + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const tracker = new UsageTracker({ + sdkKey: 'test-key', + host: 'https://example.com', + fetch, + }); + + tracker.trackRead(); + tracker.flush(); + + // Wait for the first (failing) flush to complete + await vi.waitFor(() => { + expect(requestCount).toBe(1); + }); + + // Events should have been re-queued — a new trackRead triggers + // a new schedule cycle which will include the re-queued events + tracker.trackRead(); + tracker.flush(); + + await vi.waitFor(() => { + expect(receivedEvents.length).toBe(1); + }); + + // Should contain both the re-queued event and the new one + expect(receivedEvents[0]).toHaveLength(2); + + consoleSpy.mockRestore(); + }); + }); + describe('batch size limit', () => { it('should trigger flush when batch size reaches 50', async () => { const receivedEvents: unknown[] = []; From b7cffa2b86c0da1b4760cc70166872ca22dad614 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 14:40:54 +0200 Subject: [PATCH 25/65] more fixes --- .../src/black-box-controller.test.ts | 22 +++++-- .../vercel-flags-core/src/controller-fns.ts | 8 +++ .../src/controller/fetch-datafile.ts | 12 ++++ .../vercel-flags-core/src/controller/index.ts | 7 ++- .../src/controller/polling-source.ts | 5 +- .../src/controller/stream-connection.test.ts | 60 ++++++++++++++----- .../src/controller/stream-connection.ts | 15 +++-- .../src/utils/usage-tracker.ts | 30 ++++++++-- 8 files changed, 130 insertions(+), 29 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box-controller.test.ts b/packages/vercel-flags-core/src/black-box-controller.test.ts index 5e017bec..bb4a36a2 100644 --- a/packages/vercel-flags-core/src/black-box-controller.test.ts +++ b/packages/vercel-flags-core/src/black-box-controller.test.ts @@ -819,7 +819,10 @@ describe('Controller (black-box)', () => { polling: { intervalMs: 100, initTimeoutMs: 5000 }, }); - const result = await client.evaluate('flagA'); + // Stream retries with backoff; advance timers so the init timeout fires + const resultPromise = client.evaluate('flagA'); + await vi.advanceTimersByTimeAsync(200); + const result = await resultPromise; expect(result.metrics?.source).toBe('embedded'); expect(pollCount).toBe(0); @@ -958,7 +961,10 @@ describe('Controller (black-box)', () => { polling: { intervalMs: 100, initTimeoutMs: 5000 }, }); - await client.initialize(); + // Stream retries with backoff; advance timers so the init timeout fires + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(5100); + await initPromise; expect(pollCount).toBe(0); @@ -1708,7 +1714,7 @@ describe('Controller (black-box)', () => { expect(internalReportValue).not.toHaveBeenCalled(); }); - it('should not call internalReportValue when result is error', async () => { + it('should call internalReportValue with error reason when flag is not found', async () => { const client = createClient(sdkKey, { fetch: fetchMock, stream: false, @@ -1719,7 +1725,15 @@ describe('Controller (black-box)', () => { await client.evaluate('nonexistent-flag', 'default'); - expect(internalReportValue).not.toHaveBeenCalled(); + expect(internalReportValue).toHaveBeenCalledWith( + 'nonexistent-flag', + 'default', + { + originProjectId: 'my-project-id', + originProvider: 'vercel', + reason: 'error', + }, + ); }); }); diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 8387119b..53d4cef2 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -62,6 +62,14 @@ export async function evaluate>( const flagDefinition = datafile.definitions[flagKey] as Packed.FlagDefinition; if (flagDefinition === undefined) { + if (datafile.projectId) { + internalReportValue(flagKey, defaultValue, { + originProjectId: datafile.projectId, + originProvider: 'vercel', + reason: ResolutionReason.ERROR, + }); + } + return { value: defaultValue, reason: ResolutionReason.ERROR, diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.ts index a5d84022..065f1571 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.ts @@ -10,6 +10,7 @@ export async function fetchDatafile(options: { host: string; sdkKey: string; fetch: typeof globalThis.fetch; + signal?: AbortSignal; }): Promise { const controller = new AbortController(); const timeoutId = setTimeout( @@ -17,6 +18,17 @@ export async function fetchDatafile(options: { DEFAULT_FETCH_TIMEOUT_MS, ); + // Abort the internal controller when the external signal fires + if (options.signal) { + if (options.signal.aborted) { + clearTimeout(timeoutId); + throw new Error('Fetch aborted'); + } + options.signal.addEventListener('abort', () => controller.abort(), { + once: true, + }); + } + try { const res = await options.fetch(`${options.host}/v1/datafile`, { headers: { diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 9d64be9a..8fea41dd 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -429,7 +429,10 @@ export class Controller implements ControllerInterface { console.warn( '@vercel/flags-core: Stream initialization timeout, falling back', ); - // Don't stop stream - let it continue trying in background + // Don't stop stream - let it continue trying in background. + // Swallow the rejection from the background stream promise to + // avoid unhandled promise rejections when it is eventually aborted. + this.streamSource.start().catch(() => {}); return false; } @@ -508,7 +511,7 @@ export class Controller implements ControllerInterface { private startBackgroundUpdates(): void { if (this.options.stream.enabled) { this.transition('initializing:stream'); - void this.streamSource.start(); + this.streamSource.start().catch(() => {}); } else if (this.options.polling.enabled) { void this.pollingSource.poll(); this.pollingSource.startInterval(); diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 34e47c9b..59c808cb 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -38,7 +38,10 @@ export class PollingSource extends TypedEmitter { if (this.abortController?.signal.aborted) return; try { - const data = await fetchDatafile(this.config); + const data = await fetchDatafile({ + ...this.config, + signal: this.abortController?.signal, + }); this.emit('data', data); } catch (error) { const err = diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index 8c7f6fc2..c5845c1b 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -430,32 +430,50 @@ describe('connectStream', () => { // but the promise resolution is handled by the timeout mechanism in // FlagNetworkDataSource.getDataWithStreamTimeout(). - it('should reject initPromise if error occurs before first datafile', async () => { + it('should retry on error before first datafile and reject when aborted', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let requestCount = 0; server.use( http.get(`${HOST}/v1/stream`, () => { + requestCount++; return new HttpResponse(null, { status: 500 }); }), ); const abortController = new AbortController(); - await expect( - connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, - { onMessage: vi.fn() }, - ), - ).rejects.toThrow('stream was not ok: 500'); + const promise = connectStream( + { host: HOST, sdkKey: 'vf_test', abortController }, + { onMessage: vi.fn() }, + ); + + // Wait for at least one retry attempt (first retry has 0ms backoff) + await vi.waitFor( + () => { + expect(requestCount).toBeGreaterThanOrEqual(2); + }, + { timeout: 3000 }, + ); + + // Abort to stop retries + abortController.abort(); + + // The init promise should reject since no data was received + await expect(promise).rejects.toThrow( + 'stream: aborted before receiving data', + ); errorSpy.mockRestore(); }); - it('should reject if response has no body', async () => { + it('should retry if response has no body and reject when aborted', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let requestCount = 0; server.use( http.get(`${HOST}/v1/stream`, () => { + requestCount++; // Return a response without a body return new HttpResponse(null, { status: 200, @@ -466,12 +484,26 @@ describe('connectStream', () => { const abortController = new AbortController(); - await expect( - connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, - { onMessage: vi.fn() }, - ), - ).rejects.toThrow('stream body was not present'); + const promise = connectStream( + { host: HOST, sdkKey: 'vf_test', abortController }, + { onMessage: vi.fn() }, + ); + + // Wait for at least one retry attempt (first retry has 0ms backoff) + await vi.waitFor( + () => { + expect(requestCount).toBeGreaterThanOrEqual(2); + }, + { timeout: 3000 }, + ); + + // Abort to stop retries + abortController.abort(); + + // The init promise should reject since no data was received + await expect(promise).rejects.toThrow( + 'stream: aborted before receiving data', + ); errorSpy.mockRestore(); }); diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index faa9cfdc..7310018e 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -59,6 +59,11 @@ export async function connectStream( while (!abortController.signal.aborted) { if (retryCount > MAX_RETRY_COUNT) { console.error('@vercel/flags-core: Max retry count exceeded'); + if (!initialDataReceived) { + rejectInit!( + new Error('stream: max retry count exceeded before receiving data'), + ); + } abortController.abort(); break; } @@ -136,14 +141,16 @@ export async function connectStream( } console.error('@vercel/flags-core: Stream error', error); onDisconnect?.(); - if (!initialDataReceived) { - rejectInit!(error); - break; - } retryCount++; await sleep(backoff(retryCount)); } } + + // Reject the init promise if the loop exited without receiving data + // (e.g. aborted externally before any data arrived) + if (!initialDataReceived) { + rejectInit!(new Error('stream: aborted before receiving data')); + } })(); return initPromise; diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index 5927b560..3f999105 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -38,6 +38,7 @@ interface EventBatcher { const MAX_BATCH_SIZE = 50; const MAX_BATCH_WAIT_MS = 5000; +const MAX_QUEUE_SIZE = 500; interface RequestContext { ctx: object | undefined; @@ -113,8 +114,17 @@ export class UsageTracker { * Returns a promise that resolves when the flush completes. */ flush(): Promise { - this.batcher.resolveWait?.(); - return this.batcher.pending ?? RESOLVED_VOID; + if (this.batcher.pending) { + this.batcher.resolveWait?.(); + return this.batcher.pending; + } + + // No scheduled flush yet — flush directly if there are queued events + if (this.batcher.events.length > 0) { + return this.flushEvents(); + } + + return RESOLVED_VOID; } /** @@ -209,6 +219,18 @@ export class UsageTracker { } } + /** + * Re-queues failed events, dropping oldest when the queue would exceed MAX_QUEUE_SIZE. + */ + private requeue(events: FlagsConfigReadEvent[]): void { + const combined = [...events, ...this.batcher.events]; + // Drop oldest events (from the front) when over capacity + this.batcher.events = + combined.length > MAX_QUEUE_SIZE + ? combined.slice(combined.length - MAX_QUEUE_SIZE) + : combined; + } + private async flushEvents(): Promise { if (this.batcher.events.length === 0) return; @@ -240,11 +262,11 @@ export class UsageTracker { '@vercel/flags-core: Failed to send events:', response.statusText, ); - this.batcher.events.unshift(...eventsToSend); + this.requeue(eventsToSend); } } catch (error) { debugLog('@vercel/flags-core: Error sending events:', error); - this.batcher.events.unshift(...eventsToSend); + this.requeue(eventsToSend); } } } From fd0f7f19a1552059d32e79c8fb3f2bda397a49e1 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 14:49:22 +0200 Subject: [PATCH 26/65] Update CLAUDE.md --- packages/vercel-flags-core/CLAUDE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 8bcfd0c4..4249def2 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -165,10 +165,12 @@ pnpm test:integration - Uses fetch with streaming body (NDJSON format) - 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 - 401 errors abort immediately (invalid SDK key) and reject the init promise, so fallback kicks in without waiting for the stream timeout - On disconnect: state transitions to `'degraded'`, falls back to polling if enabled - On reconnect: Controller listens for `'connected'` event and transitions back to `'streaming'` +- Background stream promises (from init timeout or `startBackgroundUpdates`) are `.catch`-ed by the Controller to prevent unhandled rejections when the stream is aborted before receiving data ### Polling @@ -177,6 +179,8 @@ pnpm test:integration - Default `initTimeoutMs`: 10000ms (10s) - Retries with exponential backoff (base: 500ms, max 3 retries) - Stops automatically when stream reconnects +- `PollingSource` passes its abort signal to `fetchDatafile`, so calling `stop()` aborts in-flight HTTP requests +- `fetchDatafile` accepts an optional `signal` parameter; when provided, it aborts the internal fetch controller when the external signal fires ### Data Origin Tagging @@ -194,7 +198,8 @@ The Controller tags all data with its origin using `tagData(data, origin)` from - At runtime: deduplicates by request context (per-instance WeakSet in UsageTracker) - During builds: deduplicates all reads to a single event (buildReadTracked flag in Controller), since there is no request context available - Uses `waitUntil()` from `@vercel/functions` (wrapped in try/catch for resilience) -- On flush failure, events are re-queued for retry +- On flush failure, events are re-queued for retry with a max queue size of 500 events (oldest events are dropped when exceeded) +- `flush()` directly flushes queued events even when no scheduled flush is pending, ensuring events are not lost during `shutdown()` ### Client Management @@ -207,6 +212,11 @@ The Controller tags all data with its origin using `tagData(data, origin)` from The Controller rejects incoming data (from stream or poll) if its `configUpdatedAt` is older than or equal to the current in-memory data. This prevents stale updates from overwriting newer data. Accepts the update if either side lacks a `configUpdatedAt`. +### Evaluation Reporting + +- `internalReportValue` in `controller-fns.ts` reports flag evaluations to the Vercel request context +- Reports are sent for all evaluations where `datafile.projectId` exists, including error cases (e.g., FLAG_NOT_FOUND) + ### Evaluation Safety - Regex comparators (`REGEX`, `NOT_REGEX`) limit input string length to 10,000 characters to prevent ReDoS From fb2614a08e18943863534b8d32eb36c3e99bac8a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 15:13:51 +0200 Subject: [PATCH 27/65] more fixes --- .../src/controller/fetch-datafile.ts | 7 ++++--- .../vercel-flags-core/src/controller/index.ts | 1 + .../src/controller/stream-connection.ts | 16 ++++++++++++---- .../src/controller/stream-source.ts | 19 +++++++++++++++++-- packages/vercel-flags-core/src/evaluate.ts | 14 +++++++++++++- .../src/utils/usage-tracker.ts | 13 +++++++++---- 6 files changed, 56 insertions(+), 14 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.ts index 065f1571..0ff0aa80 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.ts @@ -19,14 +19,13 @@ export async function fetchDatafile(options: { ); // Abort the internal controller when the external signal fires + const onExternalAbort = () => controller.abort(); if (options.signal) { if (options.signal.aborted) { clearTimeout(timeoutId); throw new Error('Fetch aborted'); } - options.signal.addEventListener('abort', () => controller.abort(), { - once: true, - }); + options.signal.addEventListener('abort', onExternalAbort, { once: true }); } try { @@ -39,6 +38,7 @@ export async function fetchDatafile(options: { }); clearTimeout(timeoutId); + options.signal?.removeEventListener('abort', onExternalAbort); if (!res.ok) { throw new Error(`Failed to fetch data: ${res.statusText}`); @@ -47,6 +47,7 @@ export async function fetchDatafile(options: { return res.json() as Promise; } catch (error) { clearTimeout(timeoutId); + options.signal?.removeEventListener('abort', onExternalAbort); throw error instanceof Error ? error : new Error('Unknown fetch error'); } } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 8fea41dd..855897cd 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -307,6 +307,7 @@ export class Controller implements ControllerInterface { */ async getDatafile(): Promise { const startTime = Date.now(); + this.isFirstGetData = false; let result: TaggedData; let cacheStatus: Metrics['cacheStatus']; diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 7310018e..366ec676 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -95,14 +95,16 @@ export async function connectStream( } const decoder = new TextDecoder(); - let buffer = ''; + const bufferChunks: string[] = []; for await (const chunk of response.body) { if (abortController.signal.aborted) break; - buffer += decoder.decode(chunk, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop()!; + bufferChunks.push(decoder.decode(chunk, { stream: true })); + const combined = bufferChunks.join(''); + bufferChunks.length = 0; + const lines = combined.split('\n'); + bufferChunks.push(lines.pop()!); for (const line of lines) { if (line === '') continue; @@ -125,6 +127,12 @@ export async function connectStream( resolveInit!(); } } + + // Pings prove the connection is alive — reset retry count + // once initial data has been received + if (message.type === 'ping' && initialDataReceived) { + retryCount = 0; + } } } diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index b3b1458c..99adc80e 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -36,14 +36,29 @@ export class StreamSource extends TypedEmitter { start(): Promise { if (this.promise) return this.promise; - this.abortController = new AbortController(); + const abortController = new AbortController(); + this.abortController = abortController; + + // Clear cached state when the stream terminates so that a subsequent + // start() call creates a fresh connection instead of returning a stale + // resolved promise. + abortController.signal.addEventListener( + 'abort', + () => { + if (this.abortController === abortController) { + this.promise = undefined; + this.abortController = undefined; + } + }, + { once: true }, + ); try { const promise = connectStream( { host: this.config.host, sdkKey: this.config.sdkKey, - abortController: this.abortController, + abortController, fetch: this.config.fetch, }, { diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 19b4f346..91799f70 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -375,6 +375,8 @@ export function evaluate( * The params used for the evaluation */ params: EvaluationParams, + /** Tracks visited environments to detect circular reuse. */ + _visited?: Set, ): EvaluationResult { const envConfig = params.definition.environments[params.environment]; @@ -404,7 +406,17 @@ export function evaluate( ); } - return evaluate({ ...params, environment: envConfig.reuse }); + const visited = _visited ?? new Set(); + if (visited.has(envConfig.reuse)) { + return { + reason: ResolutionReason.ERROR, + errorMessage: `Circular environment reuse detected: "${envConfig.reuse}"`, + value: params.defaultValue, + }; + } + visited.add(params.environment); + + return evaluate({ ...params, environment: envConfig.reuse }, visited); } if (envConfig.targets) { diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index 3f999105..1d6c5654 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -225,10 +225,15 @@ export class UsageTracker { private requeue(events: FlagsConfigReadEvent[]): void { const combined = [...events, ...this.batcher.events]; // Drop oldest events (from the front) when over capacity - this.batcher.events = - combined.length > MAX_QUEUE_SIZE - ? combined.slice(combined.length - MAX_QUEUE_SIZE) - : combined; + if (combined.length > MAX_QUEUE_SIZE) { + const dropped = combined.length - MAX_QUEUE_SIZE; + console.warn( + `@vercel/flags-core: Dropping ${dropped} usage event(s) (queue full)`, + ); + this.batcher.events = combined.slice(dropped); + } else { + this.batcher.events = combined; + } } private async flushEvents(): Promise { From d0b327a3679f20c79e9ac86c8508357b710db30a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 15:24:07 +0200 Subject: [PATCH 28/65] resolve more issues --- .../vercel-flags-core/src/controller-fns.ts | 20 ++++-- .../vercel-flags-core/src/controller/index.ts | 69 +++++++++++-------- 2 files changed, 55 insertions(+), 34 deletions(-) diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 53d4cef2..b3886127 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -17,20 +17,30 @@ export type ControllerInstance = { export const controllerInstanceMap = new Map(); +function getInstance(id: number): ControllerInstance { + const instance = controllerInstanceMap.get(id); + if (!instance) { + throw new Error( + `@vercel/flags-core: Client instance ${id} not found. It may have been shut down.`, + ); + } + return instance; +} + export function initialize(id: number): Promise { - return controllerInstanceMap.get(id)!.controller.initialize(); + return getInstance(id).controller.initialize(); } export function shutdown(id: number): void | Promise { - return controllerInstanceMap.get(id)!.controller.shutdown(); + return getInstance(id).controller.shutdown(); } export function getDatafile(id: number) { - return controllerInstanceMap.get(id)!.controller.getDatafile(); + return getInstance(id).controller.getDatafile(); } export function getFallbackDatafile(id: number): Promise { - const ds = controllerInstanceMap.get(id)!.controller; + const ds = getInstance(id).controller; if (ds.getFallbackDatafile) return ds.getFallbackDatafile(); throw new Error('flags: This data source does not support fallbacks'); } @@ -41,7 +51,7 @@ export async function evaluate>( defaultValue?: T, entities?: E, ): Promise> { - const controller = controllerInstanceMap.get(id)!.controller; + const controller = getInstance(id).controller; let datafile: Datafile; try { diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 855897cd..f9899850 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -145,40 +145,49 @@ export class Controller implements ControllerInterface { this.usageTracker = options.usageTracker ?? new UsageTracker(this.options); } + // Source event handlers (stored for cleanup) + private onStreamData = (data: DatafileInput) => { + if (this.isNewerData(data)) { + this.data = tagData(data, 'stream'); + } + }; + private onStreamConnected = () => { + if (this.state === 'degraded' || this.state === 'initializing:stream') { + this.transition('streaming'); + } + }; + private onStreamDisconnected = () => { + if (this.state === 'streaming') { + this.transition('degraded'); + } + }; + private onPollData = (data: DatafileInput) => { + if (this.isNewerData(data)) { + this.data = tagData(data, 'poll'); + } + }; + private onPollError = (error: Error) => { + console.error('@vercel/flags-core: Poll failed:', error); + }; + // --------------------------------------------------------------------------- // Source event wiring // --------------------------------------------------------------------------- private wireSourceEvents(): void { - // Stream events — tag on receipt - this.streamSource.on('data', (data) => { - if (this.isNewerData(data)) { - this.data = tagData(data, 'stream'); - } - }); - - this.streamSource.on('connected', () => { - if (this.state === 'degraded' || this.state === 'initializing:stream') { - this.transition('streaming'); - } - }); - - this.streamSource.on('disconnected', () => { - if (this.state === 'streaming') { - this.transition('degraded'); - } - }); - - // Polling events — tag on receipt - this.pollingSource.on('data', (data) => { - if (this.isNewerData(data)) { - this.data = tagData(data, 'poll'); - } - }); + this.streamSource.on('data', this.onStreamData); + this.streamSource.on('connected', this.onStreamConnected); + this.streamSource.on('disconnected', this.onStreamDisconnected); + this.pollingSource.on('data', this.onPollData); + this.pollingSource.on('error', this.onPollError); + } - this.pollingSource.on('error', (error) => { - console.error('@vercel/flags-core: Poll failed:', error); - }); + private unwireSourceEvents(): void { + this.streamSource.off('data', this.onStreamData); + this.streamSource.off('connected', this.onStreamConnected); + this.streamSource.off('disconnected', this.onStreamDisconnected); + this.pollingSource.off('data', this.onPollData); + this.pollingSource.off('error', this.onPollError); } // --------------------------------------------------------------------------- @@ -291,6 +300,7 @@ export class Controller implements ControllerInterface { * Shuts down the data source and releases resources. */ async shutdown(): Promise { + this.unwireSourceEvents(); this.streamSource.stop(); this.pollingSource.stop(); this.data = this.options.datafile @@ -514,8 +524,9 @@ export class Controller implements ControllerInterface { this.transition('initializing:stream'); this.streamSource.start().catch(() => {}); } else if (this.options.polling.enabled) { - void this.pollingSource.poll(); + // Start interval first so the abort controller exists for the initial poll this.pollingSource.startInterval(); + void this.pollingSource.poll(); this.transition('polling'); } else { this.transition('degraded'); From 519b266fc3397a271d495197703f1c263bece004 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 15:42:27 +0200 Subject: [PATCH 29/65] unite black box tests --- .../src/black-box-controller.test.ts | 1911 -------------- .../vercel-flags-core/src/black-box.test.ts | 2220 ++++++++++++++--- 2 files changed, 1939 insertions(+), 2192 deletions(-) delete mode 100644 packages/vercel-flags-core/src/black-box-controller.test.ts diff --git a/packages/vercel-flags-core/src/black-box-controller.test.ts b/packages/vercel-flags-core/src/black-box-controller.test.ts deleted file mode 100644 index bb4a36a2..00000000 --- a/packages/vercel-flags-core/src/black-box-controller.test.ts +++ /dev/null @@ -1,1911 +0,0 @@ -/** - * Black-box tests for controller behaviors. - * - * These tests verify the SDK's behavior exclusively through the public API - * (createClient → evaluate/getDatafile/getFallbackDatafile/initialize/shutdown). - * This allows internal refactoring without test breakage. - * - * Companion to black-box.test.ts which covers basic happy paths. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { StreamMessage } from './controller/stream-connection'; -import { type BundledDefinitions, createClient } from './index.default'; -import { internalReportValue } from './lib/report-value'; -import { readBundledDefinitions } from './utils/read-bundled-definitions'; - -vi.mock('./utils/read-bundled-definitions', () => ({ - readBundledDefinitions: vi.fn(() => - Promise.resolve({ definitions: null, state: 'missing-file' }), - ), -})); - -vi.mock('./lib/report-value', () => ({ - internalReportValue: vi.fn(), -})); - -const sdkKey = 'vf_fake'; -const fetchMock = vi.fn(); - -/** - * Creates a mock NDJSON stream response for testing. - * - * Returns a controller object that lets you gradually push messages - * and a `response` promise suitable for use with a fetch mock. - */ -function createMockStream() { - const encoder = new TextEncoder(); - let controller: ReadableStreamDefaultController; - - const body = new ReadableStream({ - start(c) { - controller = c; - }, - }); - - return { - response: Promise.resolve(new Response(body, { status: 200 })), - push(message: StreamMessage) { - controller.enqueue(encoder.encode(`${JSON.stringify(message)}\n`)); - }, - close() { - controller.close(); - }, - }; -} - -/** A simple bundled definitions fixture */ -function makeBundled( - overrides: Partial = {}, -): BundledDefinitions { - return { - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - ...overrides, - }; -} - -const originalEnv = { ...process.env }; - -describe('Controller (black-box)', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.mocked(readBundledDefinitions).mockReset(); - vi.mocked(internalReportValue).mockReset(); - fetchMock.mockReset(); - // Reset env vars that affect build step detection - delete process.env.CI; - delete process.env.NEXT_PHASE; - }); - - afterEach(() => { - vi.useRealTimers(); - process.env = { ...originalEnv }; - }); - - // --------------------------------------------------------------------------- - // Constructor validation - // --------------------------------------------------------------------------- - describe('constructor validation', () => { - it('should throw for missing SDK key', () => { - expect(() => - createClient('', { fetch: fetchMock, stream: false, polling: false }), - ).toThrow('flags: Missing sdkKey'); - }); - - it('should throw for SDK key not starting with vf_', () => { - expect(() => - createClient('invalid_key', { - fetch: fetchMock, - stream: false, - polling: false, - }), - ).toThrow('flags: Missing sdkKey'); - }); - - it('should throw for non-string SDK key', () => { - expect(() => - createClient(123 as unknown as string, { - fetch: fetchMock, - stream: false, - polling: false, - }), - ).toThrow(); - }); - - it('should accept valid SDK key', () => { - expect(() => - createClient('vf_valid_key', { - fetch: fetchMock, - stream: false, - polling: false, - }), - ).not.toThrow(); - }); - }); - - // --------------------------------------------------------------------------- - // Build step detection - // --------------------------------------------------------------------------- - describe('build step detection', () => { - it('should detect build step when CI=1', async () => { - process.env.CI = '1'; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - const client = createClient(sdkKey, { fetch: fetchMock }); - const result = await client.evaluate('flagA'); - - expect(result.metrics?.mode).toBe('build'); - expect(result.metrics?.source).toBe('embedded'); - // No network requests should have been made - expect(fetchMock).not.toHaveBeenCalled(); - - await client.shutdown(); - }); - - it('should detect build step when NEXT_PHASE=phase-production-build', async () => { - process.env.NEXT_PHASE = 'phase-production-build'; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - const client = createClient(sdkKey, { fetch: fetchMock }); - const result = await client.evaluate('flagA'); - - expect(result.metrics?.mode).toBe('build'); - expect(result.metrics?.source).toBe('embedded'); - expect(fetchMock).not.toHaveBeenCalled(); - - await client.shutdown(); - }); - - it('should NOT detect build step when neither CI nor NEXT_PHASE is set', async () => { - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { fetch: fetchMock }); - const initPromise = client.initialize(); - - stream.push({ - type: 'datafile', - data: makeBundled({ projectId: 'stream' }), - }); - await vi.advanceTimersByTimeAsync(0); - await initPromise; - - // Stream should have been attempted - expect(fetchMock).toHaveBeenCalled(); - const streamCall = fetchMock.mock.calls.find((call) => - call[0]?.toString().includes('/v1/stream'), - ); - expect(streamCall).toBeDefined(); - - stream.close(); - await client.shutdown(); - }); - - it('should override auto-detection with buildStep: false', async () => { - process.env.CI = '1'; // Would normally trigger build step - - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - buildStep: false, // Explicitly override CI detection - }); - - const initPromise = client.initialize(); - - stream.push({ - type: 'datafile', - data: makeBundled({ projectId: 'stream' }), - }); - await vi.advanceTimersByTimeAsync(0); - await initPromise; - - const result = await client.evaluate('flagA'); - - // Should use stream (buildStep: false overrides CI detection) - expect(result.metrics?.mode).toBe('streaming'); - - stream.close(); - await client.shutdown(); - }); - }); - - // --------------------------------------------------------------------------- - // Build step behavior - // --------------------------------------------------------------------------- - describe('build step behavior', () => { - it('should throw when bundled definitions missing during build (no defaultValue)', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - buildStep: true, - }); - - await expect(client.evaluate('flagA')).rejects.toThrow( - 'No flag definitions available during build', - ); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('should cache data after first build step read', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - buildStep: true, - }); - - const first = await client.evaluate('flagA'); - expect(first.metrics?.cacheStatus).toBe('HIT'); - - const second = await client.evaluate('flagA'); - expect(second.metrics?.cacheStatus).toBe('HIT'); - - // readBundledDefinitions should only be called once - expect(readBundledDefinitions).toHaveBeenCalledTimes(1); - - await client.shutdown(); - }); - - it('should skip network when buildStep: true even if stream/polling configured', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - buildStep: true, - stream: true, - polling: true, - }); - - const result = await client.evaluate('flagA'); - - expect(result.metrics?.source).toBe('embedded'); - expect(result.metrics?.mode).toBe('build'); - expect(fetchMock).not.toHaveBeenCalled(); - - await client.shutdown(); - }); - - it('should use datafile over bundled in build step', async () => { - const providedDatafile = makeBundled({ - configUpdatedAt: 2, - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - }); - - const bundled = makeBundled({ - configUpdatedAt: 1, - definitions: { - flagA: { - environments: { production: 0 }, - variants: [false, true], - }, - }, - }); - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: bundled, - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - buildStep: true, - datafile: providedDatafile, - }); - - const result = await client.evaluate('flagA'); - - // value true means variant index 1 (from provided datafile), not 0 (bundled) - expect(result.value).toBe(true); - expect(result.metrics?.source).toBe('in-memory'); - - await client.shutdown(); - }); - }); - - // --------------------------------------------------------------------------- - // Stream behavior - // --------------------------------------------------------------------------- - describe('stream behavior', () => { - it('should handle messages split across chunks', async () => { - const datafile = makeBundled({ projectId: 'test-project' }); - const fullMessage = JSON.stringify({ - type: 'datafile', - data: datafile, - }); - const part1 = fullMessage.slice(0, 20); - const part2 = `${fullMessage.slice(20)}\n`; - - const encoder = new TextEncoder(); - let streamController: ReadableStreamDefaultController; - - const body = new ReadableStream({ - start(c) { - streamController = c; - }, - }); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - return Promise.resolve(new Response(body, { status: 200 })); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { fetch: fetchMock }); - const initPromise = client.initialize(); - - // Send chunks separately - streamController!.enqueue(encoder.encode(part1)); - await vi.advanceTimersByTimeAsync(10); - streamController!.enqueue(encoder.encode(part2)); - await vi.advanceTimersByTimeAsync(0); - - await initPromise; - - const result = await client.evaluate('flagA'); - expect(result.value).toBe(true); - expect(result.metrics?.source).toBe('in-memory'); - expect(result.metrics?.connectionState).toBe('connected'); - - streamController!.close(); - await client.shutdown(); - }); - - it('should update definitions when new datafile messages arrive', async () => { - const datafile1 = makeBundled({ - configUpdatedAt: 1, - definitions: { - flagA: { - environments: { production: 0 }, - variants: [false, true], - }, - }, - }); - const datafile2 = makeBundled({ - configUpdatedAt: 2, - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - }); - - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { fetch: fetchMock }); - const initPromise = client.initialize(); - - stream.push({ type: 'datafile', data: datafile1 }); - await vi.advanceTimersByTimeAsync(0); - await initPromise; - - // First evaluate returns variant 0 (false) - const result1 = await client.evaluate('flagA'); - expect(result1.value).toBe(false); - - // Push updated definitions - stream.push({ type: 'datafile', data: datafile2 }); - await vi.advanceTimersByTimeAsync(0); - - // Second evaluate returns variant 1 (true) - const result2 = await client.evaluate('flagA'); - expect(result2.value).toBe(true); - - stream.close(); - await client.shutdown(); - }); - - it('should fall back to bundled when stream times out', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - // Stream opens but never sends data - const body = new ReadableStream({ start() {} }); - return Promise.resolve(new Response(body, { status: 200 })); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - polling: false, - }); - - const initPromise = client.initialize(); - - // Advance past the stream init timeout (3s) - await vi.advanceTimersByTimeAsync(3_000); - await initPromise; - - const result = await client.evaluate('flagA'); - expect(result.value).toBe(true); - expect(result.metrics?.source).toBe('embedded'); - expect(result.metrics?.connectionState).toBe('disconnected'); - }); - - it('should fall back to bundled when stream errors (4xx)', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - return Promise.resolve(new Response(null, { status: 401 })); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - // Suppress expected error logs - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const client = createClient(sdkKey, { - fetch: fetchMock, - polling: false, - }); - - const evalPromise = client.evaluate('flagA'); - - // The 401 aborts the stream but the init promise may hang until timeout - await vi.advanceTimersByTimeAsync(3_000); - - const result = await evalPromise; - expect(result.value).toBe(true); - expect(result.metrics?.source).toBe('embedded'); - - errorSpy.mockRestore(); - }); - - it('should fast-fail on 401 without waiting for stream timeout', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - return Promise.resolve(new Response(null, { status: 401 })); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const client = createClient(sdkKey, { - fetch: fetchMock, - polling: false, - }); - - const evalPromise = client.evaluate('flagA'); - - // Only advance a tiny amount — well under the 3s stream timeout. - // If the 401 fast-fail works, evaluate resolves without the full timeout. - await vi.advanceTimersByTimeAsync(100); - - const result = await evalPromise; - expect(result.value).toBe(true); - expect(result.metrics?.source).toBe('embedded'); - - errorSpy.mockRestore(); - }); - - it('should use custom initTimeoutMs value', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - const body = new ReadableStream({ start() {} }); - return Promise.resolve(new Response(body, { status: 200 })); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: { initTimeoutMs: 500 }, - polling: false, - }); - - const initPromise = client.initialize(); - - // Advance only 500ms (custom timeout) - await vi.advanceTimersByTimeAsync(500); - await initPromise; - - const result = await client.evaluate('flagA'); - expect(result.metrics?.source).toBe('embedded'); - }); - - it('should disable stream when stream: false', async () => { - const datafile = makeBundled(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/datafile')) { - return Promise.resolve( - new Response(JSON.stringify(datafile), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: true, - }); - - await client.initialize(); - await vi.advanceTimersByTimeAsync(0); - - // No stream requests should have been made - const streamCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/stream'), - ); - expect(streamCalls).toHaveLength(0); - - await client.shutdown(); - }); - }); - - // --------------------------------------------------------------------------- - // Polling behavior - // --------------------------------------------------------------------------- - describe('polling behavior', () => { - it('should use polling when enabled', async () => { - vi.useRealTimers(); // Polling uses real intervals - - let pollCount = 0; - const datafile = makeBundled(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/datafile')) { - pollCount++; - return Promise.resolve( - new Response(JSON.stringify(datafile), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: { intervalMs: 100, initTimeoutMs: 5000 }, - }); - - await client.initialize(); - - expect(pollCount).toBeGreaterThanOrEqual(1); - - // Wait for a few poll intervals - await new Promise((r) => setTimeout(r, 350)); - - expect(pollCount).toBeGreaterThanOrEqual(3); - - await client.shutdown(); - }); - - it('should disable polling when polling: false', async () => { - const datafile = makeBundled(); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile, - }); - - await client.initialize(); - await vi.advanceTimersByTimeAsync(100); - - // No datafile fetch requests should have been made - const pollCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/datafile'), - ); - expect(pollCalls).toHaveLength(0); - - await client.shutdown(); - }); - }); - - // --------------------------------------------------------------------------- - // Datafile option - // --------------------------------------------------------------------------- - describe('datafile option', () => { - it('should use provided datafile immediately', async () => { - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const datafile = makeBundled({ projectId: 'provided' }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - datafile, - }); - - const result = await client.evaluate('flagA'); - expect(result.value).toBe(true); - expect(result.metrics?.source).toBe('in-memory'); - - stream.close(); - await client.shutdown(); - }); - - it('should work with datafile only (stream and polling disabled)', async () => { - const datafile = makeBundled(); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile, - }); - - await client.initialize(); - const result = await client.evaluate('flagA'); - - expect(result.value).toBe(true); - expect(result.metrics?.source).toBe('in-memory'); - - // No network requests - const networkCalls = fetchMock.mock.calls.filter( - (call) => - call[0]?.toString().includes('/v1/stream') || - call[0]?.toString().includes('/v1/datafile'), - ); - expect(networkCalls).toHaveLength(0); - - await client.shutdown(); - }); - }); - - // --------------------------------------------------------------------------- - // Stream/polling coordination - // --------------------------------------------------------------------------- - describe('stream/polling coordination', () => { - it('should fall back to bundled when stream times out (skip polling)', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled({ projectId: 'bundled' }), - }); - - let pollCount = 0; - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - const body = new ReadableStream({ start() {} }); - return Promise.resolve(new Response(body, { status: 200 })); - } - if (url.includes('/v1/datafile')) { - pollCount++; - return Promise.resolve( - new Response(JSON.stringify(makeBundled({ projectId: 'polled' })), { - status: 200, - }), - ); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: { initTimeoutMs: 100 }, - polling: { intervalMs: 50, initTimeoutMs: 5000 }, - }); - - const initPromise = client.initialize(); - await vi.advanceTimersByTimeAsync(100); - await initPromise; - - const result = await client.evaluate('flagA'); - expect(result.metrics?.source).toBe('embedded'); - expect(pollCount).toBe(0); - - warnSpy.mockRestore(); - }); - - it('should fall back to bundled when stream fails (skip polling)', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled({ projectId: 'bundled' }), - }); - - let pollCount = 0; - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - return Promise.resolve(new Response(null, { status: 500 })); - } - if (url.includes('/v1/datafile')) { - pollCount++; - return Promise.resolve( - new Response(JSON.stringify(makeBundled({ projectId: 'polled' })), { - status: 200, - }), - ); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: { initTimeoutMs: 100 }, - polling: { intervalMs: 100, initTimeoutMs: 5000 }, - }); - - // Stream retries with backoff; advance timers so the init timeout fires - const resultPromise = client.evaluate('flagA'); - await vi.advanceTimersByTimeAsync(200); - const result = await resultPromise; - expect(result.metrics?.source).toBe('embedded'); - expect(pollCount).toBe(0); - - errorSpy.mockRestore(); - warnSpy.mockRestore(); - }); - - it('should never stream and poll simultaneously when stream is connected', async () => { - const stream = createMockStream(); - let pollCount = 0; - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - if (url.includes('/v1/datafile')) { - pollCount++; - return Promise.resolve( - new Response(JSON.stringify(makeBundled()), { status: 200 }), - ); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: true, - polling: false, - }); - - const initPromise = client.initialize(); - - stream.push({ type: 'datafile', data: makeBundled() }); - await vi.advanceTimersByTimeAsync(0); - await initPromise; - - // Wait to see if any polls happen - await vi.advanceTimersByTimeAsync(200); - - expect(pollCount).toBe(0); - - stream.close(); - await client.shutdown(); - }); - - it('should use datafile immediately while starting background stream', async () => { - vi.useRealTimers(); // Need real timers for delayed stream - - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - return stream.response; - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const providedDatafile = makeBundled({ - projectId: 'provided', - definitions: { - flagA: { - environments: { production: 0 }, - variants: [false, true], - }, - }, - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - datafile: providedDatafile, - stream: true, - polling: false, - }); - - // Initialize starts background stream connection - await client.initialize(); - - // First evaluate uses provided datafile immediately - const result1 = await client.evaluate('flagA'); - expect(result1.value).toBe(false); // variant 0 from provided - expect(result1.metrics?.source).toBe('in-memory'); - - // Now push stream data (with newer configUpdatedAt) - stream.push({ - type: 'datafile', - data: makeBundled({ - projectId: 'stream', - configUpdatedAt: 2, - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - }), - }); - - // Wait for stream to deliver - await new Promise((r) => setTimeout(r, 50)); - - const result2 = await client.evaluate('flagA'); - expect(result2.value).toBe(true); // variant 1 from stream - - stream.close(); - await client.shutdown(); - }); - - it('should not start polling from stream disconnect during initialization', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - let pollCount = 0; - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { - return Promise.resolve(new Response(null, { status: 500 })); - } - if (url.includes('/v1/datafile')) { - pollCount++; - return Promise.resolve( - new Response(JSON.stringify(makeBundled()), { status: 200 }), - ); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: { initTimeoutMs: 5000 }, - polling: { intervalMs: 100, initTimeoutMs: 5000 }, - }); - - // Stream retries with backoff; advance timers so the init timeout fires - const initPromise = client.initialize(); - await vi.advanceTimersByTimeAsync(5100); - await initPromise; - - expect(pollCount).toBe(0); - - await client.shutdown(); - errorSpy.mockRestore(); - warnSpy.mockRestore(); - }); - }); - - // --------------------------------------------------------------------------- - // getDatafile - // --------------------------------------------------------------------------- - describe('getDatafile', () => { - it('should return bundled definitions when called without initialize', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - }); - - const result = await client.getDatafile(); - expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('MISS'); - expect(result.metrics.connectionState).toBe('disconnected'); - - await client.shutdown(); - }); - - it('should fetch datafile when called without initialize and no bundled definitions', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, - }); - - const fetchedDatafile = makeBundled({ projectId: 'fetched' }); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/datafile')) { - return Promise.resolve( - new Response(JSON.stringify(fetchedDatafile), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - }); - - const result = await client.getDatafile(); - expect(result.metrics.source).toBe('remote'); - expect(result.metrics.cacheStatus).toBe('MISS'); - - await client.shutdown(); - }); - - it('should throw when called without initialize and all sources fail', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, - }); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/datafile')) { - return Promise.resolve(new Response(null, { status: 500 })); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - }); - - await expect(client.getDatafile()).rejects.toThrow( - 'No flag definitions available', - ); - - await client.shutdown(); - }); - - it('should return cached data when stream is connected', async () => { - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { fetch: fetchMock }); - - const initPromise = client.initialize(); - stream.push({ type: 'datafile', data: makeBundled() }); - await vi.advanceTimersByTimeAsync(0); - await initPromise; - - const result = await client.getDatafile(); - expect(result.metrics.source).toBe('in-memory'); - expect(result.metrics.cacheStatus).toBe('HIT'); - expect(result.metrics.connectionState).toBe('connected'); - - stream.close(); - await client.shutdown(); - }); - - it('should use build step path when CI=1', async () => { - process.env.CI = '1'; - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - const client = createClient(sdkKey, { fetch: fetchMock }); - const result = await client.getDatafile(); - - expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('MISS'); - - await client.shutdown(); - }); - - it('should return cached data on repeated calls', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - }); - - const result1 = await client.getDatafile(); - expect(result1.metrics.cacheStatus).toBe('MISS'); - - const result2 = await client.getDatafile(); - expect(result2.metrics.cacheStatus).toBe('STALE'); - - await client.shutdown(); - }); - }); - - // --------------------------------------------------------------------------- - // getFallbackDatafile - // --------------------------------------------------------------------------- - describe('getFallbackDatafile', () => { - it('should return bundled definitions when available', async () => { - const bundled = makeBundled(); - - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: bundled, - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - }); - - const result = await client.getFallbackDatafile(); - expect(result).toEqual(bundled); - - await client.shutdown(); - }); - - it('should throw FallbackNotFoundError for missing-file state', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - }); - - await expect(client.getFallbackDatafile()).rejects.toThrow( - 'Bundled definitions file not found', - ); - - try { - await client.getFallbackDatafile(); - } catch (error) { - expect((error as Error).name).toBe('FallbackNotFoundError'); - } - - await client.shutdown(); - }); - - it('should throw FallbackEntryNotFoundError for missing-entry state', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-entry', - definitions: null, - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - }); - - await expect(client.getFallbackDatafile()).rejects.toThrow( - 'No bundled definitions found for SDK key', - ); - - try { - await client.getFallbackDatafile(); - } catch (error) { - expect((error as Error).name).toBe('FallbackEntryNotFoundError'); - } - - await client.shutdown(); - }); - - it('should throw for unexpected-error state', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'unexpected-error', - definitions: null, - error: new Error('Some error'), - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - }); - - await expect(client.getFallbackDatafile()).rejects.toThrow( - 'Failed to read bundled definitions', - ); - - await client.shutdown(); - }); - }); - - // --------------------------------------------------------------------------- - // configUpdatedAt guard - // --------------------------------------------------------------------------- - describe('configUpdatedAt guard', () => { - it('should not overwrite newer data with older stream message', async () => { - vi.useRealTimers(); - - const newerDatafile = makeBundled({ - configUpdatedAt: 2000, - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - }); - - const olderDatafile = makeBundled({ - configUpdatedAt: 1000, - definitions: { - flagA: { - environments: { production: 0 }, - variants: [false, true], - }, - }, - }); - - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - polling: false, - }); - - const initPromise = client.initialize(); - - // Send newer data first - stream.push({ type: 'datafile', data: newerDatafile }); - await new Promise((r) => setTimeout(r, 10)); - await initPromise; - - // Then send older data - stream.push({ type: 'datafile', data: olderDatafile }); - await new Promise((r) => setTimeout(r, 50)); - - // Should still have newer data (older message was rejected) - const result = await client.evaluate('flagA'); - expect(result.value).toBe(true); // variant 1 = newer - - stream.close(); - await client.shutdown(); - }); - - it('should skip stream data with equal configUpdatedAt', async () => { - vi.useRealTimers(); - - const data1 = makeBundled({ - configUpdatedAt: 1000, - definitions: { - flagA: { - environments: { production: 0 }, - variants: [false, true], - }, - }, - }); - - const data2 = makeBundled({ - configUpdatedAt: 1000, // Same - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - }); - - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - polling: false, - }); - - const initPromise = client.initialize(); - - stream.push({ type: 'datafile', data: data1 }); - await new Promise((r) => setTimeout(r, 10)); - await initPromise; - - stream.push({ type: 'datafile', data: data2 }); - await new Promise((r) => setTimeout(r, 50)); - - // Should have kept first data (equal configUpdatedAt is not newer) - const result = await client.evaluate('flagA'); - expect(result.value).toBe(false); // variant 0 = data1 - - stream.close(); - await client.shutdown(); - }); - - it('should accept updates when current data has no configUpdatedAt', async () => { - vi.useRealTimers(); - - const providedDatafile = makeBundled({ - definitions: { - flagA: { - environments: { production: 0 }, - variants: [false, true], - }, - }, - }); - // Remove configUpdatedAt to simulate a plain DatafileInput - delete (providedDatafile as Record).configUpdatedAt; - - const streamData = makeBundled({ - configUpdatedAt: 1000, - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - }); - - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - datafile: providedDatafile, - polling: false, - }); - - await client.initialize(); - - // Initial evaluate uses provided datafile (variant 0) - const result1 = await client.evaluate('flagA'); - expect(result1.value).toBe(false); - - // Push stream data with configUpdatedAt - stream.push({ type: 'datafile', data: streamData }); - await new Promise((r) => setTimeout(r, 50)); - - // Should accept stream data - const result2 = await client.evaluate('flagA'); - expect(result2.value).toBe(true); // variant 1 = stream - - stream.close(); - await client.shutdown(); - }); - - it('should handle configUpdatedAt as string', async () => { - vi.useRealTimers(); - - const newerDatafile = { - ...makeBundled({ - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - }), - configUpdatedAt: '2000' as unknown as number, - }; - - const olderDatafile = { - ...makeBundled({ - definitions: { - flagA: { - environments: { production: 0 }, - variants: [false, true], - }, - }, - }), - configUpdatedAt: '1000' as unknown as number, - }; - - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - polling: false, - }); - - const initPromise = client.initialize(); - - stream.push({ type: 'datafile', data: newerDatafile }); - await new Promise((r) => setTimeout(r, 10)); - await initPromise; - - stream.push({ type: 'datafile', data: olderDatafile }); - await new Promise((r) => setTimeout(r, 50)); - - // Should still have newer data - const result = await client.evaluate('flagA'); - expect(result.value).toBe(true); // variant 1 = newer - - stream.close(); - await client.shutdown(); - }); - - it('should accept updates when configUpdatedAt is a non-numeric string', async () => { - vi.useRealTimers(); - - const currentData = { - ...makeBundled({ - definitions: { - flagA: { - environments: { production: 0 }, - variants: [false, true], - }, - }, - }), - configUpdatedAt: 'not-a-number' as unknown as number, - }; - - const newData = makeBundled({ - configUpdatedAt: 1000, - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - }); - - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - polling: false, - }); - - const initPromise = client.initialize(); - - stream.push({ type: 'datafile', data: currentData }); - await new Promise((r) => setTimeout(r, 10)); - await initPromise; - - stream.push({ type: 'datafile', data: newData }); - await new Promise((r) => setTimeout(r, 50)); - - // Should accept update since current configUpdatedAt is unparseable - const result = await client.evaluate('flagA'); - expect(result.value).toBe(true); // variant 1 = newData - - stream.close(); - await client.shutdown(); - }); - - it('should not overwrite newer in-memory data via getDatafile', async () => { - vi.useRealTimers(); - - const newerDatafile = makeBundled({ - configUpdatedAt: 2000, - definitions: { - flagA: { - environments: { production: 1 }, - variants: [false, true], - }, - }, - }); - - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - polling: false, - }); - - const initPromise = client.initialize(); - - stream.push({ type: 'datafile', data: newerDatafile }); - await new Promise((r) => setTimeout(r, 10)); - await initPromise; - - // getDatafile and then evaluate — data should still be newer - await client.getDatafile(); - - const result = await client.evaluate('flagA'); - expect(result.value).toBe(true); // variant 1 = newer - - stream.close(); - await client.shutdown(); - }); - }); - - // --------------------------------------------------------------------------- - // Evaluate behavior - // --------------------------------------------------------------------------- - describe('evaluate behavior', () => { - it('should return FLAG_NOT_FOUND with defaultValue for missing flag', async () => { - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile: makeBundled(), - buildStep: true, - }); - - const result = await client.evaluate('nonexistent-flag', 'default'); - - expect(result.value).toBe('default'); - expect(result.reason).toBe('error'); - expect(result.errorCode).toBe('FLAG_NOT_FOUND'); - expect(result.errorMessage).toContain( - 'Definition not found for flag "nonexistent-flag"', - ); - }); - - it('should evaluate existing paused flag', async () => { - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile: makeBundled(), - buildStep: true, - }); - - const result = await client.evaluate('flagA'); - - expect(result.value).toBe(true); - expect(result.reason).toBe('paused'); - }); - - it('should pass entities for targeting evaluation', async () => { - const datafile = makeBundled({ - definitions: { - 'targeted-flag': { - environments: { - production: { - // targets is the packed shorthand for targeting rules - targets: [{}, { user: { id: ['user-123'] } }], - fallthrough: 0, - }, - }, - variants: ['default', 'targeted'], - }, - }, - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile, - buildStep: true, - }); - - const result = await client.evaluate('targeted-flag', 'default', { - user: { id: 'user-123' }, - }); - - expect(result.value).toBe('targeted'); - expect(result.reason).toBe('target_match'); - }); - - it('should use empty entities when not provided', async () => { - const datafile = makeBundled({ - definitions: { - 'targeted-flag': { - environments: { - production: { - targets: [{}, { user: { id: ['user-123'] } }], - fallthrough: 0, - }, - }, - variants: ['default', 'targeted'], - }, - }, - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile, - buildStep: true, - }); - - const result = await client.evaluate('targeted-flag'); - - expect(result.value).toBe('default'); - expect(result.reason).toBe('fallthrough'); - }); - - it('should work with different value types', async () => { - const datafile = makeBundled({ - definitions: { - boolFlag: { - environments: { production: 0 }, - variants: [true], - }, - stringFlag: { - environments: { production: 0 }, - variants: ['hello'], - }, - numberFlag: { - environments: { production: 0 }, - variants: [42], - }, - objectFlag: { - environments: { production: 0 }, - variants: [{ key: 'value' }], - }, - }, - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile, - buildStep: true, - }); - - expect((await client.evaluate('boolFlag')).value).toBe(true); - expect((await client.evaluate('stringFlag')).value).toBe('hello'); - expect((await client.evaluate('numberFlag')).value).toBe(42); - expect((await client.evaluate('objectFlag')).value).toEqual({ - key: 'value', - }); - }); - - it('should call internalReportValue when projectId exists', async () => { - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile: makeBundled({ projectId: 'my-project-id' }), - buildStep: true, - }); - - await client.evaluate('flagA'); - - expect(internalReportValue).toHaveBeenCalledWith('flagA', true, { - originProjectId: 'my-project-id', - originProvider: 'vercel', - reason: 'paused', - outcomeType: 'value', - }); - }); - - it('should not call internalReportValue when projectId is missing', async () => { - const datafile = makeBundled(); - delete (datafile as Record).projectId; - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile, - buildStep: true, - }); - - await client.evaluate('flagA'); - - expect(internalReportValue).not.toHaveBeenCalled(); - }); - - it('should call internalReportValue with error reason when flag is not found', async () => { - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile: makeBundled({ projectId: 'my-project-id' }), - buildStep: true, - }); - - await client.evaluate('nonexistent-flag', 'default'); - - expect(internalReportValue).toHaveBeenCalledWith( - 'nonexistent-flag', - 'default', - { - originProjectId: 'my-project-id', - originProvider: 'vercel', - reason: 'error', - }, - ); - }); - }); - - // --------------------------------------------------------------------------- - // Concurrent initialization - // --------------------------------------------------------------------------- - describe('concurrent initialization', () => { - it('should deduplicate concurrent initialize() calls', async () => { - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { fetch: fetchMock }); - - // Call initialize three times concurrently - const p1 = client.initialize(); - const p2 = client.initialize(); - const p3 = client.initialize(); - - stream.push({ type: 'datafile', data: makeBundled() }); - await vi.advanceTimersByTimeAsync(0); - - await Promise.all([p1, p2, p3]); - - // Stream should have been fetched only once - const streamCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/stream'), - ); - expect(streamCalls).toHaveLength(1); - - stream.close(); - await client.shutdown(); - }); - - it('should deduplicate concurrent evaluate() calls that trigger initialize', async () => { - const stream = createMockStream(); - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { fetch: fetchMock }); - - // Three concurrent evaluates trigger lazy initialization - const p1 = client.evaluate('flagA'); - const p2 = client.evaluate('flagA'); - const p3 = client.evaluate('flagA'); - - stream.push({ type: 'datafile', data: makeBundled() }); - await vi.advanceTimersByTimeAsync(0); - - const [r1, r2, r3] = await Promise.all([p1, p2, p3]); - - // All should have the same value - expect(r1.value).toBe(true); - expect(r2.value).toBe(true); - expect(r3.value).toBe(true); - - // Stream should have been fetched only once - const streamCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/stream'), - ); - expect(streamCalls).toHaveLength(1); - - stream.close(); - await client.shutdown(); - }); - - it('should allow re-initialization after failure', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, - }); - - let fetchCallCount = 0; - - fetchMock.mockImplementation((input) => { - const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/datafile')) { - fetchCallCount++; - if (fetchCallCount === 1) { - // First fetch fails - return Promise.resolve(new Response(null, { status: 500 })); - } - // Second fetch succeeds - return Promise.resolve( - new Response(JSON.stringify(makeBundled()), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }); - - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - }); - - // First initialize fails (no bundled, fetch returns 500) - await expect(client.initialize()).rejects.toThrow(); - - // Second initialize should retry — fetch now succeeds - await client.initialize(); - - const result = await client.evaluate('flagA'); - expect(result.value).toBe(true); - - await client.shutdown(); - }); - }); - - // --------------------------------------------------------------------------- - // Multiple clients - // --------------------------------------------------------------------------- - describe('multiple clients', () => { - it('should maintain independent state for each client', async () => { - const datafileA = makeBundled({ - definitions: { - flagA: { - environments: { production: 0 }, - variants: ['a-value', 'b-value'], - }, - }, - }); - - const datafileB = makeBundled({ - definitions: { - flagA: { - environments: { production: 1 }, - variants: ['a-value', 'b-value'], - }, - }, - }); - - const clientA = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile: datafileA, - buildStep: true, - }); - - const clientB = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, - datafile: datafileB, - buildStep: true, - }); - - const resultA = await clientA.evaluate('flagA'); - const resultB = await clientB.evaluate('flagA'); - - expect(resultA.value).toBe('a-value'); - expect(resultB.value).toBe('b-value'); - - // Shutdown one, other should still work - await clientA.shutdown(); - - const resultB2 = await clientB.evaluate('flagA'); - expect(resultB2.value).toBe('b-value'); - - await clientB.shutdown(); - }); - }); -}); diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 50785ef3..ec94db8a 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -1,10 +1,15 @@ -// extend client with concept of per-request data so we can set overrides? -// extend client with concept of request transaction so a single request is guaranteed consistent flag data? -// could be unexpected if used in a workflow or stream or whatever +/** + * Black-box tests for controller behaviors. + * + * These tests verify the SDK's behavior exclusively through the public API + * (createClient → evaluate/getDatafile/getFallbackDatafile/initialize/shutdown). + * This allows internal refactoring without test breakage. + */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { StreamMessage } from './controller/stream-connection'; import { type BundledDefinitions, createClient } from './index.default'; +import { internalReportValue } from './lib/report-value'; import { readBundledDefinitions } from './utils/read-bundled-definitions'; vi.mock('./utils/read-bundled-definitions', () => ({ @@ -13,6 +18,10 @@ vi.mock('./utils/read-bundled-definitions', () => ({ ), })); +vi.mock('./lib/report-value', () => ({ + internalReportValue: vi.fn(), +})); + const sdkKey = 'vf_fake'; const fetchMock = vi.fn(); @@ -21,12 +30,6 @@ const fetchMock = vi.fn(); * * Returns a controller object that lets you gradually push messages * and a `response` promise suitable for use with a fetch mock. - * - * Usage: - * const stream = createMockStream(); - * fetchMock.mockReturnValueOnce(stream.response); - * stream.push({ type: 'datafile', data: datafile }); - * stream.close(); */ function createMockStream() { const encoder = new TextEncoder(); @@ -49,431 +52,2086 @@ function createMockStream() { }; } -describe('Manual', () => { +/** A simple bundled definitions fixture */ +function makeBundled( + overrides: Partial = {}, +): BundledDefinitions { + return { + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + segments: {}, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + digest: 'abc', + revision: 1, + ...overrides, + }; +} + +const originalEnv = { ...process.env }; + +describe('Controller (black-box)', () => { beforeEach(() => { vi.useFakeTimers(); vi.mocked(readBundledDefinitions).mockReset(); + vi.mocked(internalReportValue).mockReset(); fetchMock.mockReset(); + // Reset env vars that affect build step detection + delete process.env.CI; + delete process.env.NEXT_PHASE; }); afterEach(() => { vi.useRealTimers(); + process.env = { ...originalEnv }; }); - describe('buildStep', () => { - it('uses the datafile if provided, even when bundled definitions exist', async () => { - const passedDatafile: BundledDefinitions = { - definitions: { - flagA: { - environments: { - production: 1, - }, - variants: [false, true], - }, - }, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 2, - digest: 'abc', - revision: 2, - }; + // --------------------------------------------------------------------------- + // Constructor validation + // --------------------------------------------------------------------------- + describe('constructor validation', () => { + it('should throw for missing SDK key', () => { + expect(() => + createClient('', { fetch: fetchMock, stream: false, polling: false }), + ).toThrow('flags: Missing sdkKey'); + }); - const bundledDatafile: BundledDefinitions = { - definitions: { - flagA: { - environments: { - production: 0, - }, - variants: [false, true], - }, - }, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }; + it('should throw for SDK key not starting with vf_', () => { + expect(() => + createClient('invalid_key', { + fetch: fetchMock, + stream: false, + polling: false, + }), + ).toThrow('flags: Missing sdkKey'); + }); + + it('should throw for non-string SDK key', () => { + expect(() => + createClient(123 as unknown as string, { + fetch: fetchMock, + stream: false, + polling: false, + }), + ).toThrow(); + }); + + it('should accept valid SDK key', () => { + expect(() => + createClient('vf_valid_key', { + fetch: fetchMock, + stream: false, + polling: false, + }), + ).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // Build step detection + // --------------------------------------------------------------------------- + describe('build step detection', () => { + it('should detect build step when CI=1', async () => { + process.env.CI = '1'; vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', - definitions: bundledDatafile, + definitions: makeBundled(), }); - const client = createClient(sdkKey, { - buildStep: true, - fetch: fetchMock, - datafile: passedDatafile, - }); + const client = createClient(sdkKey, { fetch: fetchMock }); + const result = await client.evaluate('flagA'); - await expect(client.evaluate('flagA')).resolves.toEqual({ - metrics: { - cacheStatus: 'HIT', - connectionState: 'disconnected', - mode: 'build', - evaluationMs: 0, - readMs: 0, - source: 'in-memory', - }, - outcomeType: 'value', - reason: 'paused', - // value is expected to be true instead of false, showing - // the passed definition is used instead of the bundled one - value: true, + expect(result.metrics?.mode).toBe('build'); + expect(result.metrics?.source).toBe('embedded'); + // No network requests should have been made + expect(fetchMock).not.toHaveBeenCalled(); + + await client.shutdown(); + }); + + it('should detect build step when NEXT_PHASE=phase-production-build', async () => { + process.env.NEXT_PHASE = 'phase-production-build'; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), }); + const client = createClient(sdkKey, { fetch: fetchMock }); + const result = await client.evaluate('flagA'); + + expect(result.metrics?.mode).toBe('build'); + expect(result.metrics?.source).toBe('embedded'); expect(fetchMock).not.toHaveBeenCalled(); - // flush await client.shutdown(); + }); - // verify tracking - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), - headers: { - Authorization: 'Bearer vf_fake', - 'Content-Type': 'application/json', - 'User-Agent': 'VercelFlagsCore/1.0.1', - }, - method: 'POST', - }, + it('should NOT detect build step when neither CI nor NEXT_PHASE is set', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const initPromise = client.initialize(); + + stream.push({ + type: 'datafile', + data: makeBundled({ projectId: 'stream' }), + }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Stream should have been attempted + expect(fetchMock).toHaveBeenCalled(); + const streamCall = fetchMock.mock.calls.find((call) => + call[0]?.toString().includes('/v1/stream'), ); - expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ - { - payload: { - cacheAction: 'NONE', - cacheIsBlocking: false, - cacheIsFirstRead: true, - cacheStatus: 'HIT', - configOrigin: 'in-memory', - configUpdatedAt: 2, - duration: 0, - }, - ts: expect.any(Number), - type: 'FLAGS_CONFIG_READ', - }, - ]); + expect(streamCall).toBeDefined(); + + stream.close(); + await client.shutdown(); }); - it('uses the bundled definitions if no datafile is provided', async () => { - const bundledDefinitions: BundledDefinitions = { - definitions: { - flagA: { - environments: { - production: 1, - }, - variants: [false, true], - }, - }, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 2, - digest: 'abc', - revision: 2, - }; + it('should override auto-detection with buildStep: false', async () => { + process.env.CI = '1'; // Would normally trigger build step - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: bundledDefinitions, + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); const client = createClient(sdkKey, { - buildStep: true, fetch: fetchMock, + buildStep: false, // Explicitly override CI detection }); - await expect(client.evaluate('flagA')).resolves.toEqual({ - metrics: { - cacheStatus: 'HIT', - connectionState: 'disconnected', - mode: 'build', - evaluationMs: 0, - readMs: 0, - source: 'embedded', - }, - outcomeType: 'value', - reason: 'paused', - // value is expected to be true instead of false, showing - // the passed definition is used instead of the bundled one - value: true, + const initPromise = client.initialize(); + + stream.push({ + type: 'datafile', + data: makeBundled({ projectId: 'stream' }), }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; - expect(fetchMock).not.toHaveBeenCalled(); + const result = await client.evaluate('flagA'); - // flush - await client.shutdown(); + // Should use stream (buildStep: false overrides CI detection) + expect(result.metrics?.mode).toBe('streaming'); - // verify tracking - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), - headers: { - Authorization: 'Bearer vf_fake', - 'Content-Type': 'application/json', - 'User-Agent': 'VercelFlagsCore/1.0.1', - }, - method: 'POST', - }, - ); - expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ - { - payload: { - cacheAction: 'NONE', - cacheIsBlocking: false, - cacheIsFirstRead: true, - cacheStatus: 'HIT', - configOrigin: 'embedded', - configUpdatedAt: 2, - duration: 0, - }, - ts: expect.any(Number), - type: 'FLAGS_CONFIG_READ', - }, - ]); + stream.close(); + await client.shutdown(); }); + }); - it('returns defaultValue during build when no datafile and no bundled definitions are provided', async () => { + // --------------------------------------------------------------------------- + // Build step behavior + // --------------------------------------------------------------------------- + describe('build step behavior', () => { + it('should throw when bundled definitions missing during build (no defaultValue)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'missing-file', definitions: null, }); const client = createClient(sdkKey, { - buildStep: true, fetch: fetchMock, + buildStep: true, }); - // With defaultValue, evaluate should return it as an error result - const result = await client.evaluate('flagA', false); - - expect(result).toEqual({ - value: false, - reason: 'error', - errorMessage: expect.stringContaining( - 'No flag definitions available during build', - ), - }); + await expect(client.evaluate('flagA')).rejects.toThrow( + 'No flag definitions available during build', + ); - // No network requests should have been made (no fetching during build) expect(fetchMock).not.toHaveBeenCalled(); }); - }); - describe('failure behavior', () => { - it('should return defaultValue when all data sources fail', async () => { + it('should cache data after first build step read', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, + state: 'ok', + definitions: makeBundled(), }); - // No stream, no polling, no datafile, no bundled const client = createClient(sdkKey, { - buildStep: false, fetch: fetchMock, - stream: false, - polling: false, + buildStep: true, }); - const result = await client.evaluate('flagA', false); + const first = await client.evaluate('flagA'); + expect(first.metrics?.cacheStatus).toBe('HIT'); - expect(result).toEqual({ - value: false, - reason: 'error', - errorMessage: expect.stringContaining('No flag definitions available'), - }); + const second = await client.evaluate('flagA'); + expect(second.metrics?.cacheStatus).toBe('HIT'); + + // readBundledDefinitions should only be called once + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + + await client.shutdown(); }); - it('should throw when all data sources fail and no defaultValue provided', async () => { + it('should skip network when buildStep: true even if stream/polling configured', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, + state: 'ok', + definitions: makeBundled(), }); const client = createClient(sdkKey, { - buildStep: false, fetch: fetchMock, - stream: false, - polling: false, + buildStep: true, + stream: true, + polling: true, }); - await expect(client.evaluate('flagA')).rejects.toThrow( - 'No flag definitions available', - ); + const result = await client.evaluate('flagA'); + + expect(result.metrics?.source).toBe('embedded'); + expect(result.metrics?.mode).toBe('build'); + expect(fetchMock).not.toHaveBeenCalled(); + + await client.shutdown(); }); - it('should use bundled definitions when stream and polling are disabled', async () => { - const bundledDefinitions: BundledDefinitions = { + it('should use datafile over bundled in build step', async () => { + const providedDatafile = makeBundled({ + configUpdatedAt: 2, definitions: { flagA: { - environments: { - production: 1, - }, + environments: { production: 1 }, variants: [false, true], }, }, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 2, - digest: 'abc', - revision: 2, - }; + }); + + const bundled = makeBundled({ + configUpdatedAt: 1, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', - definitions: bundledDefinitions, + definitions: bundled, }); const client = createClient(sdkKey, { - buildStep: false, fetch: fetchMock, - stream: false, - polling: false, + buildStep: true, + datafile: providedDatafile, }); const result = await client.evaluate('flagA'); + // value true means variant index 1 (from provided datafile), not 0 (bundled) expect(result.value).toBe(true); - expect(result.reason).toBe('paused'); - expect(result.metrics?.source).toBe('embedded'); + expect(result.metrics?.source).toBe('in-memory'); + + await client.shutdown(); }); + }); - it('should use constructor datafile when stream and polling are disabled', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, + // --------------------------------------------------------------------------- + // Stream behavior + // --------------------------------------------------------------------------- + describe('stream behavior', () => { + it('should handle messages split across chunks', async () => { + const datafile = makeBundled({ projectId: 'test-project' }); + const fullMessage = JSON.stringify({ + type: 'datafile', + data: datafile, }); + const part1 = fullMessage.slice(0, 20); + const part2 = `${fullMessage.slice(20)}\n`; - const datafile: BundledDefinitions = { - definitions: { - flagA: { - environments: { - production: 1, - }, + const encoder = new TextEncoder(); + let streamController: ReadableStreamDefaultController; + + const body = new ReadableStream({ + start(c) { + streamController = c; + }, + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(body, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const initPromise = client.initialize(); + + // Send chunks separately + streamController!.enqueue(encoder.encode(part1)); + await vi.advanceTimersByTimeAsync(10); + streamController!.enqueue(encoder.encode(part2)); + await vi.advanceTimersByTimeAsync(0); + + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('in-memory'); + expect(result.metrics?.connectionState).toBe('connected'); + + streamController!.close(); + await client.shutdown(); + }); + + it('should update definitions when new datafile messages arrive', async () => { + const datafile1 = makeBundled({ + configUpdatedAt: 1, + definitions: { + flagA: { + environments: { production: 0 }, variants: [false, true], }, }, - segments: {}, - environment: 'production', - projectId: 'prj_123', + }); + const datafile2 = makeBundled({ configUpdatedAt: 2, - digest: 'abc', - revision: 2, - }; + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: datafile1 }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // First evaluate returns variant 0 (false) + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(false); + + // Push updated definitions + stream.push({ type: 'datafile', data: datafile2 }); + await vi.advanceTimersByTimeAsync(0); + + // Second evaluate returns variant 1 (true) + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(true); + + stream.close(); + await client.shutdown(); + }); + + it('should fall back to bundled when stream times out', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + // Stream opens but never sends data + const body = new ReadableStream({ start() {} }); + return Promise.resolve(new Response(body, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); const client = createClient(sdkKey, { - buildStep: false, fetch: fetchMock, - stream: false, polling: false, - datafile, }); - const result = await client.evaluate('flagA'); + const initPromise = client.initialize(); + + // Advance past the stream init timeout (3s) + await vi.advanceTimersByTimeAsync(3_000); + await initPromise; + const result = await client.evaluate('flagA'); expect(result.value).toBe(true); - expect(result.reason).toBe('paused'); - expect(result.metrics?.source).toBe('in-memory'); + expect(result.metrics?.source).toBe('embedded'); + expect(result.metrics?.connectionState).toBe('disconnected'); }); - }); - describe('creating a client', () => { - it('should not load bundled definitions or stream or poll on creation', () => { + it('should fall back to bundled when stream errors (4xx)', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + // Suppress expected error logs + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const client = createClient(sdkKey, { - buildStep: false, fetch: fetchMock, + polling: false, }); - expect(client).toBeDefined(); - expect(fetchMock).not.toHaveBeenCalled(); - // Bundled definitions are loaded lazily, not at construction time - expect(readBundledDefinitions).not.toHaveBeenCalled(); + const evalPromise = client.evaluate('flagA'); + + // The 401 aborts the stream but the init promise may hang until timeout + await vi.advanceTimersByTimeAsync(3_000); + + const result = await evalPromise; + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('embedded'); + + errorSpy.mockRestore(); }); - }); - describe('initializing the client', () => { - it('should init from the stream', async () => { - const datafile = { - definitions: {}, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }; + it('should fast-fail on 401 without waiting for stream timeout', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); - const messageStream = createMockStream(); + const evalPromise = client.evaluate('flagA'); + + // Only advance a tiny amount — well under the 3s stream timeout. + // If the 401 fast-fail works, evaluate resolves without the full timeout. + await vi.advanceTimersByTimeAsync(100); + + const result = await evalPromise; + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('embedded'); + + errorSpy.mockRestore(); + }); + + it('should use custom initTimeoutMs value', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/stream')) { - return messageStream.response; + const body = new ReadableStream({ start() {} }); + return Promise.resolve(new Response(body, { status: 200 })); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); const client = createClient(sdkKey, { - buildStep: false, fetch: fetchMock, + stream: { initTimeoutMs: 500 }, + polling: false, }); const initPromise = client.initialize(); - messageStream.push({ type: 'datafile', data: datafile }); + // Advance only 500ms (custom timeout) + await vi.advanceTimersByTimeAsync(500); + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.metrics?.source).toBe('embedded'); + }); + + it('should disable stream when stream: false', async () => { + const datafile = makeBundled(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + return Promise.resolve( + new Response(JSON.stringify(datafile), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: true, + }); + + await client.initialize(); await vi.advanceTimersByTimeAsync(0); - await initPromise; + // No stream requests should have been made + const streamCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/stream'), + ); + expect(streamCalls).toHaveLength(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0]![0].toString()).toContain('/v1/stream'); + await client.shutdown(); }); + }); - it('should fall back to bundled when stream hangs', async () => { - const datafile: BundledDefinitions = { - definitions: {}, - segments: {}, - environment: 'production', - projectId: 'prj_123', - configUpdatedAt: 1, - digest: 'abc', - revision: 1, - }; + // --------------------------------------------------------------------------- + // Polling behavior + // --------------------------------------------------------------------------- + describe('polling behavior', () => { + it('should use polling when enabled', async () => { + vi.useRealTimers(); // Polling uses real intervals + + let pollCount = 0; + const datafile = makeBundled(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(datafile), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: { intervalMs: 100, initTimeoutMs: 5000 }, + }); + + await client.initialize(); + + expect(pollCount).toBeGreaterThanOrEqual(1); + + // Wait for a few poll intervals + await new Promise((r) => setTimeout(r, 350)); + + expect(pollCount).toBeGreaterThanOrEqual(3); + + await client.shutdown(); + }); + + it('should disable polling when polling: false', async () => { + const datafile = makeBundled(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + }); + + await client.initialize(); + await vi.advanceTimersByTimeAsync(100); + + // No datafile fetch requests should have been made + const pollCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/datafile'), + ); + expect(pollCalls).toHaveLength(0); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Datafile option + // --------------------------------------------------------------------------- + describe('datafile option', () => { + it('should use provided datafile immediately', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const datafile = makeBundled({ projectId: 'provided' }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + datafile, + }); + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('in-memory'); + + stream.close(); + await client.shutdown(); + }); + + it('should work with datafile only (stream and polling disabled)', async () => { + const datafile = makeBundled(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + }); + + await client.initialize(); + const result = await client.evaluate('flagA'); + + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('in-memory'); + + // No network requests + const networkCalls = fetchMock.mock.calls.filter( + (call) => + call[0]?.toString().includes('/v1/stream') || + call[0]?.toString().includes('/v1/datafile'), + ); + expect(networkCalls).toHaveLength(0); + + await client.shutdown(); + }); + }); + // --------------------------------------------------------------------------- + // Stream/polling coordination + // --------------------------------------------------------------------------- + describe('stream/polling coordination', () => { + it('should fall back to bundled when stream times out (skip polling)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', - definitions: datafile, + definitions: makeBundled({ projectId: 'bundled' }), }); + let pollCount = 0; + fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/stream')) { - // stream opens but never sends initial data const body = new ReadableStream({ start() {} }); return Promise.resolve(new Response(body, { status: 200 })); } + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(makeBundled({ projectId: 'polled' })), { + status: 200, + }), + ); + } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createClient(sdkKey, { - buildStep: false, fetch: fetchMock, + stream: { initTimeoutMs: 100 }, + polling: { intervalMs: 50, initTimeoutMs: 5000 }, }); const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(100); + await initPromise; - // Advance past the stream init timeout (3s) - await vi.advanceTimersByTimeAsync(3_000); + const result = await client.evaluate('flagA'); + expect(result.metrics?.source).toBe('embedded'); + expect(pollCount).toBe(0); - // Should fall back directly to bundled — no polling attempted - await expect(initPromise).resolves.toBeUndefined(); + warnSpy.mockRestore(); + }); + + it('should fall back to bundled when stream fails (skip polling)', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled({ projectId: 'bundled' }), + }); + + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 500 })); + } + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(makeBundled({ projectId: 'polled' })), { + status: 200, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: { initTimeoutMs: 100 }, + polling: { intervalMs: 100, initTimeoutMs: 5000 }, + }); + + // Stream retries with backoff; advance timers so the init timeout fires + const resultPromise = client.evaluate('flagA'); + await vi.advanceTimersByTimeAsync(200); + const result = await resultPromise; + expect(result.metrics?.source).toBe('embedded'); + expect(pollCount).toBe(0); + + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('should never stream and poll simultaneously when stream is connected', async () => { + const stream = createMockStream(); + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(makeBundled()), { status: 200 }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: true, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Wait to see if any polls happen + await vi.advanceTimersByTimeAsync(200); + + expect(pollCount).toBe(0); + + stream.close(); + await client.shutdown(); + }); + + it('should use datafile immediately while starting background stream', async () => { + vi.useRealTimers(); // Need real timers for delayed stream + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return stream.response; + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const providedDatafile = makeBundled({ + projectId: 'provided', + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + datafile: providedDatafile, + stream: true, + polling: false, + }); + + // Initialize starts background stream connection + await client.initialize(); + + // First evaluate uses provided datafile immediately + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(false); // variant 0 from provided + expect(result1.metrics?.source).toBe('in-memory'); + + // Now push stream data (with newer configUpdatedAt) + stream.push({ + type: 'datafile', + data: makeBundled({ + projectId: 'stream', + configUpdatedAt: 2, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }), + }); + + // Wait for stream to deliver + await new Promise((r) => setTimeout(r, 50)); + + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(true); // variant 1 from stream + + stream.close(); + await client.shutdown(); + }); + + it('should not start polling from stream disconnect during initialization', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 500 })); + } + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + new Response(JSON.stringify(makeBundled()), { status: 200 }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: { initTimeoutMs: 5000 }, + polling: { intervalMs: 100, initTimeoutMs: 5000 }, + }); + + // Stream retries with backoff; advance timers so the init timeout fires + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(5100); + await initPromise; + + expect(pollCount).toBe(0); + + await client.shutdown(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + }); + + // --------------------------------------------------------------------------- + // getDatafile + // --------------------------------------------------------------------------- + describe('getDatafile', () => { + it('should return bundled definitions when called without initialize', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.getDatafile(); + expect(result.metrics.source).toBe('embedded'); + expect(result.metrics.cacheStatus).toBe('MISS'); + expect(result.metrics.connectionState).toBe('disconnected'); + + await client.shutdown(); + }); + + it('should fetch datafile when called without initialize and no bundled definitions', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const fetchedDatafile = makeBundled({ projectId: 'fetched' }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + return Promise.resolve( + new Response(JSON.stringify(fetchedDatafile), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.getDatafile(); + expect(result.metrics.source).toBe('remote'); + expect(result.metrics.cacheStatus).toBe('MISS'); + + await client.shutdown(); + }); + + it('should throw when called without initialize and all sources fail', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + return Promise.resolve(new Response(null, { status: 500 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.getDatafile()).rejects.toThrow( + 'No flag definitions available', + ); + + await client.shutdown(); + }); + + it('should return cached data when stream is connected', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + + const initPromise = client.initialize(); + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + const result = await client.getDatafile(); + expect(result.metrics.source).toBe('in-memory'); + expect(result.metrics.cacheStatus).toBe('HIT'); + expect(result.metrics.connectionState).toBe('connected'); + + stream.close(); + await client.shutdown(); + }); + + it('should use build step path when CI=1', async () => { + process.env.CI = '1'; + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + const result = await client.getDatafile(); + + expect(result.metrics.source).toBe('embedded'); + expect(result.metrics.cacheStatus).toBe('MISS'); + + await client.shutdown(); + }); + + it('should return cached data on repeated calls', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result1 = await client.getDatafile(); + expect(result1.metrics.cacheStatus).toBe('MISS'); + + const result2 = await client.getDatafile(); + expect(result2.metrics.cacheStatus).toBe('STALE'); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // getFallbackDatafile + // --------------------------------------------------------------------------- + describe('getFallbackDatafile', () => { + it('should return bundled definitions when available', async () => { + const bundled = makeBundled(); + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: bundled, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.getFallbackDatafile(); + expect(result).toEqual(bundled); + + await client.shutdown(); + }); + + it('should throw FallbackNotFoundError for missing-file state', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.getFallbackDatafile()).rejects.toThrow( + 'Bundled definitions file not found', + ); + + try { + await client.getFallbackDatafile(); + } catch (error) { + expect((error as Error).name).toBe('FallbackNotFoundError'); + } + + await client.shutdown(); + }); + + it('should throw FallbackEntryNotFoundError for missing-entry state', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-entry', + definitions: null, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.getFallbackDatafile()).rejects.toThrow( + 'No bundled definitions found for SDK key', + ); + + try { + await client.getFallbackDatafile(); + } catch (error) { + expect((error as Error).name).toBe('FallbackEntryNotFoundError'); + } + + await client.shutdown(); + }); + + it('should throw for unexpected-error state', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'unexpected-error', + definitions: null, + error: new Error('Some error'), + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.getFallbackDatafile()).rejects.toThrow( + 'Failed to read bundled definitions', + ); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // configUpdatedAt guard + // --------------------------------------------------------------------------- + describe('configUpdatedAt guard', () => { + it('should not overwrite newer data with older stream message', async () => { + vi.useRealTimers(); + + const newerDatafile = makeBundled({ + configUpdatedAt: 2000, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const olderDatafile = makeBundled({ + configUpdatedAt: 1000, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + // Send newer data first + stream.push({ type: 'datafile', data: newerDatafile }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + // Then send older data + stream.push({ type: 'datafile', data: olderDatafile }); + await new Promise((r) => setTimeout(r, 50)); + + // Should still have newer data (older message was rejected) + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = newer + + stream.close(); + await client.shutdown(); + }); + + it('should skip stream data with equal configUpdatedAt', async () => { + vi.useRealTimers(); + + const data1 = makeBundled({ + configUpdatedAt: 1000, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + const data2 = makeBundled({ + configUpdatedAt: 1000, // Same + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: data1 }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + stream.push({ type: 'datafile', data: data2 }); + await new Promise((r) => setTimeout(r, 50)); + + // Should have kept first data (equal configUpdatedAt is not newer) + const result = await client.evaluate('flagA'); + expect(result.value).toBe(false); // variant 0 = data1 + + stream.close(); + await client.shutdown(); + }); + + it('should accept updates when current data has no configUpdatedAt', async () => { + vi.useRealTimers(); + + const providedDatafile = makeBundled({ + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + // Remove configUpdatedAt to simulate a plain DatafileInput + delete (providedDatafile as Record).configUpdatedAt; + + const streamData = makeBundled({ + configUpdatedAt: 1000, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + datafile: providedDatafile, + polling: false, + }); + + await client.initialize(); + + // Initial evaluate uses provided datafile (variant 0) + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(false); + + // Push stream data with configUpdatedAt + stream.push({ type: 'datafile', data: streamData }); + await new Promise((r) => setTimeout(r, 50)); + + // Should accept stream data + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(true); // variant 1 = stream + + stream.close(); + await client.shutdown(); + }); + + it('should handle configUpdatedAt as string', async () => { + vi.useRealTimers(); + + const newerDatafile = { + ...makeBundled({ + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }), + configUpdatedAt: '2000' as unknown as number, + }; + + const olderDatafile = { + ...makeBundled({ + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }), + configUpdatedAt: '1000' as unknown as number, + }; + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: newerDatafile }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + stream.push({ type: 'datafile', data: olderDatafile }); + await new Promise((r) => setTimeout(r, 50)); + + // Should still have newer data + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = newer + + stream.close(); + await client.shutdown(); + }); + + it('should accept updates when configUpdatedAt is a non-numeric string', async () => { + vi.useRealTimers(); + + const currentData = { + ...makeBundled({ + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }), + configUpdatedAt: 'not-a-number' as unknown as number, + }; + + const newData = makeBundled({ + configUpdatedAt: 1000, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: currentData }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + stream.push({ type: 'datafile', data: newData }); + await new Promise((r) => setTimeout(r, 50)); + + // Should accept update since current configUpdatedAt is unparseable + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = newData + + stream.close(); + await client.shutdown(); + }); + + it('should not overwrite newer in-memory data via getDatafile', async () => { + vi.useRealTimers(); + + const newerDatafile = makeBundled({ + configUpdatedAt: 2000, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: newerDatafile }); + await new Promise((r) => setTimeout(r, 10)); + await initPromise; + + // getDatafile and then evaluate — data should still be newer + await client.getDatafile(); + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = newer + + stream.close(); + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Evaluate behavior + // --------------------------------------------------------------------------- + describe('evaluate behavior', () => { + it('should return FLAG_NOT_FOUND with defaultValue for missing flag', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled(), + buildStep: true, + }); + + const result = await client.evaluate('nonexistent-flag', 'default'); + + expect(result.value).toBe('default'); + expect(result.reason).toBe('error'); + expect(result.errorCode).toBe('FLAG_NOT_FOUND'); + expect(result.errorMessage).toContain( + 'Definition not found for flag "nonexistent-flag"', + ); + }); + + it('should evaluate existing paused flag', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled(), + buildStep: true, + }); + + const result = await client.evaluate('flagA'); + + expect(result.value).toBe(true); + expect(result.reason).toBe('paused'); + }); + + it('should pass entities for targeting evaluation', async () => { + const datafile = makeBundled({ + definitions: { + 'targeted-flag': { + environments: { + production: { + // targets is the packed shorthand for targeting rules + targets: [{}, { user: { id: ['user-123'] } }], + fallthrough: 0, + }, + }, + variants: ['default', 'targeted'], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + const result = await client.evaluate('targeted-flag', 'default', { + user: { id: 'user-123' }, + }); + + expect(result.value).toBe('targeted'); + expect(result.reason).toBe('target_match'); + }); + + it('should use empty entities when not provided', async () => { + const datafile = makeBundled({ + definitions: { + 'targeted-flag': { + environments: { + production: { + targets: [{}, { user: { id: ['user-123'] } }], + fallthrough: 0, + }, + }, + variants: ['default', 'targeted'], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + const result = await client.evaluate('targeted-flag'); + + expect(result.value).toBe('default'); + expect(result.reason).toBe('fallthrough'); + }); + + it('should work with different value types', async () => { + const datafile = makeBundled({ + definitions: { + boolFlag: { + environments: { production: 0 }, + variants: [true], + }, + stringFlag: { + environments: { production: 0 }, + variants: ['hello'], + }, + numberFlag: { + environments: { production: 0 }, + variants: [42], + }, + objectFlag: { + environments: { production: 0 }, + variants: [{ key: 'value' }], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + expect((await client.evaluate('boolFlag')).value).toBe(true); + expect((await client.evaluate('stringFlag')).value).toBe('hello'); + expect((await client.evaluate('numberFlag')).value).toBe(42); + expect((await client.evaluate('objectFlag')).value).toEqual({ + key: 'value', + }); + }); + + it('should call internalReportValue when projectId exists', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled({ projectId: 'my-project-id' }), + buildStep: true, + }); + + await client.evaluate('flagA'); + + expect(internalReportValue).toHaveBeenCalledWith('flagA', true, { + originProjectId: 'my-project-id', + originProvider: 'vercel', + reason: 'paused', + outcomeType: 'value', + }); + }); + + it('should not call internalReportValue when projectId is missing', async () => { + const datafile = makeBundled(); + delete (datafile as Record).projectId; + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + await client.evaluate('flagA'); + + expect(internalReportValue).not.toHaveBeenCalled(); + }); + + it('should call internalReportValue with error reason when flag is not found', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled({ projectId: 'my-project-id' }), + buildStep: true, + }); + + await client.evaluate('nonexistent-flag', 'default'); + + expect(internalReportValue).toHaveBeenCalledWith( + 'nonexistent-flag', + 'default', + { + originProjectId: 'my-project-id', + originProvider: 'vercel', + reason: 'error', + }, + ); + }); + }); + + // --------------------------------------------------------------------------- + // Concurrent initialization + // --------------------------------------------------------------------------- + describe('concurrent initialization', () => { + it('should deduplicate concurrent initialize() calls', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + + // Call initialize three times concurrently + const p1 = client.initialize(); + const p2 = client.initialize(); + const p3 = client.initialize(); + + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + + await Promise.all([p1, p2, p3]); + + // Stream should have been fetched only once + const streamCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/stream'), + ); + expect(streamCalls).toHaveLength(1); + + stream.close(); + await client.shutdown(); + }); + + it('should deduplicate concurrent evaluate() calls that trigger initialize', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + + // Three concurrent evaluates trigger lazy initialization + const p1 = client.evaluate('flagA'); + const p2 = client.evaluate('flagA'); + const p3 = client.evaluate('flagA'); + + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + // All should have the same value + expect(r1.value).toBe(true); + expect(r2.value).toBe(true); + expect(r3.value).toBe(true); + + // Stream should have been fetched only once + const streamCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/stream'), + ); + expect(streamCalls).toHaveLength(1); + + stream.close(); + await client.shutdown(); + }); + + it('should allow re-initialization after failure', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + let fetchCallCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + fetchCallCount++; + if (fetchCallCount === 1) { + // First fetch fails + return Promise.resolve(new Response(null, { status: 500 })); + } + // Second fetch succeeds + return Promise.resolve( + new Response(JSON.stringify(makeBundled()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + // First initialize fails (no bundled, fetch returns 500) + await expect(client.initialize()).rejects.toThrow(); + + // Second initialize should retry — fetch now succeeds + await client.initialize(); + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + + await client.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Multiple clients + // --------------------------------------------------------------------------- + describe('multiple clients', () => { + it('should maintain independent state for each client', async () => { + const datafileA = makeBundled({ + definitions: { + flagA: { + environments: { production: 0 }, + variants: ['a-value', 'b-value'], + }, + }, + }); + + const datafileB = makeBundled({ + definitions: { + flagA: { + environments: { production: 1 }, + variants: ['a-value', 'b-value'], + }, + }, + }); + + const clientA = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: datafileA, + buildStep: true, + }); + + const clientB = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: datafileB, + buildStep: true, + }); + + const resultA = await clientA.evaluate('flagA'); + const resultB = await clientB.evaluate('flagA'); + + expect(resultA.value).toBe('a-value'); + expect(resultB.value).toBe('b-value'); + + // Shutdown one, other should still work + await clientA.shutdown(); + + const resultB2 = await clientB.evaluate('flagA'); + expect(resultB2.value).toBe('b-value'); + + await clientB.shutdown(); + }); + }); + + // --------------------------------------------------------------------------- + // Lazy initialization + // --------------------------------------------------------------------------- + describe('lazy initialization', () => { + it('should not load bundled definitions or stream or poll on creation', () => { + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + }); + + expect(client).toBeDefined(); + expect(fetchMock).not.toHaveBeenCalled(); + // Bundled definitions are loaded lazily, not at construction time + expect(readBundledDefinitions).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // Failure behavior (no sources) + // --------------------------------------------------------------------------- + describe('failure behavior (no sources)', () => { + it('should return defaultValue when all data sources fail', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.evaluate('flagA', false); + + expect(result).toEqual({ + value: false, + reason: 'error', + errorMessage: expect.stringContaining('No flag definitions available'), + }); + }); + + it('should throw when all data sources fail and no defaultValue provided', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.evaluate('flagA')).rejects.toThrow( + 'No flag definitions available', + ); + }); + + it('should use bundled definitions when stream and polling are disabled', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { + buildStep: false, + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.evaluate('flagA'); + + expect(result.value).toBe(true); + expect(result.reason).toBe('paused'); + expect(result.metrics?.source).toBe('embedded'); + }); + }); + + // --------------------------------------------------------------------------- + // Usage tracking + // --------------------------------------------------------------------------- + describe('usage tracking', () => { + it('should report FLAGS_CONFIG_READ when using provided datafile in build step', async () => { + const passedDatafile = makeBundled({ + configUpdatedAt: 2, + revision: 2, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const bundledDatafile = makeBundled({ + configUpdatedAt: 1, + revision: 1, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: bundledDatafile, + }); + + const client = createClient(sdkKey, { + buildStep: true, + fetch: fetchMock, + datafile: passedDatafile, + }); + + await expect(client.evaluate('flagA')).resolves.toEqual({ + metrics: { + cacheStatus: 'HIT', + connectionState: 'disconnected', + mode: 'build', + evaluationMs: 0, + readMs: 0, + source: 'in-memory', + }, + outcomeType: 'value', + reason: 'paused', + value: true, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + + await client.shutdown(); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), + headers: { + Authorization: 'Bearer vf_fake', + 'Content-Type': 'application/json', + 'User-Agent': 'VercelFlagsCore/1.0.1', + }, + method: 'POST', + }, + ); + expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ + { + payload: { + cacheAction: 'NONE', + cacheIsBlocking: false, + cacheIsFirstRead: true, + cacheStatus: 'HIT', + configOrigin: 'in-memory', + configUpdatedAt: 2, + duration: 0, + }, + ts: expect.any(Number), + type: 'FLAGS_CONFIG_READ', + }, + ]); + }); + + it('should report FLAGS_CONFIG_READ when using bundled definitions in build step', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled({ configUpdatedAt: 2, revision: 2 }), + }); + + const client = createClient(sdkKey, { + buildStep: true, + fetch: fetchMock, + }); + + await expect(client.evaluate('flagA')).resolves.toEqual({ + metrics: { + cacheStatus: 'HIT', + connectionState: 'disconnected', + mode: 'build', + evaluationMs: 0, + readMs: 0, + source: 'embedded', + }, + outcomeType: 'value', + reason: 'paused', + value: true, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + + await client.shutdown(); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), + headers: { + Authorization: 'Bearer vf_fake', + 'Content-Type': 'application/json', + 'User-Agent': 'VercelFlagsCore/1.0.1', + }, + method: 'POST', + }, + ); + expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ + { + payload: { + cacheAction: 'NONE', + cacheIsBlocking: false, + cacheIsFirstRead: true, + cacheStatus: 'HIT', + configOrigin: 'embedded', + configUpdatedAt: 2, + duration: 0, + }, + ts: expect.any(Number), + type: 'FLAGS_CONFIG_READ', + }, + ]); }); }); }); From 9d7d6d4c7ca7395d7e1b616e7ccf2c007c874685 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 15:45:23 +0200 Subject: [PATCH 30/65] tests --- .../vercel-flags-core/src/black-box.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index ec94db8a..627961e0 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -1712,6 +1712,110 @@ describe('Controller (black-box)', () => { expect(internalReportValue).not.toHaveBeenCalled(); }); + it('should call internalReportValue with target_match reason', async () => { + const datafile = makeBundled({ + projectId: 'my-project-id', + definitions: { + 'targeted-flag': { + environments: { + production: { + targets: [{}, { user: { id: ['user-123'] } }], + fallthrough: 0, + }, + }, + variants: ['default', 'targeted'], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + await client.evaluate('targeted-flag', 'default', { + user: { id: 'user-123' }, + }); + + expect(internalReportValue).toHaveBeenCalledWith( + 'targeted-flag', + 'targeted', + { + originProjectId: 'my-project-id', + originProvider: 'vercel', + reason: 'target_match', + outcomeType: 'value', + }, + ); + }); + + it('should call internalReportValue with fallthrough reason', async () => { + const datafile = makeBundled({ + projectId: 'my-project-id', + definitions: { + 'targeted-flag': { + environments: { + production: { + targets: [{}, { user: { id: ['user-123'] } }], + fallthrough: 0, + }, + }, + variants: ['default', 'targeted'], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile, + buildStep: true, + }); + + // No entities provided, so no target matches → fallthrough + await client.evaluate('targeted-flag'); + + expect(internalReportValue).toHaveBeenCalledWith( + 'targeted-flag', + 'default', + { + originProjectId: 'my-project-id', + originProvider: 'vercel', + reason: 'fallthrough', + outcomeType: 'value', + }, + ); + }); + + it('should not include outcomeType for error reason in internalReportValue', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled({ projectId: 'my-project-id' }), + buildStep: true, + }); + + await client.evaluate('nonexistent-flag', 'fallback'); + + expect(internalReportValue).toHaveBeenCalledWith( + 'nonexistent-flag', + 'fallback', + { + originProjectId: 'my-project-id', + originProvider: 'vercel', + reason: 'error', + }, + ); + // Verify outcomeType is NOT present in the call + const callArgs = vi.mocked(internalReportValue).mock.calls[0]; + expect(callArgs[2]).not.toHaveProperty('outcomeType'); + }); + it('should call internalReportValue with error reason when flag is not found', async () => { const client = createClient(sdkKey, { fetch: fetchMock, @@ -2075,6 +2179,84 @@ describe('Controller (black-box)', () => { ]); }); + it('should only track one FLAGS_CONFIG_READ during build step', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled({ configUpdatedAt: 1 }), + }); + + const client = createClient(sdkKey, { + buildStep: true, + fetch: fetchMock, + }); + + // Multiple evaluates during build + await client.evaluate('flagA'); + await client.evaluate('flagA'); + await client.evaluate('flagA'); + + await client.shutdown(); + + // Only one ingest call, despite multiple evaluate() calls + const ingestCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/ingest'), + ); + expect(ingestCalls).toHaveLength(1); + + const events = JSON.parse(ingestCalls[0]?.[1]?.body as string); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('FLAGS_CONFIG_READ'); + }); + + it('should report FLAGS_CONFIG_READ with FOLLOWING cacheAction when streaming', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + if (url.includes('/v1/ingest')) { + return Promise.resolve(new Response(null, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + const initPromise = client.initialize(); + + stream.push({ + type: 'datafile', + data: makeBundled({ configUpdatedAt: 5 }), + }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Evaluate while streaming + await client.evaluate('flagA'); + + await client.shutdown(); + + const ingestCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/ingest'), + ); + expect(ingestCalls.length).toBeGreaterThanOrEqual(1); + + const events = JSON.parse(ingestCalls[0]?.[1]?.body as string); + const readEvent = events.find( + (e: { type: string }) => e.type === 'FLAGS_CONFIG_READ', + ); + expect(readEvent).toBeDefined(); + expect(readEvent.payload.cacheAction).toBe('FOLLOWING'); + expect(readEvent.payload.configOrigin).toBe('in-memory'); + expect(readEvent.payload.cacheIsFirstRead).toBe(true); + expect(readEvent.payload.configUpdatedAt).toBe(5); + + stream.close(); + }); + it('should report FLAGS_CONFIG_READ when using bundled definitions in build step', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', From 35ce0f7134afcc86e1d441a5df18c3de1288eb86 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 17:28:02 +0200 Subject: [PATCH 31/65] progress --- .../vercel-flags-core/src/black-box.test.ts | 196 +++++++++++++++--- .../vercel-flags-core/src/controller/index.ts | 14 +- .../vercel-flags-core/src/index.make.test.ts | 12 +- packages/vercel-flags-core/src/index.make.ts | 14 +- 4 files changed, 203 insertions(+), 33 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 627961e0..48acf72a 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -73,11 +73,25 @@ function makeBundled( }; } +const ingestRequestHeaders = Object.freeze({ + Authorization: 'Bearer vf_fake', + 'Content-Type': 'application/json', + 'User-Agent': 'VercelFlagsCore/1.0.1', +}); + +const datafileRequestHeaders = Object.freeze({ + Authorization: 'Bearer vf_fake', + 'User-Agent': 'VercelFlagsCore/1.0.1', +}); + const originalEnv = { ...process.env }; describe('Controller (black-box)', () => { + const date = new Date(); + beforeEach(() => { vi.useFakeTimers(); + vi.setSystemTime(date); vi.mocked(readBundledDefinitions).mockReset(); vi.mocked(internalReportValue).mockReset(); fetchMock.mockReset(); @@ -98,7 +112,7 @@ describe('Controller (black-box)', () => { it('should throw for missing SDK key', () => { expect(() => createClient('', { fetch: fetchMock, stream: false, polling: false }), - ).toThrow('flags: Missing sdkKey'); + ).toThrow('@vercel/flags-core: Missing sdkKey'); }); it('should throw for SDK key not starting with vf_', () => { @@ -108,7 +122,7 @@ describe('Controller (black-box)', () => { stream: false, polling: false, }), - ).toThrow('flags: Missing sdkKey'); + ).toThrow('@vercel/flags-core: Missing sdkKey'); }); it('should throw for non-string SDK key', () => { @@ -118,7 +132,9 @@ describe('Controller (black-box)', () => { stream: false, polling: false, }), - ).toThrow(); + ).toThrow( + '@vercel/flags-core: Invalid sdkKey. Expected string, got number', + ); }); it('should accept valid SDK key', () => { @@ -153,6 +169,29 @@ describe('Controller (black-box)', () => { expect(fetchMock).not.toHaveBeenCalled(); await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'embedded', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); }); it('should detect build step when NEXT_PHASE=phase-production-build', async () => { @@ -171,6 +210,29 @@ describe('Controller (black-box)', () => { expect(fetchMock).not.toHaveBeenCalled(); await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'embedded', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); }); it('should NOT detect build step when neither CI nor NEXT_PHASE is set', async () => { @@ -200,7 +262,12 @@ describe('Controller (black-box)', () => { expect(streamCall).toBeDefined(); stream.close(); + expect(fetchMock).toHaveBeenCalledTimes(1); await client.shutdown(); + await vi.advanceTimersByTimeAsync(0); + // Still 1 — shutdown flushes the usage tracker, but no evaluate() + // was called, so there are no FLAGS_CONFIG_READ events to send. + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should override auto-detection with buildStep: false', async () => { @@ -232,9 +299,34 @@ describe('Controller (black-box)', () => { // Should use stream (buildStep: false overrides CI detection) expect(result.metrics?.mode).toBe('streaming'); + expect(fetchMock).toHaveBeenCalledTimes(1); - stream.close(); await client.shutdown(); + stream.close(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); }); }); @@ -242,12 +334,83 @@ describe('Controller (black-box)', () => { // Build step behavior // --------------------------------------------------------------------------- describe('build step behavior', () => { - it('should throw when bundled definitions missing during build (no defaultValue)', async () => { + it('should fall back to one-time fetch when bundled definitions missing during build', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) + return Promise.resolve(Response.json(makeBundled())); + return Promise.resolve(new Response('', { status: 200 })); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + buildStep: true, + }); + + // run two in parallel to ensure we still only track one read + const [result] = await Promise.all([ + client.evaluate('flagA'), + client.evaluate('flagB'), + ]); + + expect(result.value).toBe(true); + expect(result.metrics?.mode).toBe('build'); + expect(result.metrics?.source).toBe('remote'); + + const fetchCall = fetchMock.mock.calls.find((call) => + call[0]?.toString().includes('/v1/datafile'), + ); + expect(fetchCall).toBeDefined(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + signal: expect.any(AbortSignal), + headers: datafileRequestHeaders, + }, + ); + + await client.shutdown(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); + }); + + it('should throw when bundled definitions missing and fetch fails during build (no defaultValue)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'missing-file', definitions: null, }); + fetchMock.mockRejectedValue(new Error('network error')); + const client = createClient(sdkKey, { fetch: fetchMock, buildStep: true, @@ -256,8 +419,6 @@ describe('Controller (black-box)', () => { await expect(client.evaluate('flagA')).rejects.toThrow( 'No flag definitions available during build', ); - - expect(fetchMock).not.toHaveBeenCalled(); }); it('should cache data after first build step read', async () => { @@ -1653,10 +1814,6 @@ describe('Controller (black-box)', () => { environments: { production: 0 }, variants: [42], }, - objectFlag: { - environments: { production: 0 }, - variants: [{ key: 'value' }], - }, }, }); @@ -1671,9 +1828,6 @@ describe('Controller (black-box)', () => { expect((await client.evaluate('boolFlag')).value).toBe(true); expect((await client.evaluate('stringFlag')).value).toBe('hello'); expect((await client.evaluate('numberFlag')).value).toBe(42); - expect((await client.evaluate('objectFlag')).value).toEqual({ - key: 'value', - }); }); it('should call internalReportValue when projectId exists', async () => { @@ -1813,7 +1967,7 @@ describe('Controller (black-box)', () => { ); // Verify outcomeType is NOT present in the call const callArgs = vi.mocked(internalReportValue).mock.calls[0]; - expect(callArgs[2]).not.toHaveProperty('outcomeType'); + expect(callArgs?.[2]).not.toHaveProperty('outcomeType'); }); it('should call internalReportValue with error reason when flag is not found', async () => { @@ -2154,11 +2308,7 @@ describe('Controller (black-box)', () => { 'https://flags.vercel.com/v1/ingest', { body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), - headers: { - Authorization: 'Bearer vf_fake', - 'Content-Type': 'application/json', - 'User-Agent': 'VercelFlagsCore/1.0.1', - }, + headers: ingestRequestHeaders, method: 'POST', }, ); @@ -2291,11 +2441,7 @@ describe('Controller (black-box)', () => { 'https://flags.vercel.com/v1/ingest', { body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), - headers: { - Authorization: 'Bearer vf_fake', - 'Content-Type': 'application/json', - 'User-Agent': 'VercelFlagsCore/1.0.1', - }, + headers: ingestRequestHeaders, method: 'POST', }, ); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index f9899850..66a29f67 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -575,12 +575,24 @@ export class Controller implements ControllerInterface { } /** - * Loads data for a build step: bundled definitions only (no network). + * Loads data for a build step: bundled → one-time fetch. */ private async loadBuildData(): Promise { const bundled = await this.bundledSource.tryLoad(); if (bundled) return tagData(bundled, 'bundled'); + // Fallback: one-time fetch + try { + const fetched = await fetchDatafile({ + host: this.options.host, + sdkKey: this.options.sdkKey, + fetch: this.options.fetch, + }); + return tagData(fetched, 'fetched'); + } catch { + // fetch failed — fall through to throw + } + throw new Error( '@vercel/flags-core: No flag definitions available during build. ' + 'Provide a datafile or bundled definitions.', diff --git a/packages/vercel-flags-core/src/index.make.test.ts b/packages/vercel-flags-core/src/index.make.test.ts index efdeb289..abe294cd 100644 --- a/packages/vercel-flags-core/src/index.make.test.ts +++ b/packages/vercel-flags-core/src/index.make.test.ts @@ -87,7 +87,9 @@ describe('make', () => { const createRawClient = createMockCreateRawClient(); const { createClient } = make(createRawClient); - expect(() => createClient('')).toThrow('flags: Missing sdkKey'); + expect(() => createClient('')).toThrow( + '@vercel/flags-core: Missing sdkKey', + ); }); it('should throw for invalid connection string', () => { @@ -95,7 +97,7 @@ describe('make', () => { const { createClient } = make(createRawClient); expect(() => createClient('invalid_string')).toThrow( - 'flags: Missing sdkKey', + '@vercel/flags-core: Missing sdkKey', ); }); @@ -105,7 +107,7 @@ describe('make', () => { expect(() => createClient('flags:edgeConfigId=ecfg_123&edgeConfigToken=token'), - ).toThrow('flags: Missing sdkKey'); + ).toThrow('@vercel/flags-core: Missing sdkKey'); }); }); @@ -142,7 +144,9 @@ describe('make', () => { const { flagsClient } = make(createRawClient); - expect(() => flagsClient.evaluate).toThrow('flags: Missing sdkKey'); + expect(() => flagsClient.evaluate).toThrow( + '@vercel/flags-core: Missing sdkKey', + ); }); it('should cache the client after first access', () => { diff --git a/packages/vercel-flags-core/src/index.make.ts b/packages/vercel-flags-core/src/index.make.ts index 593f3d1c..e1af5d93 100644 --- a/packages/vercel-flags-core/src/index.make.ts +++ b/packages/vercel-flags-core/src/index.make.ts @@ -31,14 +31,22 @@ export function make( sdkKeyOrConnectionString: string, options?: CreateClientOptions, ): FlagsClient { - if (!sdkKeyOrConnectionString) throw new Error('flags: Missing sdkKey'); + if (!sdkKeyOrConnectionString) + throw new Error('@vercel/flags-core: Missing sdkKey'); + + if (typeof sdkKeyOrConnectionString !== 'string') + throw new Error( + `@vercel/flags-core: Invalid sdkKey. Expected string, got ${typeof sdkKeyOrConnectionString}`, + ); // Parse connection string if needed (e.g., "flags:edgeConfigId=...&sdkKey=vf_xxx") const sdkKey = parseSdkKeyFromFlagsConnectionString( sdkKeyOrConnectionString, ); if (!sdkKey) { - throw new Error('flags: Missing sdkKey in connection string'); + throw new Error( + '@vercel/flags-core: Missing sdkKey in connection string', + ); } // sdk key contains the environment @@ -62,7 +70,7 @@ export function make( const sdkKey = parseSdkKeyFromFlagsConnectionString(process.env.FLAGS); if (!sdkKey) { - throw new Error('flags: Missing sdkKey'); + throw new Error('@vercel/flags-core: Missing sdkKey'); } _defaultFlagsClient = createClient(sdkKey); } From 2f57032094fb627138af38e73dfb90c4ef4b5198 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 17:38:42 +0200 Subject: [PATCH 32/65] adjust --- packages/vercel-flags-core/src/black-box.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 48acf72a..6a89b839 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -560,6 +560,7 @@ describe('Controller (black-box)', () => { it('should update definitions when new datafile messages arrive', async () => { const datafile1 = makeBundled({ + revision: 1, configUpdatedAt: 1, definitions: { flagA: { @@ -569,6 +570,7 @@ describe('Controller (black-box)', () => { }, }); const datafile2 = makeBundled({ + revision: 2, configUpdatedAt: 2, definitions: { flagA: { @@ -642,7 +644,7 @@ describe('Controller (black-box)', () => { expect(result.metrics?.connectionState).toBe('disconnected'); }); - it('should fall back to bundled when stream errors (4xx)', async () => { + it('should fall back to bundled when stream errors (502)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled(), @@ -651,7 +653,7 @@ describe('Controller (black-box)', () => { fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/stream')) { - return Promise.resolve(new Response(null, { status: 401 })); + return Promise.resolve(new Response(null, { status: 502 })); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); From 0d73afa559d2c86fc1e2a72ce2786d429024408b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 17:55:52 +0200 Subject: [PATCH 33/65] don't report on 401s; use Response.json --- .../vercel-flags-core/src/black-box.test.ts | 108 +++++++++++------- .../vercel-flags-core/src/controller/index.ts | 15 ++- .../src/controller/stream-connection.ts | 9 +- 3 files changed, 89 insertions(+), 43 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 6a89b839..62512503 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -694,10 +694,7 @@ describe('Controller (black-box)', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const client = createClient(sdkKey, { - fetch: fetchMock, - polling: false, - }); + const client = createClient(sdkKey, { fetch: fetchMock }); const evalPromise = client.evaluate('flagA'); @@ -710,6 +707,12 @@ describe('Controller (black-box)', () => { expect(result.metrics?.source).toBe('embedded'); errorSpy.mockRestore(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + await client.shutdown(); + await vi.advanceTimersByTimeAsync(0); + // No ingest call — usage tracking is suppressed after a 401 + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should use custom initTimeoutMs value', async () => { @@ -749,12 +752,7 @@ describe('Controller (black-box)', () => { fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/datafile')) { - return Promise.resolve( - new Response(JSON.stringify(datafile), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); + return Promise.resolve(Response.json(datafile)); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); @@ -792,12 +790,7 @@ describe('Controller (black-box)', () => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/datafile')) { pollCount++; - return Promise.resolve( - new Response(JSON.stringify(datafile), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); + return Promise.resolve(Response.json(datafile)); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); @@ -920,9 +913,7 @@ describe('Controller (black-box)', () => { if (url.includes('/v1/datafile')) { pollCount++; return Promise.resolve( - new Response(JSON.stringify(makeBundled({ projectId: 'polled' })), { - status: 200, - }), + Response.json(makeBundled({ projectId: 'polled' })), ); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); @@ -963,9 +954,7 @@ describe('Controller (black-box)', () => { if (url.includes('/v1/datafile')) { pollCount++; return Promise.resolve( - new Response(JSON.stringify(makeBundled({ projectId: 'polled' })), { - status: 200, - }), + Response.json(makeBundled({ projectId: 'polled' })), ); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); @@ -1000,9 +989,7 @@ describe('Controller (black-box)', () => { if (url.includes('/v1/stream')) return stream.response; if (url.includes('/v1/datafile')) { pollCount++; - return Promise.resolve( - new Response(JSON.stringify(makeBundled()), { status: 200 }), - ); + return Promise.resolve(Response.json(makeBundled())); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); @@ -1106,9 +1093,7 @@ describe('Controller (black-box)', () => { } if (url.includes('/v1/datafile')) { pollCount++; - return Promise.resolve( - new Response(JSON.stringify(makeBundled()), { status: 200 }), - ); + return Promise.resolve(Response.json(makeBundled())); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); @@ -1170,12 +1155,7 @@ describe('Controller (black-box)', () => { fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/datafile')) { - return Promise.resolve( - new Response(JSON.stringify(fetchedDatafile), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); + return Promise.resolve(Response.json(fetchedDatafile)); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); @@ -2066,6 +2046,59 @@ describe('Controller (black-box)', () => { await client.shutdown(); }); + it('should start only one retry loop when concurrent evaluate() calls hit a failing stream', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + // Stream returns 502 — triggers retry loop + return Promise.resolve(new Response(null, { status: 502 })); + } + return Promise.resolve(new Response('', { status: 200 })); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: { initTimeoutMs: 100 }, + polling: false, + }); + + // Three concurrent evaluates all trigger lazy initialization + const p1 = client.evaluate('flagA'); + const p2 = client.evaluate('flagA'); + const p3 = client.evaluate('flagA'); + + // Advance past the stream init timeout + await vi.advanceTimersByTimeAsync(100); + + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + // All should resolve (falling back to bundled after stream timeout) + expect(r1.value).toBe(true); + expect(r2.value).toBe(true); + expect(r3.value).toBe(true); + + // Concurrent callers share the same init promise, so only one retry + // loop is started. With 100ms timeout: attempt at retryCount=0 fails, + // backoff(1)=0ms → immediate retry at retryCount=1 fails, + // backoff(2)=~1s+ exceeds 100ms timeout → falls back to bundled. + // So exactly 2 stream attempts (one loop, two iterations). + const streamCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/stream'), + ); + expect(streamCalls).toHaveLength(2); + // Verify only one retry loop: all stream calls should have sequential + // X-Retry-Attempt headers (0, 1) from a single loop + expect(streamCalls[0]?.[1]?.headers?.['X-Retry-Attempt']).toBe('0'); + expect(streamCalls[1]?.[1]?.headers?.['X-Retry-Attempt']).toBe('1'); + + await client.shutdown(); + }); + it('should allow re-initialization after failure', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'missing-file', @@ -2083,12 +2116,7 @@ describe('Controller (black-box)', () => { return Promise.resolve(new Response(null, { status: 500 })); } // Second fetch succeeds - return Promise.resolve( - new Response(JSON.stringify(makeBundled()), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); + return Promise.resolve(Response.json(makeBundled())); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 66a29f67..833572ce 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -15,6 +15,7 @@ import { normalizeOptions, } from './normalized-options'; import { PollingSource } from './polling-source'; +import { UnauthorizedError } from './stream-connection'; import { StreamSource } from './stream-source'; import { originToMetricsSource, type TaggedData, tagData } from './tagged-data'; @@ -107,6 +108,9 @@ export class Controller implements ControllerInterface { private buildDataPromise: Promise | null = null; private buildReadTracked = false; + // Suppresses usage tracking when the SDK key is unauthorized + private unauthorized = false; + constructor(options: ControllerOptions) { if ( !options.sdkKey || @@ -415,7 +419,10 @@ export class Controller implements ControllerInterface { try { await this.streamSource.start(); return true; - } catch { + } catch (error) { + if (error instanceof UnauthorizedError) { + this.unauthorized = true; + } return false; } } @@ -448,8 +455,11 @@ export class Controller implements ControllerInterface { } return true; - } catch { + } catch (error) { clearTimeout(timeoutId!); + if (error instanceof Error && error.message.includes('401')) { + this.unauthorized = true; + } return false; } } @@ -750,6 +760,7 @@ export class Controller implements ControllerInterface { isFirstRead: boolean, source: Metrics['source'], ): void { + if (this.unauthorized) return; if (this.options.buildStep && this.buildReadTracked) return; if (this.options.buildStep) this.buildReadTracked = true; diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 366ec676..76a6aea2 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -16,6 +16,13 @@ function backoff(retryCount: number): number { return delay + Math.random() * 1000; } +export class UnauthorizedError extends Error { + constructor() { + super('stream: unauthorized (401)'); + this.name = 'UnauthorizedError'; + } +} + export type StreamCallbacks = { onMessage: (data: BundledDefinitions) => void; onDisconnect?: () => void; @@ -81,7 +88,7 @@ export async function connectStream( if (!response.ok) { if (response.status === 401) { if (!initialDataReceived) { - rejectInit!(new Error(`stream: unauthorized (401)`)); + rejectInit!(new UnauthorizedError()); } abortController.abort(); break; From e2fd6985ef813a425a223bf8eaad2aa20fa4c860 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 17:59:16 +0200 Subject: [PATCH 34/65] enforce min polling interval --- .../vercel-flags-core/src/black-box.test.ts | 21 ++++++++++++------- .../src/controller/normalized-options.ts | 6 ++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 62512503..92d7c9ad 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -146,6 +146,15 @@ describe('Controller (black-box)', () => { }), ).not.toThrow(); }); + + it('should throw for polling interval below 30s', () => { + expect(() => + createClient(sdkKey, { + fetch: fetchMock, + polling: { intervalMs: 1000, initTimeoutMs: 3000 }, + }), + ).toThrow('Polling interval must be at least 30000ms'); + }); }); // --------------------------------------------------------------------------- @@ -781,8 +790,6 @@ describe('Controller (black-box)', () => { // --------------------------------------------------------------------------- describe('polling behavior', () => { it('should use polling when enabled', async () => { - vi.useRealTimers(); // Polling uses real intervals - let pollCount = 0; const datafile = makeBundled(); @@ -798,7 +805,7 @@ describe('Controller (black-box)', () => { const client = createClient(sdkKey, { fetch: fetchMock, stream: false, - polling: { intervalMs: 100, initTimeoutMs: 5000 }, + polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, }); await client.initialize(); @@ -806,7 +813,7 @@ describe('Controller (black-box)', () => { expect(pollCount).toBeGreaterThanOrEqual(1); // Wait for a few poll intervals - await new Promise((r) => setTimeout(r, 350)); + await vi.advanceTimersByTimeAsync(90_000); expect(pollCount).toBeGreaterThanOrEqual(3); @@ -924,7 +931,7 @@ describe('Controller (black-box)', () => { const client = createClient(sdkKey, { fetch: fetchMock, stream: { initTimeoutMs: 100 }, - polling: { intervalMs: 50, initTimeoutMs: 5000 }, + polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, }); const initPromise = client.initialize(); @@ -966,7 +973,7 @@ describe('Controller (black-box)', () => { const client = createClient(sdkKey, { fetch: fetchMock, stream: { initTimeoutMs: 100 }, - polling: { intervalMs: 100, initTimeoutMs: 5000 }, + polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, }); // Stream retries with backoff; advance timers so the init timeout fires @@ -1104,7 +1111,7 @@ describe('Controller (black-box)', () => { const client = createClient(sdkKey, { fetch: fetchMock, stream: { initTimeoutMs: 5000 }, - polling: { intervalMs: 100, initTimeoutMs: 5000 }, + polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, }); // Stream retries with backoff; advance timers so the init timeout fires diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index e5d37a96..88c38a08 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -5,6 +5,7 @@ import type { StreamSource } from './stream-source'; 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; /** @@ -102,6 +103,11 @@ export function normalizeOptions( } else if (options.polling === false) { polling = { enabled: false, intervalMs: 0, initTimeoutMs: 0 }; } else { + if (options.polling.intervalMs < MIN_POLLING_INTERVAL_MS) { + throw new Error( + `@vercel/flags-core: Polling interval must be at least ${MIN_POLLING_INTERVAL_MS}ms, got ${options.polling.intervalMs}ms.`, + ); + } polling = { enabled: true, intervalMs: options.polling.intervalMs, From 161c3bf3e794b10c8ee47fa6a1edbce1f8cef460 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 18:16:30 +0200 Subject: [PATCH 35/65] progress --- .../vercel-flags-core/src/black-box.test.ts | 158 ++++++++++++++++-- 1 file changed, 148 insertions(+), 10 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 92d7c9ad..d49db89a 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -871,6 +871,92 @@ describe('Controller (black-box)', () => { await client.shutdown(); }); + it('should resolve initialize() immediately when datafile is provided', async () => { + // Stream that never sends data — if init blocks on stream, this test hangs + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + const body = new ReadableStream({ start() {} }); + return Promise.resolve(new Response(body, { status: 200 })); + } + return Promise.resolve(new Response('', { status: 200 })); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + datafile: makeBundled(), + }); + + // initialize() should resolve without advancing timers or stream data + await client.initialize(); + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('in-memory'); + + await client.shutdown(); + }); + + it('should use provided datafile then update from polling', async () => { + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve( + Response.json( + makeBundled({ + configUpdatedAt: 2, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }), + ), + ); + } + return Promise.resolve(new Response('', { status: 200 })); + }); + + const providedDatafile = makeBundled({ + configUpdatedAt: 1, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, + datafile: providedDatafile, + }); + + await client.initialize(); + + // First evaluate uses provided datafile (variant 1 = true) + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(true); + expect(result1.metrics?.source).toBe('in-memory'); + + // Advance past a poll interval to trigger update + await vi.advanceTimersByTimeAsync(30_000); + + expect(pollCount).toBeGreaterThanOrEqual(1); + + // Second evaluate uses polled data (variant 0 = false) + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(false); + + await client.shutdown(); + }); + it('should work with datafile only (stream and polling disabled)', async () => { const datafile = makeBundled(); @@ -937,12 +1023,43 @@ describe('Controller (black-box)', () => { const initPromise = client.initialize(); await vi.advanceTimersByTimeAsync(100); await initPromise; + const after = new Date(); const result = await client.evaluate('flagA'); expect(result.metrics?.source).toBe('embedded'); expect(pollCount).toBe(0); warnSpy.mockRestore(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: after.getTime(), + payload: { + configOrigin: 'embedded', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + headers: { + Authorization: 'Bearer vf_fake', + 'Content-Type': 'application/json', + 'User-Agent': 'VercelFlagsCore/1.0.1', + }, + method: 'POST', + }, + ); }); it('should fall back to bundled when stream fails (skip polling)', async () => { @@ -970,9 +1087,10 @@ describe('Controller (black-box)', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const streamInitTimeoutMs = 100; const client = createClient(sdkKey, { fetch: fetchMock, - stream: { initTimeoutMs: 100 }, + stream: { initTimeoutMs: streamInitTimeoutMs }, polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, }); @@ -985,6 +1103,32 @@ describe('Controller (black-box)', () => { errorSpy.mockRestore(); warnSpy.mockRestore(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime() + streamInitTimeoutMs, + payload: { + configOrigin: 'embedded', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); }); it('should never stream and poll simultaneously when stream is connected', async () => { @@ -1001,11 +1145,7 @@ describe('Controller (black-box)', () => { return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: true, - polling: false, - }); + const client = createClient(sdkKey, { fetch: fetchMock }); const initPromise = client.initialize(); @@ -1014,7 +1154,7 @@ describe('Controller (black-box)', () => { await initPromise; // Wait to see if any polls happen - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60_000); expect(pollCount).toBe(0); @@ -1048,8 +1188,6 @@ describe('Controller (black-box)', () => { const client = createClient(sdkKey, { fetch: fetchMock, datafile: providedDatafile, - stream: true, - polling: false, }); // Initialize starts background stream connection @@ -1076,7 +1214,7 @@ describe('Controller (black-box)', () => { }); // Wait for stream to deliver - await new Promise((r) => setTimeout(r, 50)); + await new Promise((r) => setTimeout(r, 0)); const result2 = await client.evaluate('flagA'); expect(result2.value).toBe(true); // variant 1 from stream From c21e5fc16bfa73a83d668ae13a1c1e95a657674d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 18:19:08 +0200 Subject: [PATCH 36/65] types --- packages/vercel-flags-core/src/black-box.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index d49db89a..316c4e40 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2238,8 +2238,10 @@ describe('Controller (black-box)', () => { expect(streamCalls).toHaveLength(2); // Verify only one retry loop: all stream calls should have sequential // X-Retry-Attempt headers (0, 1) from a single loop - expect(streamCalls[0]?.[1]?.headers?.['X-Retry-Attempt']).toBe('0'); - expect(streamCalls[1]?.[1]?.headers?.['X-Retry-Attempt']).toBe('1'); + const h0 = streamCalls[0]?.[1]?.headers as Record; + const h1 = streamCalls[1]?.[1]?.headers as Record; + expect(h0['X-Retry-Attempt']).toBe('0'); + expect(h1['X-Retry-Attempt']).toBe('1'); await client.shutdown(); }); From b9b9a3b8095b80c53fee6cc4cf76f5cdedd6ff63 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 21:35:30 +0200 Subject: [PATCH 37/65] capture all logs --- .../vercel-flags-core/src/black-box.test.ts | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 316c4e40..7d1cae59 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -636,6 +636,8 @@ describe('Controller (black-box)', () => { return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createClient(sdkKey, { fetch: fetchMock, polling: false, @@ -651,6 +653,11 @@ describe('Controller (black-box)', () => { expect(result.value).toBe(true); expect(result.metrics?.source).toBe('embedded'); expect(result.metrics?.connectionState).toBe('disconnected'); + + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back', + ); + warnSpy.mockRestore(); }); it('should fall back to bundled when stream errors (502)', async () => { @@ -667,8 +674,8 @@ describe('Controller (black-box)', () => { return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); - // Suppress expected error logs const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const client = createClient(sdkKey, { fetch: fetchMock, @@ -677,14 +684,22 @@ describe('Controller (black-box)', () => { const evalPromise = client.evaluate('flagA'); - // The 401 aborts the stream but the init promise may hang until timeout + // The 502 triggers stream error; init promise hangs until timeout await vi.advanceTimersByTimeAsync(3_000); const result = await evalPromise; expect(result.value).toBe(true); expect(result.metrics?.source).toBe('embedded'); + expect(errorSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream error', + expect.any(Error), + ); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back', + ); errorSpy.mockRestore(); + warnSpy.mockRestore(); }); it('should fast-fail on 401 without waiting for stream timeout', async () => { @@ -739,6 +754,8 @@ describe('Controller (black-box)', () => { return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createClient(sdkKey, { fetch: fetchMock, stream: { initTimeoutMs: 500 }, @@ -753,6 +770,11 @@ describe('Controller (black-box)', () => { const result = await client.evaluate('flagA'); expect(result.metrics?.source).toBe('embedded'); + + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back', + ); + warnSpy.mockRestore(); }); it('should disable stream when stream: false', async () => { @@ -2206,6 +2228,9 @@ describe('Controller (black-box)', () => { return Promise.resolve(new Response('', { status: 200 })); }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createClient(sdkKey, { fetch: fetchMock, stream: { initTimeoutMs: 100 }, @@ -2243,6 +2268,16 @@ describe('Controller (black-box)', () => { expect(h0['X-Retry-Attempt']).toBe('0'); expect(h1['X-Retry-Attempt']).toBe('1'); + expect(errorSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream error', + expect.any(Error), + ); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back', + ); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + await client.shutdown(); }); From 54ca358db4f1606020073a168703066cc5dc73d3 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 21:52:33 +0200 Subject: [PATCH 38/65] throw with prefix --- .../vercel-flags-core/src/black-box.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 7d1cae59..c66250fe 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -426,7 +426,7 @@ describe('Controller (black-box)', () => { }); await expect(client.evaluate('flagA')).rejects.toThrow( - 'No flag definitions available during build', + '@vercel/flags-core: No flag definitions available during build', ); }); @@ -1338,6 +1338,14 @@ describe('Controller (black-box)', () => { expect(result.metrics.cacheStatus).toBe('MISS'); await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + headers: datafileRequestHeaders, + signal: expect.any(AbortSignal), + }, + ); }); it('should throw when called without initialize and all sources fail', async () => { @@ -1361,7 +1369,7 @@ describe('Controller (black-box)', () => { }); await expect(client.getDatafile()).rejects.toThrow( - 'No flag definitions available', + '@vercel/flags-core: No flag definitions available', ); await client.shutdown(); @@ -2416,7 +2424,9 @@ describe('Controller (black-box)', () => { expect(result).toEqual({ value: false, reason: 'error', - errorMessage: expect.stringContaining('No flag definitions available'), + errorMessage: expect.stringContaining( + '@vercel/flags-core: No flag definitions available', + ), }); }); @@ -2434,7 +2444,7 @@ describe('Controller (black-box)', () => { }); await expect(client.evaluate('flagA')).rejects.toThrow( - 'No flag definitions available', + '@vercel/flags-core: No flag definitions available', ); }); From f598a3dabb9a2f462d59320cbbd039fe55d5f8c9 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 22:03:30 +0200 Subject: [PATCH 39/65] added a minimum gap of `BASE_DELAY_MS` (1 second) between connection attempts --- .../vercel-flags-core/src/black-box.test.ts | 71 ++++++++++++++++--- .../src/controller/stream-connection.test.ts | 58 +++++++++++++++ .../src/controller/stream-connection.ts | 10 ++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index c66250fe..9ca1422c 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -777,6 +777,54 @@ describe('Controller (black-box)', () => { warnSpy.mockRestore(); }); + it('should not spam the server when stream repeatedly connects then disconnects', async () => { + const datafile = makeBundled(); + let streamRequestCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + streamRequestCount++; + + // Each stream connection sends a datafile (resetting retryCount) + // then immediately closes — simulating a flapping connection + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `${JSON.stringify({ type: 'datafile', data: datafile })}\n`, + ), + ); + controller.close(); + }, + }); + return Promise.resolve(new Response(body, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + await client.initialize(); + + // Advance 10 seconds — without the minimum gap protection this would + // cause an unbounded number of reconnections (retryCount resets to 0 + // after each datafile, and backoff(1)=0 gives immediate retry). + // With the fix, reconnections are spaced at least 1s apart. + await vi.advanceTimersByTimeAsync(10_000); + + // At most ~11 attempts in 10s (initial + 10 reconnections at 1s each) + expect(streamRequestCount).toBeLessThanOrEqual(12); + // But we should still see reconnection attempts happening + expect(streamRequestCount).toBeGreaterThanOrEqual(2); + + await client.shutdown(); + }); + it('should disable stream when stream: false', async () => { const datafile = makeBundled(); @@ -1109,16 +1157,19 @@ describe('Controller (black-box)', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const streamInitTimeoutMs = 100; + const streamInitTimeoutMs = 1_500; const client = createClient(sdkKey, { fetch: fetchMock, stream: { initTimeoutMs: streamInitTimeoutMs }, polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, }); - // Stream retries with backoff; advance timers so the init timeout fires + // Stream retries with backoff; advance timers so the init timeout fires. + // The minimum reconnection gap is 1s, so the first retry happens at ~1s. + // With initTimeoutMs=1500 we get: attempt at t=0 (fail), retry at t=1000 + // (fail, backoff(2) >= 1s), timeout at t=1500 → fall back to bundled. const resultPromise = client.evaluate('flagA'); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(1_600); const result = await resultPromise; expect(result.metrics?.source).toBe('embedded'); expect(pollCount).toBe(0); @@ -2241,7 +2292,7 @@ describe('Controller (black-box)', () => { const client = createClient(sdkKey, { fetch: fetchMock, - stream: { initTimeoutMs: 100 }, + stream: { initTimeoutMs: 1_500 }, polling: false, }); @@ -2250,8 +2301,10 @@ describe('Controller (black-box)', () => { const p2 = client.evaluate('flagA'); const p3 = client.evaluate('flagA'); - // Advance past the stream init timeout - await vi.advanceTimersByTimeAsync(100); + // Advance past the stream init timeout. + // The minimum reconnection gap is 1s, so: attempt at t=0 (fail), + // retry at t=1000 (fail, backoff(2) >= 1s), timeout at t=1500. + await vi.advanceTimersByTimeAsync(1_500); const [r1, r2, r3] = await Promise.all([p1, p2, p3]); @@ -2261,9 +2314,9 @@ describe('Controller (black-box)', () => { expect(r3.value).toBe(true); // Concurrent callers share the same init promise, so only one retry - // loop is started. With 100ms timeout: attempt at retryCount=0 fails, - // backoff(1)=0ms → immediate retry at retryCount=1 fails, - // backoff(2)=~1s+ exceeds 100ms timeout → falls back to bundled. + // loop is started. With 1500ms timeout: attempt at retryCount=0 fails, + // minimum gap enforces 1s delay → retry at retryCount=1 fails at t=1000, + // backoff(2) >= 1s exceeds remaining timeout → falls back to bundled. // So exactly 2 stream attempts (one loop, two iterations). const streamCalls = fetchMock.mock.calls.filter((call) => call[0]?.toString().includes('/v1/stream'), diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index c5845c1b..42d4269a 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -387,6 +387,64 @@ describe('connectStream', () => { abortController.abort(); }); + it('should enforce minimum delay between reconnection attempts when retryCount resets', async () => { + vi.useFakeTimers(); + + const retryAttempts: string[] = []; + let requestCount = 0; + + server.use( + http.get(`${HOST}/v1/stream`, ({ request }) => { + retryAttempts.push(request.headers.get('X-Retry-Attempt') ?? ''); + requestCount++; + + // Each request: send datafile (resets retryCount to 0) then close + // On the 4th request, keep open to stop the loop + return new HttpResponse( + createNdjsonStream( + [ + { + type: 'datafile', + data: { projectId: 'test', definitions: {} }, + }, + ], + { keepOpen: requestCount >= 4 }, + ), + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }), + ); + + const abortController = new AbortController(); + + await connectStream( + { host: HOST, sdkKey: 'vf_test', abortController }, + { onMessage: vi.fn() }, + ); + + // After the first stream closes, retryCount was reset to 0 then + // incremented to 1 — backoff(1) = 0 but minimum gap is 1s. + // Advance 999ms — not enough for the minimum gap + await vi.advanceTimersByTimeAsync(999); + expect(requestCount).toBe(1); + + // Advance past the 1s minimum gap + await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(0); + expect(requestCount).toBe(2); + + // Same pattern for the next reconnection + await vi.advanceTimersByTimeAsync(999); + expect(requestCount).toBe(2); + + await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(0); + expect(requestCount).toBe(3); + + abortController.abort(); + vi.useRealTimers(); + }); + it('should call onDisconnect when stream ends normally', async () => { let requestCount = 0; diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 76a6aea2..258352a8 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -52,6 +52,7 @@ export async function connectStream( } = config; const { onMessage, onDisconnect } = callbacks; let retryCount = 0; + let lastAttemptTime = 0; let resolveInit: () => void; let rejectInit: (error: unknown) => void; @@ -76,6 +77,7 @@ export async function connectStream( } try { + lastAttemptTime = Date.now(); const response = await fetchFn(`${host}/v1/stream`, { headers: { Authorization: `Bearer ${sdkKey}`, @@ -147,7 +149,9 @@ export async function connectStream( if (!abortController.signal.aborted) { onDisconnect?.(); retryCount++; - await sleep(backoff(retryCount)); + const elapsed = Date.now() - lastAttemptTime; + const minGap = Math.max(0, BASE_DELAY_MS - elapsed); + await sleep(Math.max(backoff(retryCount), minGap)); continue; } } catch (error) { @@ -157,7 +161,9 @@ export async function connectStream( console.error('@vercel/flags-core: Stream error', error); onDisconnect?.(); retryCount++; - await sleep(backoff(retryCount)); + const elapsed = Date.now() - lastAttemptTime; + const minGap = Math.max(0, BASE_DELAY_MS - elapsed); + await sleep(Math.max(backoff(retryCount), minGap)); } } From a79013ee98f63d794cb0e338c65d967d2e7112fb Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 22:20:33 +0200 Subject: [PATCH 40/65] step --- .../vercel-flags-core/src/black-box.test.ts | 72 +++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 9ca1422c..f955fe7d 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -79,6 +79,12 @@ const ingestRequestHeaders = Object.freeze({ 'User-Agent': 'VercelFlagsCore/1.0.1', }); +const streamRequestHeaders = Object.freeze({ + Authorization: 'Bearer vf_fake', + 'User-Agent': 'VercelFlagsCore/1.0.1', + 'X-Retry-Attempt': '0', +}); + const datafileRequestHeaders = Object.freeze({ Authorization: 'Bearer vf_fake', 'User-Agent': 'VercelFlagsCore/1.0.1', @@ -1449,6 +1455,16 @@ describe('Controller (black-box)', () => { stream.close(); await client.shutdown(); + + // no evaluate call so no usage tracking + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: streamRequestHeaders, + signal: expect.any(AbortSignal), + }, + ); }); it('should use build step path when CI=1', async () => { @@ -1481,10 +1497,22 @@ describe('Controller (black-box)', () => { }); const result1 = await client.getDatafile(); - expect(result1.metrics.cacheStatus).toBe('MISS'); + expect(result1.metrics).toEqual({ + cacheStatus: 'MISS', + connectionState: 'disconnected', + mode: 'offline', + readMs: 0, + source: 'embedded', + }); const result2 = await client.getDatafile(); - expect(result2.metrics.cacheStatus).toBe('STALE'); + expect(result2.metrics).toEqual({ + cacheStatus: 'STALE', + connectionState: 'disconnected', + mode: 'offline', + readMs: 0, + source: 'embedded', + }); await client.shutdown(); }); @@ -1512,6 +1540,7 @@ describe('Controller (black-box)', () => { expect(result).toEqual(bundled); await client.shutdown(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('should throw FallbackNotFoundError for missing-file state', async () => { @@ -1537,6 +1566,7 @@ describe('Controller (black-box)', () => { } await client.shutdown(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('should throw FallbackEntryNotFoundError for missing-entry state', async () => { @@ -1552,7 +1582,7 @@ describe('Controller (black-box)', () => { }); await expect(client.getFallbackDatafile()).rejects.toThrow( - 'No bundled definitions found for SDK key', + '@vercel/flags-core: No bundled definitions found for SDK key', ); try { @@ -1562,6 +1592,7 @@ describe('Controller (black-box)', () => { } await client.shutdown(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('should throw for unexpected-error state', async () => { @@ -1578,7 +1609,7 @@ describe('Controller (black-box)', () => { }); await expect(client.getFallbackDatafile()).rejects.toThrow( - 'Failed to read bundled definitions', + '@vercel/flags-core: Failed to read bundled definitions', ); await client.shutdown(); @@ -1638,10 +1669,43 @@ describe('Controller (black-box)', () => { // Should still have newer data (older message was rejected) const result = await client.evaluate('flagA'); + const after = new Date(); expect(result.value).toBe(true); // variant 1 = newer stream.close(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: streamRequestHeaders, + signal: expect.any(AbortSignal), + }, + ); await client.shutdown(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + method: 'POST', + headers: ingestRequestHeaders, + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: after.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 2000, + }, + }, + ]), + }, + ); }); it('should skip stream data with equal configUpdatedAt', async () => { From 2ce930a0f333dd91b48f38a468fe5f217e2dd96b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 22:30:54 +0200 Subject: [PATCH 41/65] progress --- .../vercel-flags-core/src/black-box.test.ts | 53 ++++++++++++++++++- .../vercel-flags-core/src/controller-fns.ts | 2 +- .../vercel-flags-core/src/integration.test.ts | 2 +- .../src/utils/usage-tracker.test.ts | 11 +++- 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index f955fe7d..26fc9631 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -1991,7 +1991,7 @@ describe('Controller (black-box)', () => { expect(result.reason).toBe('error'); expect(result.errorCode).toBe('FLAG_NOT_FOUND'); expect(result.errorMessage).toContain( - 'Definition not found for flag "nonexistent-flag"', + '@vercel/flags-core: Definition not found for flag "nonexistent-flag"', ); }); @@ -2298,6 +2298,10 @@ describe('Controller (black-box)', () => { stream.close(); await client.shutdown(); + await vi.advanceTimersByTimeAsync(0); + + // didn't evaluate any flags, so no config reads tracked + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should deduplicate concurrent evaluate() calls that trigger initialize', async () => { @@ -2334,6 +2338,53 @@ describe('Controller (black-box)', () => { stream.close(); await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); }); it('should start only one retry loop when concurrent evaluate() calls hit a failing stream', async () => { diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index b3886127..6a3cb8db 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -84,7 +84,7 @@ export async function evaluate>( value: defaultValue, reason: ResolutionReason.ERROR, errorCode: ErrorCode.FLAG_NOT_FOUND, - errorMessage: `Definition not found for flag "${flagKey}"`, + errorMessage: `@vercel/flags-core: Definition not found for flag "${flagKey}"`, metrics: { evaluationMs: 0, readMs: datafile.metrics.readMs, diff --git a/packages/vercel-flags-core/src/integration.test.ts b/packages/vercel-flags-core/src/integration.test.ts index ae63e941..5ac8e88b 100644 --- a/packages/vercel-flags-core/src/integration.test.ts +++ b/packages/vercel-flags-core/src/integration.test.ts @@ -48,7 +48,7 @@ describe('integration evaluate', () => { expect(result.reason).toBe(ResolutionReason.ERROR); expect(result.errorCode).toBe('FLAG_NOT_FOUND'); expect(result.errorMessage).toBe( - 'Definition not found for flag "does-not-exist"', + '@vercel/flags-core: Definition not found for flag "does-not-exist"', ); expect(result.metrics).toBeDefined(); }); diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index 0f03f1ee..243ef198 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -324,11 +324,15 @@ describe('UsageTracker', () => { './usage-tracker' ); let debugHeader: string | null = null; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); server.use( http.post('https://example.com/v1/ingest', async ({ request }) => { debugHeader = request.headers.get('x-vercel-debug-ingest'); - return HttpResponse.json({ ok: true }); + return HttpResponse.json( + { ok: true }, + { headers: { 'x-vercel-id': 'iad1::abcdef-1234' } }, + ); }), ); @@ -343,7 +347,12 @@ describe('UsageTracker', () => { await vi.waitFor(() => { expect(debugHeader).toBe('1'); + expect(consoleSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Ingest response 200 for 1 events on iad1::abcdef-1234', + ); }); + + consoleSpy.mockRestore(); }); it('should not send x-vercel-debug-ingest header when not in debug mode', async () => { From 14947064b75e54ca53e0e40fd0f81b7a2cbd88fb Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 22:35:52 +0200 Subject: [PATCH 42/65] use request context in tests --- .../vercel-flags-core/src/black-box.test.ts | 37 +++++------- packages/vercel-flags-core/src/test-utils.ts | 15 +++++ .../src/utils/usage-tracker.test.ts | 58 +++++-------------- 3 files changed, 44 insertions(+), 66 deletions(-) create mode 100644 packages/vercel-flags-core/src/test-utils.ts diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 26fc9631..054d9f84 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { StreamMessage } from './controller/stream-connection'; import { type BundledDefinitions, createClient } from './index.default'; import { internalReportValue } from './lib/report-value'; +import { setRequestContext } from './test-utils'; import { readBundledDefinitions } from './utils/read-bundled-definitions'; vi.mock('./utils/read-bundled-definitions', () => ({ @@ -2313,6 +2314,12 @@ describe('Controller (black-box)', () => { return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); + // Set up a fake request context so usage tracking deduplicates + const cleanupContext = setRequestContext({ + 'x-vercel-id': 'iad1::req-abc123', + host: 'myapp.vercel.app', + }); + const client = createClient(sdkKey, { fetch: fetchMock }); // Three concurrent evaluates trigger lazy initialization @@ -2338,6 +2345,10 @@ describe('Controller (black-box)', () => { stream.close(); await client.shutdown(); + + cleanupContext(); + + // Only a single config read should be tracked thanks to request context deduplication expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenLastCalledWith( 'https://flags.vercel.com/v1/ingest', @@ -2347,6 +2358,8 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: date.getTime(), payload: { + vercelRequestId: 'iad1::req-abc123', + invocationHost: 'myapp.vercel.app', configOrigin: 'in-memory', cacheStatus: 'HIT', cacheAction: 'FOLLOWING', @@ -2356,30 +2369,6 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, }, }, - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'in-memory', - cacheStatus: 'HIT', - cacheAction: 'FOLLOWING', - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 1, - }, - }, - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'in-memory', - cacheStatus: 'HIT', - cacheAction: 'FOLLOWING', - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 1, - }, - }, ]), headers: ingestRequestHeaders, method: 'POST', diff --git a/packages/vercel-flags-core/src/test-utils.ts b/packages/vercel-flags-core/src/test-utils.ts new file mode 100644 index 00000000..59dd8d30 --- /dev/null +++ b/packages/vercel-flags-core/src/test-utils.ts @@ -0,0 +1,15 @@ +const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); + +/** + * Installs a fake Vercel request context on `globalThis`. + * Returns a cleanup function that removes it. + */ +export function setRequestContext(headers: Record): () => void { + const mockContext = { headers }; + (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT] = { + get: () => mockContext, + }; + return () => { + delete (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT]; + }; +} diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index 243ef198..19bc9fd0 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -9,6 +9,7 @@ import { it, vi, } from 'vitest'; +import { setRequestContext } from '../test-utils'; import { type FlagsConfigReadEvent, UsageTracker } from './usage-tracker'; // Mock @vercel/functions @@ -483,18 +484,10 @@ describe('UsageTracker', () => { }), ); - // Set up a mock request context - const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); - const mockContext = { - headers: { - 'x-vercel-id': 'test-request-id', - host: 'example.com', - }, - }; - - (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT] = { - get: () => mockContext, - }; + const cleanupContext = setRequestContext({ + 'x-vercel-id': 'test-request-id', + host: 'example.com', + }); const tracker = new UsageTracker({ sdkKey: 'test-key', @@ -516,8 +509,7 @@ describe('UsageTracker', () => { const events = receivedEvents[0] as Array<{ type: string }>; expect(events).toHaveLength(1); - // Clean up - delete (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT]; + cleanupContext(); }); it('should include headers from request context', async () => { @@ -531,18 +523,10 @@ describe('UsageTracker', () => { }), ); - // Set up a mock request context - const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); - const mockContext = { - headers: { - 'x-vercel-id': 'req_123', - host: 'myapp.vercel.app', - }, - }; - - (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT] = { - get: () => mockContext, - }; + const cleanupContext = setRequestContext({ + 'x-vercel-id': 'req_123', + host: 'myapp.vercel.app', + }); const tracker = new UsageTracker({ sdkKey: 'test-key', @@ -562,8 +546,7 @@ describe('UsageTracker', () => { expect(event.payload.vercelRequestId).toBe('req_123'); expect(event.payload.invocationHost).toBe('myapp.vercel.app'); - // Clean up - delete (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT]; + cleanupContext(); }); }); @@ -579,18 +562,10 @@ describe('UsageTracker', () => { }), ); - // Set up a shared request context - const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); - const mockContext = { - headers: { - 'x-vercel-id': 'shared-request-id', - host: 'example.com', - }, - }; - - (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT] = { - get: () => mockContext, - }; + const cleanupContext = setRequestContext({ + 'x-vercel-id': 'shared-request-id', + host: 'example.com', + }); const tracker1 = new UsageTracker({ sdkKey: 'key-1', @@ -618,8 +593,7 @@ describe('UsageTracker', () => { expect(receivedEvents[0]).toHaveLength(1); expect(receivedEvents[1]).toHaveLength(1); - // Clean up - delete (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT]; + cleanupContext(); }); }); From df14ca8fe4adda0133de090ddd9d444a534d4ab5 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 22:57:59 +0200 Subject: [PATCH 43/65] add separate tests depending on context --- .../vercel-flags-core/src/black-box.test.ts | 125 +++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 054d9f84..99c8a21f 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2305,7 +2305,7 @@ describe('Controller (black-box)', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('should deduplicate concurrent evaluate() calls that trigger initialize', async () => { + it('should deduplicate concurrent evaluate() calls that trigger initialize, and only track one read when request context is set', async () => { const stream = createMockStream(); fetchMock.mockImplementation((input) => { @@ -2376,6 +2376,89 @@ describe('Controller (black-box)', () => { ); }); + it('should deduplicate concurrent evaluate() calls that trigger initialize, and track each read individually when request context is missing', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + + // Three concurrent evaluates trigger lazy initialization + const p1 = client.evaluate('flagA'); + const p2 = client.evaluate('flagA'); + const p3 = client.evaluate('flagA'); + + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + // All should have the same value + expect(r1.value).toBe(true); + expect(r2.value).toBe(true); + expect(r3.value).toBe(true); + + // Stream should have been fetched only once + const streamCalls = fetchMock.mock.calls.filter((call) => + call[0]?.toString().includes('/v1/stream'), + ); + expect(streamCalls).toHaveLength(1); + + stream.close(); + await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); + }); + it('should start only one retry loop when concurrent evaluate() calls hit a failing stream', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', @@ -2476,14 +2559,54 @@ describe('Controller (black-box)', () => { // First initialize fails (no bundled, fetch returns 500) await expect(client.initialize()).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + headers: datafileRequestHeaders, + signal: expect.any(AbortSignal), + }, + ); // Second initialize should retry — fetch now succeeds await client.initialize(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + headers: datafileRequestHeaders, + signal: expect.any(AbortSignal), + }, + ); const result = await client.evaluate('flagA'); expect(result.value).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + headers: ingestRequestHeaders, + method: 'POST', + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + }, + ); }); }); From b5b9ea80622bb879b2f77695f6f0496293a115b2 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 23:21:31 +0200 Subject: [PATCH 44/65] tests --- .../vercel-flags-core/src/black-box.test.ts | 140 +++++++++++------- 1 file changed, 83 insertions(+), 57 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 99c8a21f..30492d00 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2809,26 +2809,25 @@ describe('Controller (black-box)', () => { expect(fetchMock).toHaveBeenCalledWith( 'https://flags.vercel.com/v1/ingest', { - body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 2, + }, + }, + ]), headers: ingestRequestHeaders, method: 'POST', }, ); - expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ - { - payload: { - cacheAction: 'NONE', - cacheIsBlocking: false, - cacheIsFirstRead: true, - cacheStatus: 'HIT', - configOrigin: 'in-memory', - configUpdatedAt: 2, - duration: 0, - }, - ts: expect.any(Number), - type: 'FLAGS_CONFIG_READ', - }, - ]); }); it('should only track one FLAGS_CONFIG_READ during build step', async () => { @@ -2843,21 +2842,33 @@ describe('Controller (black-box)', () => { }); // Multiple evaluates during build - await client.evaluate('flagA'); - await client.evaluate('flagA'); + await Promise.all([client.evaluate('flagA'), client.evaluate('flagA')]); await client.evaluate('flagA'); await client.shutdown(); - - // Only one ingest call, despite multiple evaluate() calls - const ingestCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/ingest'), + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'embedded', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, ); - expect(ingestCalls).toHaveLength(1); - - const events = JSON.parse(ingestCalls[0]?.[1]?.body as string); - expect(events).toHaveLength(1); - expect(events[0].type).toBe('FLAGS_CONFIG_READ'); }); it('should report FLAGS_CONFIG_READ with FOLLOWING cacheAction when streaming', async () => { @@ -2889,22 +2900,38 @@ describe('Controller (black-box)', () => { // Evaluate while streaming await client.evaluate('flagA'); - await client.shutdown(); - - const ingestCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/ingest'), + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: streamRequestHeaders, + signal: expect.any(AbortSignal), + }, ); - expect(ingestCalls.length).toBeGreaterThanOrEqual(1); - - const events = JSON.parse(ingestCalls[0]?.[1]?.body as string); - const readEvent = events.find( - (e: { type: string }) => e.type === 'FLAGS_CONFIG_READ', + await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 5, + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, ); - expect(readEvent).toBeDefined(); - expect(readEvent.payload.cacheAction).toBe('FOLLOWING'); - expect(readEvent.payload.configOrigin).toBe('in-memory'); - expect(readEvent.payload.cacheIsFirstRead).toBe(true); - expect(readEvent.payload.configUpdatedAt).toBe(5); stream.close(); }); @@ -2942,26 +2969,25 @@ describe('Controller (black-box)', () => { expect(fetchMock).toHaveBeenCalledWith( 'https://flags.vercel.com/v1/ingest', { - body: expect.stringContaining('"type":"FLAGS_CONFIG_READ"'), + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'embedded', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 2, + }, + }, + ]), headers: ingestRequestHeaders, method: 'POST', }, ); - expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual([ - { - payload: { - cacheAction: 'NONE', - cacheIsBlocking: false, - cacheIsFirstRead: true, - cacheStatus: 'HIT', - configOrigin: 'embedded', - configUpdatedAt: 2, - duration: 0, - }, - ts: expect.any(Number), - type: 'FLAGS_CONFIG_READ', - }, - ]); }); }); }); From 997c0e645bfdb827dcff53f697b0f489896d4fe3 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 23:24:22 +0200 Subject: [PATCH 45/65] rm unused option --- packages/vercel-flags-core/src/controller/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 833572ce..713db282 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -146,7 +146,7 @@ export class Controller implements ControllerInterface { this.data = tagData(this.options.datafile, 'provided'); } - this.usageTracker = options.usageTracker ?? new UsageTracker(this.options); + this.usageTracker = new UsageTracker(this.options); } // Source event handlers (stored for cleanup) From 8b628c4b047566e86e6ac1369cc46b32e37396e5 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 07:03:19 +0200 Subject: [PATCH 46/65] add state machine chart --- .../vercel-flags-core/src/controller/index.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 713db282..e8eb5bcf 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -47,6 +47,49 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { /** * Explicit states for the controller state machine. + * + * + * ┌──────┐ + * │ idle │ + * └──┬───┘ + * ┌───────────┴──────────────────────────────┐ + * initialize() initialize() + * (runtime) (build step) + * │ │ + * ▼ ▼ + * ┌─────────────────────┐ ┌───────────────┐ + * │ initializing:stream │ │ build:loading │ + * └──┬──────────────┬───┘ └──────┬────────┘ + * │ │ │ + * success timeout/fail │ + * │ │ ▼ + * ▼ ▼ ┌─────────────┐ + * ┌───────────┐ ┌───────────────────────┐ │ build:ready │ + * │ streaming │ │ initializing:polling │ └─────────────┘ + * └─────┬─────┘ └──┬───────────────┬────┘ + * │ success timeout/fail + * disconnect │ │ + * │ ▼ ▼ + * │ ┌─────────┐ ┌────────────────────────┐ + * │ │ polling │ │ initializing:fallback │ + * │ └─────────┘ └──┬──────────────────┬──┘ + * │ success fail + * │ │ │ + * ▼ ▼ ▼ + * ┌───────────────────────────────────────────────┐ + * │ degraded │ + * └───────────────────────┬───────────────────────┘ + * │ + * stream reconnects + * │ + * ▼ + * ┌───────────┐ + * │ streaming │ (recovery) + * └───────────┘ + * + * Any state ──shutdown()──▶ ┌──────────┐ + * │ shutdown │ + * └──────────┘ */ type State = | 'idle' From 3433e962bdb365af8f45cfd5e74594dc5fd8f369 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 07:10:46 +0200 Subject: [PATCH 47/65] rm sources --- .../vercel-flags-core/src/controller/index.ts | 16 ++++++---------- .../src/controller/normalized-options.ts | 10 ---------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index e8eb5bcf..537535b7 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -168,18 +168,14 @@ export class Controller implements ControllerInterface { this.options = normalizeOptions(options); // Create source modules (or use injected ones for testing) - this.streamSource = - options.sources?.stream ?? new StreamSource(this.options); + this.streamSource = new StreamSource(this.options); - this.pollingSource = - options.sources?.polling ?? new PollingSource(this.options); + this.pollingSource = new PollingSource(this.options); - this.bundledSource = - options.sources?.bundled ?? - new BundledSource({ - sdkKey: this.options.sdkKey, - readBundledDefinitions, - }); + this.bundledSource = new BundledSource({ + sdkKey: this.options.sdkKey, + readBundledDefinitions, + }); // Wire source events to state machine this.wireSourceEvents(); diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 88c38a08..366badfd 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -54,16 +54,6 @@ export type ControllerOptions = { * @default globalThis.fetch */ fetch?: typeof globalThis.fetch; - - /** - * Custom source modules for dependency injection (testing). - * When provided, these replace the default source instances. - */ - sources?: { - stream?: StreamSource; - polling?: PollingSource; - bundled?: BundledSource; - }; }; export type NormalizedOptions = { From dbc54e55c4e70b91209ab1af0a20edd1e60873c5 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 07:17:24 +0200 Subject: [PATCH 48/65] use fake timers for more tests --- .../src/controller/stream-connection.test.ts | 87 ++++++++++--------- .../src/utils/usage-tracker.test.ts | 29 ++++--- 2 files changed, 64 insertions(+), 52 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index 42d4269a..b41491b4 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -287,6 +287,9 @@ describe('connectStream', () => { }); describe('retry behavior', () => { + beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true })); + afterEach(() => vi.useRealTimers()); + it('should increment X-Retry-Attempt on reconnect after stream closes', async () => { const retryAttempts: string[] = []; let requestCount = 0; @@ -321,14 +324,11 @@ describe('connectStream', () => { { onMessage: vi.fn(), onDisconnect }, ); - // Wait for reconnection attempt - await vi.waitFor( - () => { - expect(requestCount).toBeGreaterThanOrEqual(2); - }, - { timeout: 3000 }, - ); + // Advance past the reconnection backoff delay + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(0); + expect(requestCount).toBeGreaterThanOrEqual(2); expect(retryAttempts[0]).toBe('0'); expect(retryAttempts[1]).toBe('1'); expect(onDisconnect).toHaveBeenCalled(); @@ -368,13 +368,15 @@ describe('connectStream', () => { { onMessage: vi.fn() }, ); - // Wait for multiple reconnections - await vi.waitFor( - () => { - expect(requestCount).toBeGreaterThanOrEqual(3); - }, - { timeout: 5000 }, - ); + // Advance past first reconnection backoff + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(0); + + // Advance past second reconnection backoff + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(0); + + expect(requestCount).toBeGreaterThanOrEqual(3); // Each reconnect after successful datafile should reset to 0, then increment by 1 // Request 1: retry=0, gets datafile, resets to 0, stream closes, increments to 1 @@ -388,8 +390,6 @@ describe('connectStream', () => { }); it('should enforce minimum delay between reconnection attempts when retryCount resets', async () => { - vi.useFakeTimers(); - const retryAttempts: string[] = []; let requestCount = 0; @@ -442,7 +442,6 @@ describe('connectStream', () => { expect(requestCount).toBe(3); abortController.abort(); - vi.useRealTimers(); }); it('should call onDisconnect when stream ends normally', async () => { @@ -474,9 +473,11 @@ describe('connectStream', () => { { onMessage: vi.fn(), onDisconnect }, ); - await vi.waitFor(() => { - expect(onDisconnect).toHaveBeenCalled(); - }); + // Advance past the reconnection backoff delay + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(0); + + expect(onDisconnect).toHaveBeenCalled(); abortController.abort(); }); @@ -489,6 +490,7 @@ describe('connectStream', () => { // FlagNetworkDataSource.getDataWithStreamTimeout(). it('should retry on error before first datafile and reject when aborted', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let requestCount = 0; @@ -506,13 +508,13 @@ describe('connectStream', () => { { onMessage: vi.fn() }, ); - // Wait for at least one retry attempt (first retry has 0ms backoff) - await vi.waitFor( - () => { - expect(requestCount).toBeGreaterThanOrEqual(2); - }, - { timeout: 3000 }, - ); + // First request fires immediately, first retry has 0ms backoff + await vi.advanceTimersByTimeAsync(0); + // Advance past the second retry backoff (1s base + jitter) + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(0); + + expect(requestCount).toBeGreaterThanOrEqual(2); // Abort to stop retries abortController.abort(); @@ -523,9 +525,11 @@ describe('connectStream', () => { ); errorSpy.mockRestore(); + vi.useRealTimers(); }); it('should retry if response has no body and reject when aborted', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let requestCount = 0; @@ -547,13 +551,13 @@ describe('connectStream', () => { { onMessage: vi.fn() }, ); - // Wait for at least one retry attempt (first retry has 0ms backoff) - await vi.waitFor( - () => { - expect(requestCount).toBeGreaterThanOrEqual(2); - }, - { timeout: 3000 }, - ); + // First request fires immediately, first retry has 0ms backoff + await vi.advanceTimersByTimeAsync(0); + // Advance past the second retry backoff (1s base + jitter) + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(0); + + expect(requestCount).toBeGreaterThanOrEqual(2); // Abort to stop retries abortController.abort(); @@ -564,9 +568,11 @@ describe('connectStream', () => { ); errorSpy.mockRestore(); + vi.useRealTimers(); }); it('should call onDisconnect on error after initial data received', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let requestCount = 0; @@ -598,16 +604,15 @@ describe('connectStream', () => { { onMessage: vi.fn(), onDisconnect }, ); - // Wait for disconnect to be called (from first stream close and error) - await vi.waitFor( - () => { - expect(onDisconnect).toHaveBeenCalled(); - }, - { timeout: 3000 }, - ); + // Advance past the reconnection backoff delay + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(0); + + expect(onDisconnect).toHaveBeenCalled(); abortController.abort(); errorSpy.mockRestore(); + vi.useRealTimers(); }); // Note: Testing MAX_RETRY_COUNT exceeded is skipped because the backoff delays diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index 19bc9fd0..084ca333 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -212,6 +212,7 @@ describe('UsageTracker', () => { }); it('should not send empty batches', async () => { + vi.useFakeTimers(); let requestCount = 0; server.use( @@ -230,12 +231,14 @@ describe('UsageTracker', () => { // Flush without tracking anything tracker.flush(); - // Wait a bit to ensure no request is made - await new Promise((r) => setTimeout(r, 100)); + // Advance timers to ensure no request is made + await vi.advanceTimersByTimeAsync(100); expect(requestCount).toBe(0); + vi.useRealTimers(); }); it('should handle fetch errors gracefully', async () => { + vi.useFakeTimers(); const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); @@ -255,14 +258,16 @@ describe('UsageTracker', () => { tracker.trackRead(); tracker.flush(); - // Wait for the flush to complete - await new Promise((r) => setTimeout(r, 100)); + // Advance timers to let the flush complete + await vi.advanceTimersByTimeAsync(100); // Should not throw and should not log error (only logs in debug mode) expect(consoleSpy).not.toHaveBeenCalled(); + vi.useRealTimers(); }); it('should handle non-ok responses gracefully', async () => { + vi.useFakeTimers(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); server.use( @@ -280,11 +285,12 @@ describe('UsageTracker', () => { tracker.trackRead(); tracker.flush(); - // Wait for the flush to complete - await new Promise((r) => setTimeout(r, 100)); + // Advance timers to let the flush complete + await vi.advanceTimersByTimeAsync(100); // Should not log in non-debug mode expect(consoleSpy).not.toHaveBeenCalled(); + vi.useRealTimers(); }); it('should log errors in debug mode', async () => { @@ -442,6 +448,7 @@ describe('UsageTracker', () => { }); it('should be safe to call flush multiple times', async () => { + vi.useFakeTimers(); let requestCount = 0; server.use( @@ -462,13 +469,13 @@ describe('UsageTracker', () => { tracker.flush(); tracker.flush(); - await vi.waitFor(() => { - expect(requestCount).toBe(1); - }); + await vi.advanceTimersByTimeAsync(0); + expect(requestCount).toBe(1); - // Wait a bit more to ensure no additional requests - await new Promise((r) => setTimeout(r, 100)); + // Advance timers to ensure no additional requests + await vi.advanceTimersByTimeAsync(100); expect(requestCount).toBe(1); + vi.useRealTimers(); }); }); From 36d4636a7d3467d1c8f98cf62405cfeba2ec9a82 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 07:29:59 +0200 Subject: [PATCH 49/65] swap remaining tests from msw to mocked fetch --- packages/vercel-flags-core/package.json | 3 - .../src/controller/stream-connection.test.ts | 468 ++++------- .../src/utils/usage-tracker.test.ts | 779 +++++------------- pnpm-lock.yaml | 47 -- 4 files changed, 363 insertions(+), 934 deletions(-) diff --git a/packages/vercel-flags-core/package.json b/packages/vercel-flags-core/package.json index 25348f71..35f6f68f 100644 --- a/packages/vercel-flags-core/package.json +++ b/packages/vercel-flags-core/package.json @@ -69,11 +69,8 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.18.2", - "@fetch-mock/vitest": "0.2.18", "@types/node": "20.11.17", - "fetch-mock": "12.6.0", "flags": "workspace:*", - "msw": "2.6.4", "next": "16.1.6", "tsup": "8.5.1", "typescript": "5.6.3", diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index b41491b4..2605ccb2 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -1,27 +1,13 @@ -import { HttpResponse, http } from 'msw'; -import { setupServer } from 'msw/node'; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { connectStream } from './stream-connection'; const HOST = 'https://flags.vercel.com'; +const fetchMock = vi.fn(); -const server = setupServer(); - -beforeAll(() => server.listen()); beforeEach(() => { vi.clearAllMocks(); + fetchMock.mockReset(); }); -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); function createNdjsonStream( messages: object[], @@ -43,25 +29,41 @@ function createNdjsonStream( }); } +function streamResponse( + body: ReadableStream | null, + status = 200, +): Promise { + return Promise.resolve( + new Response(body, { + status, + headers: { 'Content-Type': 'application/x-ndjson' }, + }), + ); +} + +function ndjsonResponse(messages: object[], options?: { keepOpen?: boolean }) { + return streamResponse(createNdjsonStream(messages, options)); +} + +const datafileMsg = (definitions = {}) => ({ + type: 'datafile' as const, + data: { projectId: 'test', definitions }, +}); + describe('connectStream', () => { describe('connection success', () => { it('should resolve when first datafile message is received', async () => { const definitions = { projectId: 'test', definitions: {} }; - server.use( - http.get(`${HOST}/v1/stream`, () => { - return new HttpResponse( - createNdjsonStream([{ type: 'datafile', data: definitions }]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), + fetchMock.mockImplementation(() => + ndjsonResponse([{ type: 'datafile', data: definitions }]), ); const abortController = new AbortController(); const onMessage = vi.fn(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage }, ); @@ -75,20 +77,15 @@ describe('connectStream', () => { definitions: { flag: { variants: [true] } }, }; - server.use( - http.get(`${HOST}/v1/stream`, () => { - return new HttpResponse( - createNdjsonStream([{ type: 'datafile', data: definitions }]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), + fetchMock.mockImplementation(() => + ndjsonResponse([{ type: 'datafile', data: definitions }]), ); const abortController = new AbortController(); const onMessage = vi.fn(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage }, ); @@ -100,24 +97,19 @@ describe('connectStream', () => { it('should ignore ping messages', async () => { const definitions = { projectId: 'test', definitions: {} }; - server.use( - http.get(`${HOST}/v1/stream`, () => { - return new HttpResponse( - createNdjsonStream([ - { type: 'ping' }, - { type: 'datafile', data: definitions }, - { type: 'ping' }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), + fetchMock.mockImplementation(() => + ndjsonResponse([ + { type: 'ping' }, + { type: 'datafile', data: definitions }, + { type: 'ping' }, + ]), ); const abortController = new AbortController(); const onMessage = vi.fn(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage }, ); @@ -135,27 +127,24 @@ describe('connectStream', () => { const part1 = fullMessage.slice(0, 20); const part2 = `${fullMessage.slice(20)}\n`; - server.use( - http.get(`${HOST}/v1/stream`, () => { - return new HttpResponse( - new ReadableStream({ - async start(controller) { - controller.enqueue(new TextEncoder().encode(part1)); - await new Promise((r) => setTimeout(r, 10)); - controller.enqueue(new TextEncoder().encode(part2)); - controller.close(); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), + fetchMock.mockImplementation(() => + streamResponse( + new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode(part1)); + await new Promise((r) => setTimeout(r, 10)); + controller.enqueue(new TextEncoder().encode(part2)); + controller.close(); + }, + }), + ), ); const abortController = new AbortController(); const onMessage = vi.fn(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage }, ); @@ -166,32 +155,29 @@ describe('connectStream', () => { it('should skip empty lines in stream', async () => { const definitions = { projectId: 'test', definitions: {} }; - server.use( - http.get(`${HOST}/v1/stream`, () => { - return new HttpResponse( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('\n\n')); - controller.enqueue( - new TextEncoder().encode( - JSON.stringify({ type: 'datafile', data: definitions }) + - '\n', - ), - ); - controller.enqueue(new TextEncoder().encode('\n')); - controller.close(); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), + fetchMock.mockImplementation(() => + streamResponse( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('\n\n')); + controller.enqueue( + new TextEncoder().encode( + JSON.stringify({ type: 'datafile', data: definitions }) + + '\n', + ), + ); + controller.enqueue(new TextEncoder().encode('\n')); + controller.close(); + }, + }), + ), ); const abortController = new AbortController(); const onMessage = vi.fn(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage }, ); @@ -201,126 +187,73 @@ describe('connectStream', () => { }); describe('headers', () => { - it('should include Authorization header with Bearer token', async () => { - let capturedHeaders: Headers | null = null; - - server.use( - http.get(`${HOST}/v1/stream`, ({ request }) => { - capturedHeaders = request.headers; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); + beforeEach(() => { + fetchMock.mockImplementation(() => ndjsonResponse([datafileMsg()])); + }); + it('should include Authorization header with Bearer token', async () => { const abortController = new AbortController(); await connectStream( - { host: HOST, sdkKey: 'vf_my_key', abortController }, + { host: HOST, sdkKey: 'vf_my_key', abortController, fetch: fetchMock }, { onMessage: vi.fn() }, ); - expect(capturedHeaders!.get('Authorization')).toBe('Bearer vf_my_key'); + const headers = fetchMock.mock.calls[0]![1]!.headers as Record< + string, + string + >; + expect(headers.Authorization).toBe('Bearer vf_my_key'); abortController.abort(); }); it('should include User-Agent header with version', async () => { - let capturedHeaders: Headers | null = null; - - server.use( - http.get(`${HOST}/v1/stream`, ({ request }) => { - capturedHeaders = request.headers; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - const abortController = new AbortController(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage: vi.fn() }, ); - expect(capturedHeaders!.get('User-Agent')).toMatch(/^VercelFlagsCore\//); + const headers = fetchMock.mock.calls[0]![1]!.headers as Record< + string, + string + >; + expect(headers['User-Agent']).toMatch(/^VercelFlagsCore\//); abortController.abort(); }); it('should include X-Retry-Attempt header starting at 0', async () => { - let capturedHeaders: Headers | null = null; - - server.use( - http.get(`${HOST}/v1/stream`, ({ request }) => { - capturedHeaders = request.headers; - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - const abortController = new AbortController(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage: vi.fn() }, ); - expect(capturedHeaders!.get('X-Retry-Attempt')).toBe('0'); + const headers = fetchMock.mock.calls[0]![1]!.headers as Record< + string, + string + >; + expect(headers['X-Retry-Attempt']).toBe('0'); abortController.abort(); }); }); describe('retry behavior', () => { - beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true })); + beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it('should increment X-Retry-Attempt on reconnect after stream closes', async () => { - const retryAttempts: string[] = []; let requestCount = 0; - server.use( - http.get(`${HOST}/v1/stream`, ({ request }) => { - retryAttempts.push(request.headers.get('X-Retry-Attempt') ?? ''); - requestCount++; - - // First request: send data then close - // Second request: send data and keep open - return new HttpResponse( - createNdjsonStream( - [ - { - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - }, - ], - { keepOpen: requestCount >= 2 }, - ), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); + fetchMock.mockImplementation(() => { + requestCount++; + return ndjsonResponse([datafileMsg()], { keepOpen: requestCount >= 2 }); + }); const abortController = new AbortController(); const onDisconnect = vi.fn(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage: vi.fn(), onDisconnect }, ); @@ -329,42 +262,27 @@ describe('connectStream', () => { await vi.advanceTimersByTimeAsync(0); expect(requestCount).toBeGreaterThanOrEqual(2); - expect(retryAttempts[0]).toBe('0'); - expect(retryAttempts[1]).toBe('1'); + const h0 = fetchMock.mock.calls[0]![1]!.headers as Record; + const h1 = fetchMock.mock.calls[1]![1]!.headers as Record; + expect(h0['X-Retry-Attempt']).toBe('0'); + expect(h1['X-Retry-Attempt']).toBe('1'); expect(onDisconnect).toHaveBeenCalled(); abortController.abort(); }); it('should reset retryCount to 0 after receiving datafile', async () => { - const retryAttempts: string[] = []; let requestCount = 0; - server.use( - http.get(`${HOST}/v1/stream`, ({ request }) => { - retryAttempts.push(request.headers.get('X-Retry-Attempt') ?? ''); - requestCount++; - - // Close stream after each datafile to trigger reconnect - return new HttpResponse( - createNdjsonStream( - [ - { - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - }, - ], - { keepOpen: requestCount >= 3 }, - ), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); + fetchMock.mockImplementation(() => { + requestCount++; + return ndjsonResponse([datafileMsg()], { keepOpen: requestCount >= 3 }); + }); const abortController = new AbortController(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage: vi.fn() }, ); @@ -379,46 +297,28 @@ describe('connectStream', () => { expect(requestCount).toBeGreaterThanOrEqual(3); // Each reconnect after successful datafile should reset to 0, then increment by 1 - // Request 1: retry=0, gets datafile, resets to 0, stream closes, increments to 1 - // Request 2: retry=1, gets datafile, resets to 0, stream closes, increments to 1 - // Request 3: retry=1, gets datafile, resets to 0 - expect(retryAttempts[0]).toBe('0'); - expect(retryAttempts[1]).toBe('1'); - expect(retryAttempts[2]).toBe('1'); + const h0 = fetchMock.mock.calls[0]![1]!.headers as Record; + const h1 = fetchMock.mock.calls[1]![1]!.headers as Record; + const h2 = fetchMock.mock.calls[2]![1]!.headers as Record; + expect(h0['X-Retry-Attempt']).toBe('0'); + expect(h1['X-Retry-Attempt']).toBe('1'); + expect(h2['X-Retry-Attempt']).toBe('1'); abortController.abort(); }); it('should enforce minimum delay between reconnection attempts when retryCount resets', async () => { - const retryAttempts: string[] = []; let requestCount = 0; - server.use( - http.get(`${HOST}/v1/stream`, ({ request }) => { - retryAttempts.push(request.headers.get('X-Retry-Attempt') ?? ''); - requestCount++; - - // Each request: send datafile (resets retryCount to 0) then close - // On the 4th request, keep open to stop the loop - return new HttpResponse( - createNdjsonStream( - [ - { - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - }, - ], - { keepOpen: requestCount >= 4 }, - ), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); + fetchMock.mockImplementation(() => { + requestCount++; + return ndjsonResponse([datafileMsg()], { keepOpen: requestCount >= 4 }); + }); const abortController = new AbortController(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage: vi.fn() }, ); @@ -447,29 +347,16 @@ describe('connectStream', () => { it('should call onDisconnect when stream ends normally', async () => { let requestCount = 0; - server.use( - http.get(`${HOST}/v1/stream`, () => { - requestCount++; - return new HttpResponse( - createNdjsonStream( - [ - { - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - }, - ], - { keepOpen: requestCount >= 2 }, - ), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); + fetchMock.mockImplementation(() => { + requestCount++; + return ndjsonResponse([datafileMsg()], { keepOpen: requestCount >= 2 }); + }); const abortController = new AbortController(); const onDisconnect = vi.fn(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage: vi.fn(), onDisconnect }, ); @@ -494,17 +381,15 @@ describe('connectStream', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let requestCount = 0; - server.use( - http.get(`${HOST}/v1/stream`, () => { - requestCount++; - return new HttpResponse(null, { status: 500 }); - }), - ); + fetchMock.mockImplementation(() => { + requestCount++; + return Promise.resolve(new Response(null, { status: 500 })); + }); const abortController = new AbortController(); const promise = connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage: vi.fn() }, ); @@ -533,21 +418,20 @@ describe('connectStream', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let requestCount = 0; - server.use( - http.get(`${HOST}/v1/stream`, () => { - requestCount++; - // Return a response without a body - return new HttpResponse(null, { + fetchMock.mockImplementation(() => { + requestCount++; + return Promise.resolve( + new Response(null, { status: 200, headers: { 'Content-Type': 'application/x-ndjson' }, - }); - }), - ); + }), + ); + }); const abortController = new AbortController(); const promise = connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage: vi.fn() }, ); @@ -576,31 +460,19 @@ describe('connectStream', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let requestCount = 0; - server.use( - http.get(`${HOST}/v1/stream`, () => { - requestCount++; - if (requestCount === 1) { - // First request succeeds - return new HttpResponse( - createNdjsonStream([ - { - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - } - // Subsequent requests fail - return new HttpResponse(null, { status: 500 }); - }), - ); + fetchMock.mockImplementation(() => { + requestCount++; + if (requestCount === 1) { + return ndjsonResponse([datafileMsg()]); + } + return Promise.resolve(new Response(null, { status: 500 })); + }); const abortController = new AbortController(); const onDisconnect = vi.fn(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage: vi.fn(), onDisconnect }, ); @@ -622,35 +494,32 @@ describe('connectStream', () => { // This is tested indirectly through FlagNetworkDataSource integration tests. it('should stop when abortController is aborted externally', async () => { - server.use( - http.get(`${HOST}/v1/stream`, ({ request }) => { - return new HttpResponse( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ - type: 'datafile', - data: { projectId: 'test', definitions: {} }, - })}\n`, - ), - ); - // Keep stream open - request.signal.addEventListener('abort', () => { - controller.close(); - }); - }, - }), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), + fetchMock.mockImplementation((_input, init) => + streamResponse( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `${JSON.stringify({ + type: 'datafile', + data: { projectId: 'test', definitions: {} }, + })}\n`, + ), + ); + // Keep stream open + init?.signal?.addEventListener('abort', () => { + controller.close(); + }); + }, + }), + ), ); const abortController = new AbortController(); const onMessage = vi.fn(); await connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage }, ); @@ -669,23 +538,18 @@ describe('connectStream', () => { const data1 = { projectId: 'test', definitions: { v: 1 } }; const data2 = { projectId: 'test', definitions: { v: 2 } }; - server.use( - http.get(`${HOST}/v1/stream`, () => { - return new HttpResponse( - createNdjsonStream([ - { type: 'datafile', data: data1 }, - { type: 'datafile', data: data2 }, - ]), - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), + fetchMock.mockImplementation(() => + ndjsonResponse([ + { type: 'datafile', data: data1 }, + { type: 'datafile', data: data2 }, + ]), ); const abortController = new AbortController(); const onMessage = vi.fn(); const promise = connectStream( - { host: HOST, sdkKey: 'vf_test', abortController }, + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, { onMessage }, ); diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index 084ca333..524ffc0b 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -1,14 +1,4 @@ -import { HttpResponse, http } from 'msw'; -import { setupServer } from 'msw/node'; -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - it, - vi, -} from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { setRequestContext } from '../test-utils'; import { type FlagsConfigReadEvent, UsageTracker } from './usage-tracker'; @@ -17,58 +7,69 @@ vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn(), })); -const server = setupServer(); +const fetchMock = vi.fn(); + +function jsonResponse( + body: unknown, + init?: { status?: number; headers?: Record }, +): Promise { + return Promise.resolve( + new Response(JSON.stringify(body), { + status: init?.status ?? 200, + headers: { + 'Content-Type': 'application/json', + ...init?.headers, + }, + }), + ); +} -beforeAll(() => server.listen()); afterEach(() => { - server.resetHandlers(); + fetchMock.mockReset(); vi.restoreAllMocks(); // Clean up environment variables delete process.env.VERCEL_DEPLOYMENT_ID; delete process.env.VERCEL_REGION; delete process.env.DEBUG; }); -afterAll(() => server.close()); + +function createTracker(sdkKey = 'test-key') { + return new UsageTracker({ + sdkKey, + host: 'https://example.com', + fetch: fetchMock, + }); +} + +function getBody(callIndex = 0): unknown { + const [, init] = fetchMock.mock.calls[callIndex]!; + return JSON.parse(init!.body as string); +} + +function getHeaders(callIndex = 0): Record { + const [, init] = fetchMock.mock.calls[callIndex]!; + return init!.headers as Record; +} describe('UsageTracker', () => { describe('constructor', () => { it('should create an instance with sdkKey and host', () => { - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); - + const tracker = createTracker(); expect(tracker).toBeInstanceOf(UsageTracker); }); }); describe('trackRead', () => { it('should batch events and send them after flush', async () => { - const receivedEvents: unknown[] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = await request.json(); - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + expect(fetchMock).toHaveBeenCalledTimes(1); + const events = getBody() as FlagsConfigReadEvent[]; expect(events).toHaveLength(1); const event = events[0] as FlagsConfigReadEvent; expect(event.type).toBe('FLAGS_CONFIG_READ'); @@ -79,218 +80,110 @@ describe('UsageTracker', () => { process.env.VERCEL_DEPLOYMENT_ID = 'dpl_123'; process.env.VERCEL_REGION = 'iad1'; - const receivedEvents: unknown[] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = await request.json(); - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.deploymentId).toBe('dpl_123'); expect(event.payload.region).toBe('iad1'); }); it('should batch multiple events', async () => { - const receivedEvents: unknown[] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = await request.json(); - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); // Track multiple reads (without request context, so they won't be deduplicated) tracker.trackRead(); tracker.trackRead(); tracker.trackRead(); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as Array<{ type: string }>; + const events = getBody() as Array<{ type: string }>; expect(events).toHaveLength(3); }); it('should send correct authorization header', async () => { - let authHeader: string | null = null; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - authHeader = request.headers.get('Authorization'); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = new UsageTracker({ sdkKey: 'my-secret-key', host: 'https://example.com', - fetch, + fetch: fetchMock, }); tracker.trackRead(); - tracker.flush(); + await tracker.flush(); - await vi.waitFor(() => { - expect(authHeader).toBe('Bearer my-secret-key'); - }); + expect(getHeaders().Authorization).toBe('Bearer my-secret-key'); }); it('should send correct content-type header', async () => { - let contentType: string | null = null; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - contentType = request.headers.get('Content-Type'); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); + await tracker.flush(); - await vi.waitFor(() => { - expect(contentType).toBe('application/json'); - }); + expect(getHeaders()['Content-Type']).toBe('application/json'); }); it('should send user-agent header', async () => { - let userAgent: string | null = null; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - userAgent = request.headers.get('User-Agent'); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); + await tracker.flush(); - await vi.waitFor(() => { - expect(userAgent).toMatch(/^VercelFlagsCore\//); - }); + expect(getHeaders()['User-Agent']).toMatch(/^VercelFlagsCore\//); }); it('should not send empty batches', async () => { - vi.useFakeTimers(); - let requestCount = 0; - - server.use( - http.post('https://example.com/v1/ingest', async () => { - requestCount++; - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); // Flush without tracking anything - tracker.flush(); + await tracker.flush(); - // Advance timers to ensure no request is made - await vi.advanceTimersByTimeAsync(100); - expect(requestCount).toBe(0); - vi.useRealTimers(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('should handle fetch errors gracefully', async () => { - vi.useFakeTimers(); const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); - server.use( - http.post('https://example.com/v1/ingest', () => { - return HttpResponse.error(); - }), - ); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); - - // Advance timers to let the flush complete - await vi.advanceTimersByTimeAsync(100); + await tracker.flush(); // Should not throw and should not log error (only logs in debug mode) expect(consoleSpy).not.toHaveBeenCalled(); - vi.useRealTimers(); }); it('should handle non-ok responses gracefully', async () => { - vi.useFakeTimers(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - server.use( - http.post('https://example.com/v1/ingest', () => { - return new HttpResponse(null, { status: 500 }); - }), - ); + fetchMock.mockResolvedValue(new Response(null, { status: 500 })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); - - // Advance timers to let the flush complete - await vi.advanceTimersByTimeAsync(100); + await tracker.flush(); // Should not log in non-debug mode expect(consoleSpy).not.toHaveBeenCalled(); - vi.useRealTimers(); }); it('should log errors in debug mode', async () => { @@ -301,27 +194,21 @@ describe('UsageTracker', () => { ); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - server.use( - http.post('https://example.com/v1/ingest', () => { - return new HttpResponse(null, { status: 500 }); - }), - ); + fetchMock.mockResolvedValue(new Response(null, { status: 500 })); const tracker = new FreshUsageTracker({ sdkKey: 'test-key', host: 'https://example.com', - fetch, + fetch: fetchMock, }); tracker.trackRead(); - tracker.flush(); + await tracker.flush(); - await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( - '@vercel/flags-core: Failed to send events:', - expect.any(String), - ); - }); + expect(consoleSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Failed to send events:', + expect.any(String), + ); }); it('should send x-vercel-debug-ingest header in debug mode', async () => { @@ -330,60 +217,41 @@ describe('UsageTracker', () => { const { UsageTracker: FreshUsageTracker } = await import( './usage-tracker' ); - let debugHeader: string | null = null; const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - debugHeader = request.headers.get('x-vercel-debug-ingest'); - return HttpResponse.json( - { ok: true }, - { headers: { 'x-vercel-id': 'iad1::abcdef-1234' } }, - ); - }), + fetchMock.mockImplementation(() => + jsonResponse( + { ok: true }, + { headers: { 'x-vercel-id': 'iad1::abcdef-1234' } }, + ), ); const tracker = new FreshUsageTracker({ sdkKey: 'test-key', host: 'https://example.com', - fetch, + fetch: fetchMock, }); tracker.trackRead(); - tracker.flush(); + await tracker.flush(); - await vi.waitFor(() => { - expect(debugHeader).toBe('1'); - expect(consoleSpy).toHaveBeenCalledWith( - '@vercel/flags-core: Ingest response 200 for 1 events on iad1::abcdef-1234', - ); - }); + expect(getHeaders()['x-vercel-debug-ingest']).toBe('1'); + expect(consoleSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Ingest response 200 for 1 events on iad1::abcdef-1234', + ); consoleSpy.mockRestore(); }); it('should not send x-vercel-debug-ingest header when not in debug mode', async () => { - let debugHeader: string | null = 'initial'; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - debugHeader = request.headers.get('x-vercel-debug-ingest'); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); + await tracker.flush(); - await vi.waitFor(() => { - expect(debugHeader).toBeNull(); - }); + expect(getHeaders()['x-vercel-debug-ingest']).toBeUndefined(); }); it('should log ingest response in debug mode', async () => { @@ -394,161 +262,91 @@ describe('UsageTracker', () => { ); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - server.use( - http.post('https://example.com/v1/ingest', () => { - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = new FreshUsageTracker({ sdkKey: 'test-key', host: 'https://example.com', - fetch, + fetch: fetchMock, }); tracker.trackRead(); - tracker.flush(); + await tracker.flush(); - await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - '@vercel/flags-core: Ingest response 200 for 1 events', - ), - ); - }); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + '@vercel/flags-core: Ingest response 200 for 1 events', + ), + ); }); }); describe('flush', () => { it('should trigger immediate flush of pending events', async () => { - const receivedEvents: unknown[] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = await request.json(); - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); // Flush immediately instead of waiting for timeout - tracker.flush(); + await tracker.flush(); - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should be safe to call flush multiple times', async () => { - vi.useFakeTimers(); - let requestCount = 0; - - server.use( - http.post('https://example.com/v1/ingest', async () => { - requestCount++; - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); tracker.flush(); tracker.flush(); - tracker.flush(); - - await vi.advanceTimersByTimeAsync(0); - expect(requestCount).toBe(1); + await tracker.flush(); - // Advance timers to ensure no additional requests - await vi.advanceTimersByTimeAsync(100); - expect(requestCount).toBe(1); - vi.useRealTimers(); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); describe('request context deduplication', () => { it('should deduplicate events with the same request context', async () => { - const receivedEvents: unknown[] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = await request.json(); - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const cleanupContext = setRequestContext({ 'x-vercel-id': 'test-request-id', host: 'example.com', }); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); // Track multiple times with same context tracker.trackRead(); tracker.trackRead(); tracker.trackRead(); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); // Only one event should be recorded due to deduplication - const events = receivedEvents[0] as Array<{ type: string }>; + const events = getBody() as Array<{ type: string }>; expect(events).toHaveLength(1); cleanupContext(); }); it('should include headers from request context', async () => { - const receivedEvents: FlagsConfigReadEvent[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as FlagsConfigReadEvent[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const cleanupContext = setRequestContext({ 'x-vercel-id': 'req_123', host: 'myapp.vercel.app', }); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.vercelRequestId).toBe('req_123'); expect(event.payload.invocationHost).toBe('myapp.vercel.app'); @@ -559,15 +357,7 @@ describe('UsageTracker', () => { describe('cross-instance deduplication', () => { it('should not deduplicate across separate UsageTracker instances', async () => { - const receivedEvents: unknown[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as unknown[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const cleanupContext = setRequestContext({ 'x-vercel-id': 'shared-request-id', @@ -577,28 +367,25 @@ describe('UsageTracker', () => { const tracker1 = new UsageTracker({ sdkKey: 'key-1', host: 'https://example.com', - fetch, + fetch: fetchMock, }); const tracker2 = new UsageTracker({ sdkKey: 'key-2', host: 'https://example.com', - fetch, + fetch: fetchMock, }); // Both trackers track with the same request context tracker1.trackRead(); tracker2.trackRead(); - tracker1.flush(); - tracker2.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(2); - }); + await tracker1.flush(); + await tracker2.flush(); // Each tracker should have sent its own event - expect(receivedEvents[0]).toHaveLength(1); - expect(receivedEvents[1]).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(getBody(0)).toHaveLength(1); + expect(getBody(1)).toHaveLength(1); cleanupContext(); }); @@ -607,96 +394,68 @@ describe('UsageTracker', () => { describe('flush failure retry', () => { it('should re-queue events on failed flush and send them on next flush', async () => { let requestCount = 0; - const receivedEvents: unknown[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - requestCount++; - if (requestCount === 1) { - // First flush fails - return new HttpResponse(null, { status: 500 }); - } - // Second flush succeeds - const body = (await request.json()) as unknown[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, + fetchMock.mockImplementation(async (_input, init) => { + requestCount++; + if (requestCount === 1) { + return new Response(null, { status: 500 }); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); }); + const tracker = createTracker(); + tracker.trackRead(); - tracker.flush(); + await tracker.flush(); - // Wait for the first (failing) flush to complete - await vi.waitFor(() => { - expect(requestCount).toBe(1); - }); + expect(requestCount).toBe(1); // Events should have been re-queued — a new trackRead triggers // a new schedule cycle which will include the re-queued events tracker.trackRead(); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); + expect(requestCount).toBe(2); // Should contain both the re-queued event and the new one - expect(receivedEvents[0]).toHaveLength(2); + expect(getBody(1)).toHaveLength(2); }); it('should re-queue events on fetch error and send them on next flush', async () => { let requestCount = 0; - const receivedEvents: unknown[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - requestCount++; - if (requestCount === 1) { - // First flush throws network error - return HttpResponse.error(); - } - // Second flush succeeds - const body = (await request.json()) as unknown[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + + fetchMock.mockImplementation(async () => { + requestCount++; + if (requestCount === 1) { + throw new TypeError('Failed to fetch'); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); + await tracker.flush(); - // Wait for the first (failing) flush to complete - await vi.waitFor(() => { - expect(requestCount).toBe(1); - }); + expect(requestCount).toBe(1); // Events should have been re-queued — a new trackRead triggers // a new schedule cycle which will include the re-queued events tracker.trackRead(); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); + expect(requestCount).toBe(2); // Should contain both the re-queued event and the new one - expect(receivedEvents[0]).toHaveLength(2); + expect(getBody(1)).toHaveLength(2); consoleSpy.mockRestore(); }); @@ -704,33 +463,21 @@ describe('UsageTracker', () => { describe('batch size limit', () => { it('should trigger flush when batch size reaches 50', async () => { - const receivedEvents: unknown[] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = await request.json(); - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); // Track 50 events (without request context to avoid deduplication) for (let i = 0; i < 50; i++) { tracker.trackRead(); } - // Should auto-flush at 50 events + // Should auto-flush at 50 events — wait for the scheduled flush await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); }); - const events = receivedEvents[0] as Array<{ type: string }>; + const events = getBody() as Array<{ type: string }>; expect(events).toHaveLength(50); }); }); @@ -745,11 +492,7 @@ describe('UsageTracker', () => { }, }; - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); // Should not throw expect(() => tracker.trackRead()).not.toThrow(); @@ -761,199 +504,91 @@ describe('UsageTracker', () => { describe('trackRead options', () => { it('should include configOrigin in the event payload', async () => { - const receivedEvents: FlagsConfigReadEvent[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as FlagsConfigReadEvent[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory' }); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.configOrigin).toBe('in-memory'); }); it('should include cacheStatus in the event payload', async () => { - const receivedEvents: FlagsConfigReadEvent[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as FlagsConfigReadEvent[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory', cacheStatus: 'HIT' }); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.cacheStatus).toBe('HIT'); }); it('should include cacheIsFirstRead in the event payload', async () => { - const receivedEvents: FlagsConfigReadEvent[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as FlagsConfigReadEvent[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory', cacheIsFirstRead: true }); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.cacheIsFirstRead).toBe(true); }); it('should include cacheIsBlocking in the event payload', async () => { - const receivedEvents: FlagsConfigReadEvent[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as FlagsConfigReadEvent[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory', cacheIsBlocking: true }); - tracker.flush(); + await tracker.flush(); - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); - - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.cacheIsBlocking).toBe(true); }); it('should include duration in the event payload', async () => { - const receivedEvents: FlagsConfigReadEvent[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as FlagsConfigReadEvent[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory', duration: 150 }); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.duration).toBe(150); }); it('should include configUpdatedAt in the event payload', async () => { - const receivedEvents: FlagsConfigReadEvent[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as FlagsConfigReadEvent[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); const timestamp = Date.now(); tracker.trackRead({ configOrigin: 'in-memory', configUpdatedAt: timestamp, }); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.configUpdatedAt).toBe(timestamp); }); it('should include all options in the event payload', async () => { - const receivedEvents: FlagsConfigReadEvent[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as FlagsConfigReadEvent[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); const timestamp = Date.now(); tracker.trackRead({ @@ -964,13 +599,9 @@ describe('UsageTracker', () => { duration: 200, configUpdatedAt: timestamp, }); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.configOrigin).toBe('in-memory'); expect(event.payload.cacheStatus).toBe('MISS'); @@ -981,31 +612,15 @@ describe('UsageTracker', () => { }); it('should omit undefined options from the event payload', async () => { - const receivedEvents: FlagsConfigReadEvent[][] = []; - - server.use( - http.post('https://example.com/v1/ingest', async ({ request }) => { - const body = (await request.json()) as FlagsConfigReadEvent[]; - receivedEvents.push(body); - return HttpResponse.json({ ok: true }); - }), - ); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - const tracker = new UsageTracker({ - sdkKey: 'test-key', - host: 'https://example.com', - fetch, - }); + const tracker = createTracker(); // Only pass configOrigin, omit others tracker.trackRead({ configOrigin: 'embedded' }); - tracker.flush(); - - await vi.waitFor(() => { - expect(receivedEvents.length).toBe(1); - }); + await tracker.flush(); - const events = receivedEvents[0] as FlagsConfigReadEvent[]; + const events = getBody() as FlagsConfigReadEvent[]; const event = events[0] as FlagsConfigReadEvent; expect(event.payload.configOrigin).toBe('embedded'); expect(event.payload.cacheStatus).toBeUndefined(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d08b53c..c412a970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -954,21 +954,12 @@ importers: '@arethetypeswrong/cli': specifier: 0.18.2 version: 0.18.2 - '@fetch-mock/vitest': - specifier: 0.2.18 - version: 0.2.18(vitest@2.1.9(@types/node@20.11.17)(lightningcss@1.30.2)(msw@2.6.4(@types/node@20.11.17)(typescript@5.6.3))) '@types/node': specifier: 20.11.17 version: 20.11.17 - fetch-mock: - specifier: 12.6.0 - version: 12.6.0 flags: specifier: workspace:* version: link:../flags - msw: - specifier: 2.6.4 - version: 2.6.4(@types/node@20.11.17)(typescript@5.6.3) next: specifier: 16.1.6 version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-da641178-20260129))(react@19.3.0-canary-da641178-20260129) @@ -1864,12 +1855,6 @@ packages: resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@fetch-mock/vitest@0.2.18': - resolution: {integrity: sha512-s2bG7/MSwVFun5gTzrkZzJSmcdSurTmxt5B+JA/4ALyx0Pfo1al0/MlZPBtZ358Kkjv9CpRlhpyLf6bt4OrtLQ==} - engines: {node: '>=18.11.0'} - peerDependencies: - vitest: '*' - '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -4211,9 +4196,6 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/glob-to-regexp@0.4.4': - resolution: {integrity: sha512-nDKoaKJYbnn1MZxUY0cA1bPmmgZbg0cTq7Rh13d0KWYNOiKbqoR+2d89SnRPszGh7ROzSwZ/GOjZ4jPbmmZ6Eg==} - '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -5542,10 +5524,6 @@ packages: resolution: {integrity: sha512-hgH6CCb+7+0c8PBlakI2KubG6R+Rb1MhpNcdvqUXZTBwBHf32piwY255diAkAmkGZ6AWlywOU88AkOgP9q8Rdw==} engines: {node: '>=20', pnpm: '>=10'} - fetch-mock@12.6.0: - resolution: {integrity: sha512-oAy0OqAvjAvduqCeWveBix7LLuDbARPqZZ8ERYtBcCURA3gy7EALA3XWq0tCNxsSg+RmmJqyaeeZlOCV9abv6w==} - engines: {node: '>=18.11.0'} - fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -5797,9 +5775,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -7439,10 +7414,6 @@ packages: resolution: {integrity: sha512-6IQpFBv6e5vz1QAqI+V4k8P2e/3gRrqfCJ9FI+O1FLQTO+Uz6RXZEZOPmTJ6hlGj7gkERzY5BRCv09whKP96/g==} engines: {node: '>=6'} - regexparam@3.0.0: - resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} - engines: {node: '>=8'} - rehype-harden@1.1.7: resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==} @@ -9122,11 +9093,6 @@ snapshots: '@eslint/core': 0.16.0 levn: 0.4.1 - '@fetch-mock/vitest@0.2.18(vitest@2.1.9(@types/node@20.11.17)(lightningcss@1.30.2)(msw@2.6.4(@types/node@20.11.17)(typescript@5.6.3)))': - dependencies: - fetch-mock: 12.6.0 - vitest: 2.1.9(@types/node@20.11.17)(lightningcss@1.30.2)(msw@2.6.4(@types/node@20.11.17)(typescript@5.6.3)) - '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -11870,8 +11836,6 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/glob-to-regexp@0.4.4': {} - '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -13498,13 +13462,6 @@ snapshots: dependencies: xml-js: 1.6.11 - fetch-mock@12.6.0: - dependencies: - '@types/glob-to-regexp': 0.4.4 - dequal: 2.0.3 - glob-to-regexp: 0.4.1 - regexparam: 3.0.0 - fflate@0.8.2: {} file-entry-cache@8.0.0: @@ -13755,8 +13712,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.4.1: {} - glob@10.4.5: dependencies: foreground-child: 3.3.1 @@ -15891,8 +15846,6 @@ snapshots: regexparam@1.3.0: {} - regexparam@3.0.0: {} - rehype-harden@1.1.7: dependencies: unist-util-visit: 5.0.0 From 224792c106b679731457f023a51714dc479a4578 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 07:37:53 +0200 Subject: [PATCH 50/65] update comments --- packages/vercel-flags-core/src/controller/index.ts | 2 +- .../src/controller/stream-connection.test.ts | 6 +++--- packages/vercel-flags-core/src/lib/report-value.ts | 2 +- .../src/utils/read-bundled-definitions.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 537535b7..2cffa37d 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -108,7 +108,7 @@ type State = // --------------------------------------------------------------------------- /** - * A DataSource implementation that connects to flags.vercel.com. + * Connects to flags.vercel.com and manages flag definitions. * * Implemented as a state machine controller that delegates all I/O to * source modules (StreamSource, PollingSource, BundledSource). diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index 2605ccb2..cf48cdc7 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -371,10 +371,10 @@ describe('connectStream', () => { }); describe('failure cases', () => { - // Note: 401 response behavior is tested through FlagNetworkDataSource + // Note: 401 response behavior is tested through Controller // which handles the timeout fallback. The stream-connection aborts on 401 // but the promise resolution is handled by the timeout mechanism in - // FlagNetworkDataSource.getDataWithStreamTimeout(). + // Controller. it('should retry on error before first datafile and reject when aborted', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -491,7 +491,7 @@ describe('connectStream', () => { // make the test too slow. The behavior is: // - After 10 retries without receiving data, the connection aborts // - console.error('@vercel/flags-core: Max retry count exceeded') is logged - // This is tested indirectly through FlagNetworkDataSource integration tests. + // This is tested indirectly through Controller integration tests. it('should stop when abortController is aborted externally', async () => { fetchMock.mockImplementation((_input, init) => diff --git a/packages/vercel-flags-core/src/lib/report-value.ts b/packages/vercel-flags-core/src/lib/report-value.ts index 94f5990a..43994038 100644 --- a/packages/vercel-flags-core/src/lib/report-value.ts +++ b/packages/vercel-flags-core/src/lib/report-value.ts @@ -2,7 +2,7 @@ import { version } from '../../package.json'; import type { OutcomeType, ResolutionReason } from '../types'; /** - * Only used interally for now. + * Only used internally for now. */ export function internalReportValue( key: string, diff --git a/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts b/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts index cc7cf0f3..aa58c050 100644 --- a/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts +++ b/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // The readBundledDefinitions function uses dynamic import which is hard to mock. -// Instead, we test the behavior indirectly through the FlagNetworkDataSource +// Instead, we test the behavior indirectly through the Controller // which already mocks readBundledDefinitions. // Here we just test the function interface and basic behavior. @@ -49,7 +49,7 @@ describe('readBundledDefinitions', () => { }); // The detailed behavior of readBundledDefinitions is tested indirectly - // through FlagNetworkDataSource tests which mock readBundledDefinitions. + // through Controller tests which mock readBundledDefinitions. // Those tests cover: // - 'ok' state with bundled definitions // - 'missing-file' state From 144feab7db1ea4eceefd21b06b931428c10c1d45 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 07:39:43 +0200 Subject: [PATCH 51/65] Update CLAUDE.md --- packages/vercel-flags-core/CLAUDE.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 4249def2..1151bb3a 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -26,9 +26,11 @@ src/ │ ├── normalized-options.ts # Option normalization │ └── typed-emitter.ts # Lightweight typed event emitter ├── openfeature.*.ts # OpenFeature provider +├── test-utils.ts # Shared test helpers ├── utils/ # Utilities │ ├── usage-tracker.ts │ ├── sdk-keys.ts +│ ├── sleep.ts │ └── read-bundled-definitions.ts └── lib/ └── report-value.ts # Flag evaluation reporting to Vercel request context @@ -103,7 +105,7 @@ 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`): -1. **Stream** - Real-time updates via SSE, wait up to `initTimeoutMs` +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 4. **Bundled definitions** - Use `@vercel/flags-definitions` @@ -176,8 +178,8 @@ pnpm test:integration - Interval-based HTTP requests to `/v1/datafile` - Default `intervalMs`: 30000ms (30s) -- Default `initTimeoutMs`: 10000ms (10s) -- Retries with exponential backoff (base: 500ms, max 3 retries) +- Default `initTimeoutMs`: 3000ms (3s) +- No retries — on fetch failure, emits an error event and waits for the next interval - Stops automatically when stream reconnects - `PollingSource` passes its abort signal to `fetchDatafile`, so calling `stop()` aborts in-flight HTTP requests - `fetchDatafile` accepts an optional `signal` parameter; when provided, it aborts the internal fetch controller when the external signal fires @@ -214,7 +216,7 @@ The Controller rejects incoming data (from stream or poll) if its `configUpdated ### Evaluation Reporting -- `internalReportValue` in `controller-fns.ts` reports flag evaluations to the Vercel request context +- `internalReportValue` (defined in `lib/report-value.ts`, called from `controller-fns.ts`) reports flag evaluations to the Vercel request context - Reports are sent for all evaluations where `datafile.projectId` exists, including error cases (e.g., FLAG_NOT_FOUND) ### Evaluation Safety @@ -224,7 +226,7 @@ The Controller rejects incoming data (from stream or poll) if its `configUpdated ### Debug Mode -Enable debug logging with `DEBUG=1` environment variable. +Enable debug logging with `DEBUG=@vercel/flags-core` environment variable. ## Dependencies From 0d5af6c1084221e4fc12c881a7cdba7aafa1febd Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 07:41:16 +0200 Subject: [PATCH 52/65] update comment --- packages/vercel-flags-core/CLAUDE.md | 3 ++- packages/vercel-flags-core/src/controller/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 1151bb3a..501d8252 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -100,7 +100,8 @@ Behavior differs based on environment: **Build step** (CI=1, NEXT_PHASE=phase-production-build, or `buildStep: true`): 1. **Provided datafile** - Use `options.datafile` if provided 2. **Bundled definitions** - Use `@vercel/flags-definitions` -3. **Throw** - No network during build +3. **One-time fetch** - Fallback network request +4. **Throw** - If all above fail 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). diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 2cffa37d..c1fffa2a 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -114,8 +114,8 @@ type State = * source modules (StreamSource, PollingSource, BundledSource). * * **Build step** (CI=1 or Next.js build, or buildStep: true): - * - Uses datafile (if provided) or bundled definitions - * - No streaming, polling, or fetching + * - Uses datafile (if provided), bundled definitions, or one-time fetch as fallback + * - No streaming or polling * * **Runtime — streaming mode** (stream enabled): * - Uses streaming exclusively From 99d47b039c732caba53f00a2e36b9c621314eee7 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 07:45:37 +0200 Subject: [PATCH 53/65] fix comment --- .../vercel-flags-core/src/controller/index.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index c1fffa2a..c310244a 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -118,16 +118,17 @@ type State = * - No streaming or polling * * **Runtime — streaming mode** (stream enabled): - * - Uses streaming exclusively - * - Fallback: last known value → constructor datafile → bundled → defaultValue → throw - * - Polling is never started, even if configured + * - Uses streaming exclusively; polling is never started, even if configured + * - Init fallback (no data yet): constructor datafile → bundled → throw + * - Read fallback (post-init): in-memory value → constructor datafile → bundled → throw * * **Runtime — polling mode** (polling enabled, stream disabled): * - Uses polling exclusively - * - Same fallback chain + * - Same fallback chains as streaming mode * * **Runtime — offline mode** (neither stream nor polling): - * - Uses constructor datafile → bundled → one-time fetch → defaultValue → throw + * - Init fallback: constructor datafile → bundled → one-time fetch → throw + * - Read fallback: in-memory value → constructor datafile → bundled → one-time fetch → throw */ export class Controller implements ControllerInterface { private options: NormalizedOptions; @@ -264,7 +265,7 @@ export class Controller implements ControllerInterface { /** * Initializes the data source. * - * Build step: datafile → bundled (no network) + * Build step: datafile → bundled → one-time fetch * Streaming mode: stream → datafile → bundled * Polling mode (no stream): poll → datafile → bundled * Offline mode (neither): datafile → bundled → one-time fetch @@ -428,7 +429,7 @@ export class Controller implements ControllerInterface { * Resolves the current data, using the appropriate strategy for the * current mode. Returns tagged data and cache status. * - * Build step: cached → bundled (no network) + * Build step: cached → bundled → one-time fetch * Runtime with cache: return cached data * Runtime without cache: stream/poll → datafile → bundled → fetch → throw */ From f56eda55ba23fc21a4fdd2b53c4004a8b7ab5c7d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 07:48:29 +0200 Subject: [PATCH 54/65] fix types --- packages/vercel-flags-core/src/index.next-js.ts | 2 +- packages/vercel-flags-core/src/openfeature.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/src/index.next-js.ts b/packages/vercel-flags-core/src/index.next-js.ts index 19422a18..7cbb9127 100644 --- a/packages/vercel-flags-core/src/index.next-js.ts +++ b/packages/vercel-flags-core/src/index.next-js.ts @@ -31,7 +31,7 @@ function setCacheLife(): void { } } -const cachedFns: typeof fns = { +const cachedFns: Parameters[0] = { initialize: async (...args) => { 'use cache'; setCacheLife(); diff --git a/packages/vercel-flags-core/src/openfeature.test.ts b/packages/vercel-flags-core/src/openfeature.test.ts index 31f4b90e..20fd020b 100644 --- a/packages/vercel-flags-core/src/openfeature.test.ts +++ b/packages/vercel-flags-core/src/openfeature.test.ts @@ -19,6 +19,7 @@ function createStaticController(opts: { source: 'in-memory', cacheStatus: 'HIT', connectionState: 'connected', + mode: 'streaming', }, }; return { From e60cad08a79eb1dc7867b8c380e5e600b826bbb6 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 21 Feb 2026 08:02:43 +0200 Subject: [PATCH 55/65] rm peek --- packages/vercel-flags-core/src/create-raw-client.ts | 5 ----- packages/vercel-flags-core/src/types.ts | 10 ---------- 2 files changed, 15 deletions(-) diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index be81366e..105e64eb 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -108,11 +108,6 @@ export function createCreateRawClient(fns: { } return fns.evaluate(id, flagKey, defaultValue, entities); }, - peek: () => { - const instance = controllerInstanceMap.get(id); - if (!instance) throw new Error(`Instance not found for id ${id}`); - return instance; - }, }; return api; }; diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index ce189043..32c96af5 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -115,11 +115,6 @@ export type Source = { projectSlug: string; }; -export type PeekResult = { - datafile: Datafile; - fallbackDatafile?: BundledDefinitions; -}; - /** * A client for Vercel Flags */ @@ -164,11 +159,6 @@ export type FlagsClient = { * Throws FallbackEntryNotFoundError if the file exists but has no entry for the SDK key. */ getFallbackDatafile(): Promise; - - /** - * Peek offers insights into the client's current state. Used for debugging purposes. Not covered by semver. - */ - peek(): ControllerInstance; }; export type EvaluationParams = { From 0f2b228a063a139cb92e0ef3be3963911d5d7805 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 24 Feb 2026 11:52:16 +0200 Subject: [PATCH 56/65] adds ping checks and sending x-revision header (#282) * abort on missed pings * use revision from bundled definitions * tests * simplify * send x-revsion on reconnects too * simplify StreamSource * rename * rename in test * wait for stream * replace startBackgroundUpdates with explicit call * Update CLAUDE.md * restore stronger tests * Update CLAUDE.md * convert * simplify tests * fix test --- packages/vercel-flags-core/CLAUDE.md | 66 +- .../vercel-flags-core/src/black-box.test.ts | 1102 ++++++++++++++--- .../src/controller/bundled-source.ts | 2 +- .../vercel-flags-core/src/controller/index.ts | 69 +- .../src/controller/normalized-options.ts | 3 - .../src/controller/stream-connection.test.ts | 456 ++++++- .../src/controller/stream-connection.ts | 170 ++- .../src/controller/stream-source.ts | 33 +- packages/vercel-flags-core/src/types.ts | 2 + 9 files changed, 1618 insertions(+), 285 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 501d8252..bdabacf5 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -113,9 +113,9 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu 5. **One-time fetch** - Last resort (only when stream and polling are both disabled) Key behaviors: -- Bundled definitions are always loaded as ultimate fallback -- All mechanisms write to in-memory state -- If in-memory state exists, serve immediately while background updates happen +- Bundled definitions are loaded eagerly so their revision can be sent to the stream via `X-Revision` header +- When streaming is enabled and data already exists (bundled or provided), `initialize()` still waits for stream confirmation (`primed` or `datafile`) up to `initTimeoutMs`, then falls back to existing data on timeout +- For polling or offline mode with existing data, `initialize()` returns immediately - **Never stream AND poll simultaneously** - If stream reconnects while polling → stop polling - If stream disconnects → start polling (if enabled) @@ -148,13 +148,21 @@ The package has conditional exports based on environment: ## Commands +All commands must be run from the package directory (`packages/vercel-flags-core`): + ```bash # Build pnpm build -# Test +# Run all tests pnpm test +# Run a single test file +pnpm vitest --run src/black-box.test.ts + +# Run a single test file in watch mode +pnpm vitest src/black-box.test.ts + # Type check pnpm check @@ -162,18 +170,66 @@ pnpm check pnpm test:integration ``` +## Test Guidelines (black-box.test.ts) + +### Critical rules + +- **All tests must use fake timers** unless there is a specific reason to use `vi.useRealTimers()`. The `beforeEach` sets up fake timers; only opt out when testing real async timing. +- **No stderr leaks**: every `console.warn` and `console.error` the implementation emits must be captured by a spy (`vi.spyOn(console, 'warn').mockImplementation(() => {})`) and asserted. A test that produces stderr output is broken. +- **Tests should complete in milliseconds**, not seconds. If a test takes ~3s, it's hitting a real timeout instead of advancing fake timers. + +### initialize() blocks on stream confirmation + +`initialize()` waits for a stream message (`primed` or `datafile`) up to `initTimeoutMs` before resolving, even when bundled data or a provided datafile is available. This means: + +- **With fake timers**: call `client.initialize()` (or `client.evaluate()` which triggers lazy init), then `await vi.advanceTimersByTimeAsync(initTimeoutMs)` to trigger the timeout fallback. +- **With real timers (`vi.useRealTimers()`)**: you MUST push a stream message before awaiting `initialize()`, otherwise it blocks for the real 3s timeout: + ```typescript + const initPromise = client.initialize(); + await new Promise((r) => setTimeout(r, 0)); // let stream connect + stream.push({ type: 'primed', revision: 42, projectId: 'prj_123', environment: 'production' }); + await initPromise; // resolves immediately + ``` + +### Prefer evaluate-driven tests over explicit initialize() + +Many tests on the `control` branch test that `evaluate()` triggers lazy initialization. Prefer this pattern to test the full public API path: +```typescript +const evalPromise = client.evaluate('flagA'); +await vi.advanceTimersByTimeAsync(3_000); +const result = await evalPromise; +``` +Only call `initialize()` explicitly when the test specifically needs to verify initialization behavior (e.g., deduplication, timing, init promise resolution). + +### Assert console output from the implementation + +The implementation logs warnings/errors for specific conditions. Tests must assert these: +- Stream timeout: `console.warn('@vercel/flags-core: Stream initialization timeout, falling back')` +- Stream error (e.g., 502): `console.error('@vercel/flags-core: Stream error', expect.any(Error))` +- 401 fast-fail: `console.error` with auth error (no retry, no timeout wait) + +### Do not weaken assertions when adapting tests + +When updating tests for new behavior, preserve the strength of existing assertions: +- Keep exact call count checks (e.g., `expect(streamCalls).toHaveLength(2)`) rather than weakening to `.toBeGreaterThanOrEqual(1)` +- Keep specific header assertions (e.g., `X-Retry-Attempt` values) rather than removing them +- Keep `errorSpy`/`warnSpy` assertions rather than dropping them + ## Important Implementation Details ### Stream Connection - Uses fetch with streaming body (NDJSON format) +- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onDisconnect` +- 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` - 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 - 401 errors abort immediately (invalid SDK key) and reject the init promise, so fallback kicks in without waiting for the stream timeout - On disconnect: state transitions to `'degraded'`, falls back to polling if enabled - On reconnect: Controller listens for `'connected'` event and transitions back to `'streaming'` -- Background stream promises (from init timeout or `startBackgroundUpdates`) are `.catch`-ed by the Controller to prevent unhandled rejections when the stream is aborted before receiving data +- Background stream promises (from init timeout) are `.catch`-ed by the Controller to prevent unhandled rejections when the stream is aborted before receiving data ### Polling diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 30492d00..f817309a 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -48,7 +48,11 @@ function createMockStream() { controller.enqueue(encoder.encode(`${JSON.stringify(message)}\n`)); }, close() { - controller.close(); + try { + controller.close(); + } catch { + // Stream may already be closed (e.g. after shutdown) + } }, }; } @@ -271,11 +275,17 @@ describe('Controller (black-box)', () => { await initPromise; // Stream should have been attempted - expect(fetchMock).toHaveBeenCalled(); - const streamCall = fetchMock.mock.calls.find((call) => - call[0]?.toString().includes('/v1/stream'), + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: { + ...streamRequestHeaders, + 'X-Retry-Attempt': '0', + }, + signal: expect.any(AbortSignal), + }, ); - expect(streamCall).toBeDefined(); stream.close(); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -627,7 +637,9 @@ describe('Controller (black-box)', () => { await client.shutdown(); }); - it('should fall back to bundled when stream times out', async () => { + it('should use bundled definitions when stream is slow after init timeout', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled(), @@ -643,23 +655,20 @@ describe('Controller (black-box)', () => { return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const client = createClient(sdkKey, { fetch: fetchMock, polling: false, }); + // initialize() now waits for the stream to confirm (primed/datafile) + // but falls back to bundled data after the init timeout const initPromise = client.initialize(); - - // Advance past the stream init timeout (3s) - await vi.advanceTimersByTimeAsync(3_000); + await vi.advanceTimersByTimeAsync(3000); await initPromise; const result = await client.evaluate('flagA'); expect(result.value).toBe(true); expect(result.metrics?.source).toBe('embedded'); - expect(result.metrics?.connectionState).toBe('disconnected'); expect(warnSpy).toHaveBeenCalledWith( '@vercel/flags-core: Stream initialization timeout, falling back', @@ -667,7 +676,7 @@ describe('Controller (black-box)', () => { warnSpy.mockRestore(); }); - it('should fall back to bundled when stream errors (502)', async () => { + it('should use bundled definitions when stream errors (502) after init timeout', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled(), @@ -709,7 +718,7 @@ describe('Controller (black-box)', () => { warnSpy.mockRestore(); }); - it('should fast-fail on 401 without waiting for stream timeout', async () => { + it('should suppress usage tracking and not retry on 401', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled(), @@ -739,10 +748,33 @@ describe('Controller (black-box)', () => { errorSpy.mockRestore(); + // Only one stream call — 401 does not trigger retries + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: { + ...streamRequestHeaders, + 'X-Revision': '1', + }, + signal: expect.any(AbortSignal), + }, + ); + + // Advance time to allow any potential retries (should not happen) + await vi.advanceTimersByTimeAsync(5_000); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: { ...streamRequestHeaders, 'X-Revision': '1' }, + signal: expect.any(AbortSignal), + }, + ); + await client.shutdown(); await vi.advanceTimersByTimeAsync(0); - // No ingest call — usage tracking is suppressed after a 401 + // still only one call, no ingest calls expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -852,11 +884,16 @@ describe('Controller (black-box)', () => { await client.initialize(); await vi.advanceTimersByTimeAsync(0); - // No stream requests should have been made - const streamCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/stream'), + // No stream requests should have been made, + // the below check verifies only a dataifle call was made + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + headers: datafileRequestHeaders, + signal: expect.any(AbortSignal), + }, ); - expect(streamCalls).toHaveLength(0); await client.shutdown(); }); @@ -924,7 +961,9 @@ describe('Controller (black-box)', () => { // Datafile option // --------------------------------------------------------------------------- describe('datafile option', () => { - it('should use provided datafile immediately', async () => { + it('should use provided datafile after stream init timeout', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stream = createMockStream(); fetchMock.mockImplementation((input) => { @@ -940,16 +979,27 @@ describe('Controller (black-box)', () => { datafile, }); - const result = await client.evaluate('flagA'); + // evaluate() triggers lazy initialize() which waits for stream + const evalPromise = client.evaluate('flagA'); + await vi.advanceTimersByTimeAsync(3000); + const result = await evalPromise; + expect(result.value).toBe(true); expect(result.metrics?.source).toBe('in-memory'); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back', + ); + + warnSpy.mockRestore(); stream.close(); await client.shutdown(); }); - it('should resolve initialize() immediately when datafile is provided', async () => { - // Stream that never sends data — if init blocks on stream, this test hangs + it('should resolve initialize() with provided datafile after stream init timeout', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Stream that never sends data fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/stream')) { @@ -964,13 +1014,20 @@ describe('Controller (black-box)', () => { datafile: makeBundled(), }); - // initialize() should resolve without advancing timers or stream data - await client.initialize(); + // initialize() waits for stream, falls back after timeout + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(3000); + await initPromise; const result = await client.evaluate('flagA'); expect(result.value).toBe(true); expect(result.metrics?.source).toBe('in-memory'); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back', + ); + + warnSpy.mockRestore(); await client.shutdown(); }); @@ -1139,7 +1196,7 @@ describe('Controller (black-box)', () => { ); }); - it('should fall back to bundled when stream fails (skip polling)', async () => { + it('should use bundled definitions when stream fails after init timeout (skip polling)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled({ projectId: 'bundled' }), @@ -1158,42 +1215,41 @@ describe('Controller (black-box)', () => { Response.json(makeBundled({ projectId: 'polled' })), ); } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const streamInitTimeoutMs = 1_500; const client = createClient(sdkKey, { fetch: fetchMock, - stream: { initTimeoutMs: streamInitTimeoutMs }, + stream: { initTimeoutMs: 1_500 }, polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, }); - // Stream retries with backoff; advance timers so the init timeout fires. - // The minimum reconnection gap is 1s, so the first retry happens at ~1s. - // With initTimeoutMs=1500 we get: attempt at t=0 (fail), retry at t=1000 - // (fail, backoff(2) >= 1s), timeout at t=1500 → fall back to bundled. - const resultPromise = client.evaluate('flagA'); - await vi.advanceTimersByTimeAsync(1_600); - const result = await resultPromise; + // initialize() waits for stream, falls back after 1.5s timeout + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(1_500); + await initPromise; + const after = new Date(); + + const result = await client.evaluate('flagA'); expect(result.metrics?.source).toBe('embedded'); + // No polling should have started expect(pollCount).toBe(0); errorSpy.mockRestore(); warnSpy.mockRestore(); - expect(fetchMock).toHaveBeenCalledTimes(2); await client.shutdown(); - expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenLastCalledWith( 'https://flags.vercel.com/v1/ingest', { body: JSON.stringify([ { type: 'FLAGS_CONFIG_READ', - ts: date.getTime() + streamInitTimeoutMs, + ts: after.getTime(), payload: { configOrigin: 'embedded', cacheStatus: 'HIT', @@ -1270,8 +1326,16 @@ describe('Controller (black-box)', () => { datafile: providedDatafile, }); - // Initialize starts background stream connection - await client.initialize(); + // Initialize waits for stream confirmation; push primed so it resolves + const initPromise = client.initialize(); + await new Promise((r) => setTimeout(r, 0)); + stream.push({ + type: 'primed', + revision: 1, + projectId: 'provided', + environment: 'production', + }); + await initPromise; // First evaluate uses provided datafile immediately const result1 = await client.evaluate('flagA'); @@ -1303,162 +1367,888 @@ describe('Controller (black-box)', () => { await client.shutdown(); }); - it('should not start polling from stream disconnect during initialization', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), - }); + it('should send X-Revision header when provided datafile has revision', async () => { + vi.useRealTimers(); - let pollCount = 0; + const stream = createMockStream(); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/stream')) { - return Promise.resolve(new Response(null, { status: 500 })); - } - if (url.includes('/v1/datafile')) { - pollCount++; - return Promise.resolve(Response.json(makeBundled())); + return stream.response; } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const providedDatafile = makeBundled({ revision: 42 }); const client = createClient(sdkKey, { fetch: fetchMock, - stream: { initTimeoutMs: 5000 }, - polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, + datafile: providedDatafile, }); - // Stream retries with backoff; advance timers so the init timeout fires + // Push primed so initialize() resolves without waiting for timeout const initPromise = client.initialize(); - await vi.advanceTimersByTimeAsync(5100); - await initPromise; - - expect(pollCount).toBe(0); - - await client.shutdown(); - errorSpy.mockRestore(); - warnSpy.mockRestore(); - }); - }); - - // --------------------------------------------------------------------------- - // getDatafile - // --------------------------------------------------------------------------- - describe('getDatafile', () => { - it('should return bundled definitions when called without initialize', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: makeBundled(), + await new Promise((r) => setTimeout(r, 0)); + stream.push({ + type: 'primed', + revision: 42, + projectId: 'prj_123', + environment: 'production', }); + await initPromise; - const client = createClient(sdkKey, { - fetch: fetchMock, - stream: false, - polling: false, + // The stream request should include the X-Revision header + const streamCall = fetchMock.mock.calls.find((call) => { + const url = typeof call[0] === 'string' ? call[0] : call[0]!.toString(); + return url.includes('/v1/stream'); }); + expect(streamCall).toBeDefined(); + const headers = streamCall![1]!.headers as Record; + expect(headers['X-Revision']).toBe('42'); - const result = await client.getDatafile(); - expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('MISS'); - expect(result.metrics.connectionState).toBe('disconnected'); - + stream.close(); await client.shutdown(); }); - it('should fetch datafile when called without initialize and no bundled definitions', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, - }); + it('should not send X-Revision header when provided datafile has no revision', async () => { + vi.useRealTimers(); - const fetchedDatafile = makeBundled({ projectId: 'fetched' }); + const stream = createMockStream(); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/datafile')) { - return Promise.resolve(Response.json(fetchedDatafile)); + if (url.includes('/v1/stream')) { + return stream.response; } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); + // DatafileInput without revision field + const providedDatafile = { + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + environment: 'production', + projectId: 'prj_123', + configUpdatedAt: 1, + }; + const client = createClient(sdkKey, { fetch: fetchMock, - stream: false, - polling: false, + datafile: providedDatafile, }); - const result = await client.getDatafile(); - expect(result.metrics.source).toBe('remote'); - expect(result.metrics.cacheStatus).toBe('MISS'); + // Push a datafile so initialize() resolves without waiting for timeout + const initPromise = client.initialize(); + await new Promise((r) => setTimeout(r, 0)); + stream.push({ + type: 'datafile', + data: makeBundled({ configUpdatedAt: 1 }), + }); + await initPromise; + + const streamCall = fetchMock.mock.calls.find((call) => { + const url = typeof call[0] === 'string' ? call[0] : call[0]!.toString(); + return url.includes('/v1/stream'); + }); + expect(streamCall).toBeDefined(); + const headers = streamCall![1]!.headers as Record; + expect(headers['X-Revision']).toBeUndefined(); + stream.close(); await client.shutdown(); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenLastCalledWith( - 'https://flags.vercel.com/v1/datafile', - { - headers: datafileRequestHeaders, - signal: expect.any(AbortSignal), - }, - ); }); - it('should throw when called without initialize and all sources fail', async () => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'missing-file', - definitions: null, - }); + it('should handle primed response and keep using provided datafile', async () => { + vi.useRealTimers(); + + const stream = createMockStream(); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/datafile')) { - return Promise.resolve(new Response(null, { status: 500 })); + if (url.includes('/v1/stream')) { + return stream.response; } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); + const providedDatafile = makeBundled({ + revision: 33, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + const client = createClient(sdkKey, { fetch: fetchMock, - stream: false, - polling: false, + datafile: providedDatafile, }); - await expect(client.getDatafile()).rejects.toThrow( - '@vercel/flags-core: No flag definitions available', - ); + // Server responds with primed (our revision is current) + const initPromise = client.initialize(); + await new Promise((r) => setTimeout(r, 0)); + stream.push({ + type: 'primed', + revision: 33, + projectId: 'prj_123', + environment: 'production', + }); + await initPromise; + + // First evaluate uses provided datafile + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(false); // variant 0 from provided + + // After primed, still uses the same data + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(false); // still variant 0 + expect(result2.metrics?.connectionState).toBe('connected'); + expect(result2.metrics?.mode).toBe('streaming'); + stream.close(); await client.shutdown(); }); - it('should return cached data when stream is connected', async () => { + it('should handle primed then subsequent datafile update', async () => { + vi.useRealTimers(); + const stream = createMockStream(); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) return stream.response; + if (url.includes('/v1/stream')) { + return stream.response; + } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); - const client = createClient(sdkKey, { fetch: fetchMock }); + const providedDatafile = makeBundled({ + revision: 5, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + datafile: providedDatafile, + }); + // Server responds with primed first, resolving initialize() const initPromise = client.initialize(); - stream.push({ type: 'datafile', data: makeBundled() }); - await vi.advanceTimersByTimeAsync(0); + await new Promise((r) => setTimeout(r, 0)); + stream.push({ + type: 'primed', + revision: 5, + projectId: 'prj_123', + environment: 'production', + }); await initPromise; - const result = await client.getDatafile(); - expect(result.metrics.source).toBe('in-memory'); - expect(result.metrics.cacheStatus).toBe('HIT'); - expect(result.metrics.connectionState).toBe('connected'); - - stream.close(); - await client.shutdown(); - - // no evaluate call so no usage tracking - expect(fetchMock).toHaveBeenCalledTimes(1); + // Then server pushes a new datafile (config changed) + stream.push({ + type: 'datafile', + data: makeBundled({ + configUpdatedAt: 2, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }), + }); + await new Promise((r) => setTimeout(r, 0)); + + // Should use the updated data + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 from stream update + + stream.close(); + await client.shutdown(); + }); + + it('should not start polling from stream disconnect during initialization', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + let pollCount = 0; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 500 })); + } + if (url.includes('/v1/datafile')) { + pollCount++; + return Promise.resolve(Response.json(makeBundled())); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: { initTimeoutMs: 5000 }, + polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, + }); + + // Stream retries with backoff; advance timers so the init timeout fires + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(5100); + await initPromise; + + expect(pollCount).toBe(0); + + await client.shutdown(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + }); + + // --------------------------------------------------------------------------- + // Degraded connection scenarios + // --------------------------------------------------------------------------- + describe('degraded connection scenarios', () => { + it('should transition to degraded on disconnect and back to streaming on reconnect with newer data', async () => { + const datafile1 = makeBundled({ + configUpdatedAt: 1, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + const datafile2 = makeBundled({ + configUpdatedAt: 2, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + + let streamCount = 0; + const streams: ReturnType[] = []; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + streamCount++; + const s = createMockStream(); + streams.push(s); + return s.response; + } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + const initPromise = client.initialize(); + + // Allow the eager bundled load (returns undefined) to settle + // so the stream connection is started + await vi.advanceTimersByTimeAsync(0); + + // First stream sends datafile + streams[0]!.push({ type: 'datafile', data: datafile1 }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Verify streaming state + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(false); + expect(result1.metrics?.connectionState).toBe('connected'); + + // Disconnect (server closes stream) + streams[0]!.close(); + await vi.advanceTimersByTimeAsync(0); + + // Verify degraded state + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(false); + expect(result2.metrics?.connectionState).toBe('disconnected'); + + // Advance past reconnection backoff (minimum 1s gap) + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + + // Push newer data on reconnected stream + expect(streamCount).toBeGreaterThanOrEqual(2); + streams[1]!.push({ type: 'datafile', data: datafile2 }); + await vi.advanceTimersByTimeAsync(0); + + // Verify back to streaming with newer data + const result3 = await client.evaluate('flagA'); + expect(result3.value).toBe(true); + expect(result3.metrics?.connectionState).toBe('connected'); + + await client.shutdown(); + }); + + it('should detect zombie connection when pings stop arriving', async () => { + const datafile = makeBundled(); + + let streamCount = 0; + const streams: ReturnType[] = []; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + streamCount++; + const s = createMockStream(); + streams.push(s); + return s.response; + } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + const initPromise = client.initialize(); + + // Allow the eager bundled load to settle so the stream starts + await vi.advanceTimersByTimeAsync(0); + + streams[0]!.push({ type: 'datafile', data: datafile }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Send pings for a while (proves connection is alive) + streams[0]!.push({ type: 'ping' }); + await vi.advanceTimersByTimeAsync(30_000); + streams[0]!.push({ type: 'ping' }); + await vi.advanceTimersByTimeAsync(30_000); + + // Verify still connected + const result1 = await client.evaluate('flagA'); + expect(result1.metrics?.connectionState).toBe('connected'); + + // Now stop sending pings, advance past timeout (90s) + await vi.advanceTimersByTimeAsync(90_000); + await vi.advanceTimersByTimeAsync(0); + + // Should have transitioned to degraded + const result2 = await client.evaluate('flagA'); + expect(result2.metrics?.connectionState).toBe('disconnected'); + + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Ping timeout, reconnecting', + ); + + // Should have attempted reconnection + expect(streamCount).toBeGreaterThanOrEqual(2); + + await client.shutdown(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('should skip malformed JSON in stream and continue processing', async () => { + const encoder = new TextEncoder(); + let streamController: ReadableStreamDefaultController; + + const body = new ReadableStream({ + start(c) { + streamController = c; + }, + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(body, { status: 200 })); + } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + const initPromise = client.initialize(); + + // Send malformed JSON first + streamController!.enqueue(encoder.encode('not valid json\n')); + await vi.advanceTimersByTimeAsync(0); + + // Then send valid datafile + const datafile = makeBundled(); + streamController!.enqueue( + encoder.encode( + `${JSON.stringify({ type: 'datafile', data: datafile })}\n`, + ), + ); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + expect(result.metrics?.connectionState).toBe('connected'); + + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Failed to parse stream message, skipping', + ); + + streamController!.close(); + await client.shutdown(); + warnSpy.mockRestore(); + }); + + it('should silently ignore empty lines in stream', async () => { + const encoder = new TextEncoder(); + let streamController: ReadableStreamDefaultController; + + const body = new ReadableStream({ + start(c) { + streamController = c; + }, + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(body, { status: 200 })); + } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + const initPromise = client.initialize(); + + // Send empty lines + streamController!.enqueue(encoder.encode('\n\n\n')); + await vi.advanceTimersByTimeAsync(0); + + // Then valid datafile + const datafile = makeBundled(); + streamController!.enqueue( + encoder.encode( + `${JSON.stringify({ type: 'datafile', data: datafile })}\n`, + ), + ); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + + // No warnings should have been logged for empty lines + expect(warnSpy).not.toHaveBeenCalledWith( + '@vercel/flags-core: Failed to parse stream message, skipping', + ); + + streamController!.close(); + await client.shutdown(); + warnSpy.mockRestore(); + }); + + it('should handle 200 response with missing body', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + return Promise.resolve(new Response(null, { status: 200 })); + } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: { initTimeoutMs: 2000 }, + polling: false, + }); + + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(2_000); + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.metrics?.source).toBe('embedded'); + expect(result.metrics?.connectionState).toBe('disconnected'); + + expect(errorSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream error', + expect.objectContaining({ + message: 'stream body was not present', + }), + ); + + await client.shutdown(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('should recover from network error mid-stream', async () => { + const datafile = makeBundled(); + let streamCount = 0; + const streams: ReturnType[] = []; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + streamCount++; + if (streamCount === 1) { + // First stream: send datafile, then error + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `${JSON.stringify({ type: 'datafile', data: datafile })}\n`, + ), + ); + // Schedule error after a tick + setTimeout( + () => controller.error(new TypeError('network error')), + 0, + ); + }, + }); + return Promise.resolve(new Response(body, { status: 200 })); + } + // Subsequent streams: normal + const s = createMockStream(); + streams.push(s); + return s.response; + } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Should have received initial data + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(true); + + // Error fires, wait for disconnect + await vi.advanceTimersByTimeAsync(0); + + // The error triggers reconnection. Advance past backoff. + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + + expect(streamCount).toBeGreaterThanOrEqual(2); + + // Reconnect with new data + streams[0]!.push({ + type: 'datafile', + data: makeBundled({ configUpdatedAt: 2 }), + }); + await vi.advanceTimersByTimeAsync(0); + + const result2 = await client.evaluate('flagA'); + expect(result2.metrics?.connectionState).toBe('connected'); + + await client.shutdown(); + errorSpy.mockRestore(); + }); + + it('should reject older data on stream reconnection', async () => { + const newerData = makeBundled({ + configUpdatedAt: 2000, + definitions: { + flagA: { + environments: { production: 1 }, + variants: [false, true], + }, + }, + }); + const olderData = makeBundled({ + configUpdatedAt: 1000, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + + let streamCount = 0; + const streams: ReturnType[] = []; + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + streamCount++; + const s = createMockStream(); + streams.push(s); + return s.response; + } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + const initPromise = client.initialize(); + + // Allow the eager bundled load to settle so the stream starts + await vi.advanceTimersByTimeAsync(0); + + // First stream sends newer data + streams[0]!.push({ type: 'datafile', data: newerData }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + const result1 = await client.evaluate('flagA'); + expect(result1.value).toBe(true); // variant 1 from newer data + + // Stream disconnects + streams[0]!.close(); + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + + // Reconnected stream sends older data + expect(streamCount).toBeGreaterThanOrEqual(2); + streams[1]!.push({ type: 'datafile', data: olderData }); + await vi.advanceTimersByTimeAsync(0); + + // Should still have newer data (configUpdatedAt guard rejected older) + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(true); // still variant 1 + expect(result2.metrics?.connectionState).toBe('connected'); + + await client.shutdown(); + }); + + it('should cleanly shut down mid-stream', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + const initPromise = client.initialize(); + + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Verify connected + const result = await client.evaluate('flagA'); + expect(result.metrics?.connectionState).toBe('connected'); + + // Shutdown while stream is still open — should not throw + await client.shutdown(); + await vi.advanceTimersByTimeAsync(0); + + // No stream requests should happen after shutdown, which + // we verify by checking the calls that actually happened + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls).toEqual([ + [ + 'https://flags.vercel.com/v1/stream', + { + headers: streamRequestHeaders, + signal: expect.any(AbortSignal), + }, + ], + [ + 'https://flags.vercel.com/v1/ingest', + { + headers: ingestRequestHeaders, + method: 'POST', + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + }, + }, + ]), + }, + ], + ]); + + await vi.advanceTimersByTimeAsync(5_000); + + // still no streaming calls, as the count has not changed from above + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + // --------------------------------------------------------------------------- + // getDatafile + // --------------------------------------------------------------------------- + describe('getDatafile', () => { + it('should return bundled definitions when called without initialize', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled(), + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.getDatafile(); + expect(result.metrics.source).toBe('embedded'); + expect(result.metrics.cacheStatus).toBe('MISS'); + expect(result.metrics.connectionState).toBe('disconnected'); + + await client.shutdown(); + }); + + it('should fetch datafile when called without initialize and no bundled definitions', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + const fetchedDatafile = makeBundled({ projectId: 'fetched' }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + return Promise.resolve(Response.json(fetchedDatafile)); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + const result = await client.getDatafile(); + expect(result.metrics.source).toBe('remote'); + expect(result.metrics.cacheStatus).toBe('MISS'); + + await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + headers: datafileRequestHeaders, + signal: expect.any(AbortSignal), + }, + ); + }); + + it('should throw when called without initialize and all sources fail', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'missing-file', + definitions: null, + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + return Promise.resolve(new Response(null, { status: 500 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + }); + + await expect(client.getDatafile()).rejects.toThrow( + '@vercel/flags-core: No flag definitions available', + ); + + await client.shutdown(); + }); + + it('should return cached data when stream is connected', async () => { + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { fetch: fetchMock }); + + const initPromise = client.initialize(); + stream.push({ type: 'datafile', data: makeBundled() }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + const result = await client.getDatafile(); + expect(result.metrics.source).toBe('in-memory'); + expect(result.metrics.cacheStatus).toBe('HIT'); + expect(result.metrics.connectionState).toBe('connected'); + + stream.close(); + await client.shutdown(); + + // no evaluate call so no usage tracking + expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenLastCalledWith( 'https://flags.vercel.com/v1/stream', { @@ -1800,19 +2590,15 @@ describe('Controller (black-box)', () => { polling: false, }); - await client.initialize(); - - // Initial evaluate uses provided datafile (variant 0) - const result1 = await client.evaluate('flagA'); - expect(result1.value).toBe(false); - - // Push stream data with configUpdatedAt + // Push stream data so initialize() resolves without waiting for timeout + const initPromise = client.initialize(); + await new Promise((r) => setTimeout(r, 0)); stream.push({ type: 'datafile', data: streamData }); - await new Promise((r) => setTimeout(r, 50)); + await initPromise; - // Should accept stream data - const result2 = await client.evaluate('flagA'); - expect(result2.value).toBe(true); // variant 1 = stream + // The stream data replaced the provided datafile (which had no configUpdatedAt) + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); // variant 1 = stream stream.close(); await client.shutdown(); @@ -2292,10 +3078,14 @@ describe('Controller (black-box)', () => { await Promise.all([p1, p2, p3]); // Stream should have been fetched only once - const streamCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/stream'), + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: streamRequestHeaders, + signal: expect.any(AbortSignal), + }, ); - expect(streamCalls).toHaveLength(1); stream.close(); await client.shutdown(); @@ -2338,10 +3128,14 @@ describe('Controller (black-box)', () => { expect(r3.value).toBe(true); // Stream should have been fetched only once - const streamCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/stream'), + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: streamRequestHeaders, + signal: expect.any(AbortSignal), + }, ); - expect(streamCalls).toHaveLength(1); stream.close(); await client.shutdown(); @@ -2403,10 +3197,14 @@ describe('Controller (black-box)', () => { expect(r3.value).toBe(true); // Stream should have been fetched only once - const streamCalls = fetchMock.mock.calls.filter((call) => - call[0]?.toString().includes('/v1/stream'), + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: streamRequestHeaders, + signal: expect.any(AbortSignal), + }, ); - expect(streamCalls).toHaveLength(1); stream.close(); await client.shutdown(); diff --git a/packages/vercel-flags-core/src/controller/bundled-source.ts b/packages/vercel-flags-core/src/controller/bundled-source.ts index 066c64db..8a7a47de 100644 --- a/packages/vercel-flags-core/src/controller/bundled-source.ts +++ b/packages/vercel-flags-core/src/controller/bundled-source.ts @@ -68,7 +68,7 @@ export class BundledSource { */ async tryLoad(): Promise { const result = await this.getResult(); - if (result.state === 'ok' && result.definitions) { + if (result?.state === 'ok' && result.definitions) { return result.definitions; } return undefined; diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index c310244a..2b502b79 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -169,7 +169,10 @@ export class Controller implements ControllerInterface { this.options = normalizeOptions(options); // Create source modules (or use injected ones for testing) - this.streamSource = new StreamSource(this.options); + this.streamSource = new StreamSource( + this.options, + () => this.data?.revision, + ); this.pollingSource = new PollingSource(this.options); @@ -195,6 +198,13 @@ export class Controller implements ControllerInterface { this.data = tagData(data, 'stream'); } }; + private onStreamPrimed = () => { + // The server confirmed our revision is current — no new data needed. + // Transition to streaming like a normal connected event. + if (this.state === 'degraded' || this.state === 'initializing:stream') { + this.transition('streaming'); + } + }; private onStreamConnected = () => { if (this.state === 'degraded' || this.state === 'initializing:stream') { this.transition('streaming'); @@ -220,6 +230,7 @@ export class Controller implements ControllerInterface { private wireSourceEvents(): void { this.streamSource.on('data', this.onStreamData); + this.streamSource.on('primed', this.onStreamPrimed); this.streamSource.on('connected', this.onStreamConnected); this.streamSource.on('disconnected', this.onStreamDisconnected); this.pollingSource.on('data', this.onPollData); @@ -228,6 +239,7 @@ export class Controller implements ControllerInterface { private unwireSourceEvents(): void { this.streamSource.off('data', this.onStreamData); + this.streamSource.off('primed', this.onStreamPrimed); this.streamSource.off('connected', this.onStreamConnected); this.streamSource.off('disconnected', this.onStreamDisconnected); this.pollingSource.off('data', this.onPollData); @@ -283,10 +295,35 @@ export class Controller implements ControllerInterface { this.data = tagData(this.options.datafile, 'provided'); } - // If we already have data (from provided datafile), start background updates - // but don't block on them + // If no data yet, try loading bundled definitions eagerly so we can + // send the revision to the stream and potentially get a lightweight + // "primed" response instead of a full datafile. + if (!this.data) { + try { + const bundled = await this.bundledSource.tryLoad(); + if (bundled) { + this.data = tagData(bundled, 'bundled'); + } + } catch { + // Bundled definitions not available — proceed without revision + } + } + + // If we already have data (from provided datafile or bundled definitions), + // start updates. For streams, wait for confirmation (primed or datafile) + // so we know we have fresh data. For polling or no-updates, start in the + // background and return immediately since we already have usable data. if (this.data) { - this.startBackgroundUpdates(); + if (this.options.stream.enabled) { + this.transition('initializing:stream'); + await this.tryInitializeStream(); + } else if (this.options.polling.enabled) { + this.pollingSource.startInterval(); + void this.pollingSource.poll(); + this.transition('polling'); + } else { + this.transition('degraded'); + } return; } @@ -490,7 +527,7 @@ export class Controller implements ControllerInterface { // Don't stop stream - let it continue trying in background. // Swallow the rejection from the background stream promise to // avoid unhandled promise rejections when it is eventually aborted. - this.streamSource.start().catch(() => {}); + void this.streamSource.start().catch(() => {}); return false; } @@ -561,28 +598,6 @@ export class Controller implements ControllerInterface { } } - // --------------------------------------------------------------------------- - // Background updates - // --------------------------------------------------------------------------- - - /** - * Starts background updates (stream or polling) without blocking. - * Used when we already have data from provided datafile. - */ - private startBackgroundUpdates(): void { - if (this.options.stream.enabled) { - this.transition('initializing:stream'); - this.streamSource.start().catch(() => {}); - } else if (this.options.polling.enabled) { - // Start interval first so the abort controller exists for the initial poll - this.pollingSource.startInterval(); - void this.pollingSource.poll(); - this.transition('polling'); - } else { - this.transition('degraded'); - } - } - // --------------------------------------------------------------------------- // Build step helpers // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 366badfd..5474e4a3 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -1,7 +1,4 @@ import type { DatafileInput, PollingOptions, StreamOptions } from '../types'; -import type { BundledSource } from './bundled-source'; -import type { PollingSource } from './polling-source'; -import type { StreamSource } from './stream-source'; const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; const DEFAULT_POLLING_INTERVAL_MS = 30_000; diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index cf48cdc7..27526415 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -60,18 +60,18 @@ describe('connectStream', () => { ); const abortController = new AbortController(); - const onMessage = vi.fn(); + const onDatafile = vi.fn(); await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage }, + { onDatafile }, ); - expect(onMessage).toHaveBeenCalledWith(definitions); + expect(onDatafile).toHaveBeenCalledWith(definitions); abortController.abort(); }); - it('should call onMessage callback with parsed data', async () => { + it('should call onDatafile callback with parsed data', async () => { const definitions = { projectId: 'test', definitions: { flag: { variants: [true] } }, @@ -82,15 +82,15 @@ describe('connectStream', () => { ); const abortController = new AbortController(); - const onMessage = vi.fn(); + const onDatafile = vi.fn(); await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage }, + { onDatafile }, ); - expect(onMessage).toHaveBeenCalledTimes(1); - expect(onMessage).toHaveBeenCalledWith(definitions); + expect(onDatafile).toHaveBeenCalledTimes(1); + expect(onDatafile).toHaveBeenCalledWith(definitions); abortController.abort(); }); @@ -106,15 +106,15 @@ describe('connectStream', () => { ); const abortController = new AbortController(); - const onMessage = vi.fn(); + const onDatafile = vi.fn(); await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage }, + { onDatafile }, ); - expect(onMessage).toHaveBeenCalledTimes(1); - expect(onMessage).toHaveBeenCalledWith(definitions); + expect(onDatafile).toHaveBeenCalledTimes(1); + expect(onDatafile).toHaveBeenCalledWith(definitions); abortController.abort(); }); @@ -141,14 +141,14 @@ describe('connectStream', () => { ); const abortController = new AbortController(); - const onMessage = vi.fn(); + const onDatafile = vi.fn(); await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage }, + { onDatafile }, ); - expect(onMessage).toHaveBeenCalledWith(definitions); + expect(onDatafile).toHaveBeenCalledWith(definitions); abortController.abort(); }); @@ -174,14 +174,14 @@ describe('connectStream', () => { ); const abortController = new AbortController(); - const onMessage = vi.fn(); + const onDatafile = vi.fn(); await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage }, + { onDatafile }, ); - expect(onMessage).toHaveBeenCalledTimes(1); + expect(onDatafile).toHaveBeenCalledTimes(1); abortController.abort(); }); }); @@ -195,7 +195,7 @@ describe('connectStream', () => { const abortController = new AbortController(); await connectStream( { host: HOST, sdkKey: 'vf_my_key', abortController, fetch: fetchMock }, - { onMessage: vi.fn() }, + { onDatafile: vi.fn() }, ); const headers = fetchMock.mock.calls[0]![1]!.headers as Record< @@ -210,7 +210,7 @@ describe('connectStream', () => { const abortController = new AbortController(); await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage: vi.fn() }, + { onDatafile: vi.fn() }, ); const headers = fetchMock.mock.calls[0]![1]!.headers as Record< @@ -225,7 +225,7 @@ describe('connectStream', () => { const abortController = new AbortController(); await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage: vi.fn() }, + { onDatafile: vi.fn() }, ); const headers = fetchMock.mock.calls[0]![1]!.headers as Record< @@ -254,7 +254,7 @@ describe('connectStream', () => { await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage: vi.fn(), onDisconnect }, + { onDatafile: vi.fn(), onDisconnect }, ); // Advance past the reconnection backoff delay @@ -283,7 +283,7 @@ describe('connectStream', () => { await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage: vi.fn() }, + { onDatafile: vi.fn() }, ); // Advance past first reconnection backoff @@ -319,7 +319,7 @@ describe('connectStream', () => { await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage: vi.fn() }, + { onDatafile: vi.fn() }, ); // After the first stream closes, retryCount was reset to 0 then @@ -357,7 +357,7 @@ describe('connectStream', () => { await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage: vi.fn(), onDisconnect }, + { onDatafile: vi.fn(), onDisconnect }, ); // Advance past the reconnection backoff delay @@ -390,7 +390,7 @@ describe('connectStream', () => { const promise = connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage: vi.fn() }, + { onDatafile: vi.fn() }, ); // First request fires immediately, first retry has 0ms backoff @@ -432,7 +432,7 @@ describe('connectStream', () => { const promise = connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage: vi.fn() }, + { onDatafile: vi.fn() }, ); // First request fires immediately, first retry has 0ms backoff @@ -473,7 +473,7 @@ describe('connectStream', () => { await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage: vi.fn(), onDisconnect }, + { onDatafile: vi.fn(), onDisconnect }, ); // Advance past the reconnection backoff delay @@ -516,14 +516,14 @@ describe('connectStream', () => { ); const abortController = new AbortController(); - const onMessage = vi.fn(); + const onDatafile = vi.fn(); await connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage }, + { onDatafile }, ); - expect(onMessage).toHaveBeenCalledTimes(1); + expect(onDatafile).toHaveBeenCalledTimes(1); // Abort externally abortController.abort(); @@ -533,8 +533,150 @@ describe('connectStream', () => { }); }); + describe('ping timeout', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('should abort connection when no messages received within ping timeout', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let requestCount = 0; + + fetchMock.mockImplementation((_input, init) => { + requestCount++; + return streamResponse( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode(`${JSON.stringify(datafileMsg())}\n`), + ); + // Keep stream open — simulates a zombie connection + init?.signal?.addEventListener('abort', () => { + controller.close(); + }); + }, + }), + ); + }); + + const abortController = new AbortController(); + const onDisconnect = vi.fn(); + + await connectStream( + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, + { onDatafile: vi.fn(), onDisconnect }, + ); + + expect(requestCount).toBe(1); + + // Advance past the 90s ping timeout + await vi.advanceTimersByTimeAsync(90_000); + // Allow microtasks from stream cancellation to settle + await vi.advanceTimersByTimeAsync(0); + // Advance past the reconnection backoff (min 1s gap) + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Ping timeout, reconnecting', + ); + expect(onDisconnect).toHaveBeenCalled(); + + // Should have attempted reconnection + expect(requestCount).toBeGreaterThanOrEqual(2); + + abortController.abort(); + warnSpy.mockRestore(); + }); + + it('should reset timeout on each ping', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let streamController: ReadableStreamDefaultController; + + fetchMock.mockImplementation((_input, init) => + streamResponse( + new ReadableStream({ + start(c) { + streamController = c; + c.enqueue( + new TextEncoder().encode(`${JSON.stringify(datafileMsg())}\n`), + ); + init?.signal?.addEventListener('abort', () => { + c.close(); + }); + }, + }), + ), + ); + + const abortController = new AbortController(); + + await connectStream( + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, + { onDatafile: vi.fn() }, + ); + + // Send pings at 30s intervals (before the 90s timeout) + for (let i = 0; i < 5; i++) { + await vi.advanceTimersByTimeAsync(30_000); + streamController!.enqueue( + new TextEncoder().encode(`${JSON.stringify({ type: 'ping' })}\n`), + ); + await vi.advanceTimersByTimeAsync(0); + } + + // 150s total elapsed but no timeout because pings kept resetting it + expect(warnSpy).not.toHaveBeenCalledWith( + '@vercel/flags-core: Ping timeout, reconnecting', + ); + + abortController.abort(); + warnSpy.mockRestore(); + }); + + it('should not start timeout before initial data received', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + fetchMock.mockImplementation((_input, init) => + streamResponse( + new ReadableStream({ + start(controller) { + // Keep stream open without sending any data + init?.signal?.addEventListener('abort', () => { + controller.close(); + }); + }, + }), + ), + ); + + const abortController = new AbortController(); + + const promise = connectStream( + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, + { onDatafile: vi.fn() }, + ); + + // Advance past 90s — ping timeout should NOT fire since no initial data + await vi.advanceTimersByTimeAsync(90_000); + await vi.advanceTimersByTimeAsync(0); + + expect(warnSpy).not.toHaveBeenCalledWith( + '@vercel/flags-core: Ping timeout, reconnecting', + ); + + abortController.abort(); + await expect(promise).rejects.toThrow( + 'stream: aborted before receiving data', + ); + + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + }); + describe('multiple datafile messages', () => { - it('should call onMessage for each datafile but only resolve once', async () => { + it('should call onDatafile for each datafile but only resolve once', async () => { const data1 = { projectId: 'test', definitions: { v: 1 } }; const data2 = { projectId: 'test', definitions: { v: 2 } }; @@ -546,11 +688,11 @@ describe('connectStream', () => { ); const abortController = new AbortController(); - const onMessage = vi.fn(); + const onDatafile = vi.fn(); const promise = connectStream( { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, - { onMessage }, + { onDatafile }, ); // Should resolve (not hang waiting for more data) @@ -558,13 +700,253 @@ describe('connectStream', () => { // Wait for all messages to be processed await vi.waitFor(() => { - expect(onMessage).toHaveBeenCalledTimes(2); + expect(onDatafile).toHaveBeenCalledTimes(2); }); - expect(onMessage).toHaveBeenNthCalledWith(1, data1); - expect(onMessage).toHaveBeenNthCalledWith(2, data2); + expect(onDatafile).toHaveBeenNthCalledWith(1, data1); + expect(onDatafile).toHaveBeenNthCalledWith(2, data2); abortController.abort(); }); }); + + describe('X-Revision header', () => { + beforeEach(() => { + fetchMock.mockImplementation(() => ndjsonResponse([datafileMsg()])); + }); + + it('should include X-Revision header when revision is provided', async () => { + const abortController = new AbortController(); + await connectStream( + { + host: HOST, + sdkKey: 'vf_test', + abortController, + fetch: fetchMock, + revision: () => 42, + }, + { onDatafile: vi.fn() }, + ); + + const headers = fetchMock.mock.calls[0]![1]!.headers as Record< + string, + string + >; + expect(headers['X-Revision']).toBe('42'); + abortController.abort(); + }); + + it('should not include X-Revision header when revision is undefined', async () => { + const abortController = new AbortController(); + await connectStream( + { host: HOST, sdkKey: 'vf_test', abortController, fetch: fetchMock }, + { onDatafile: vi.fn() }, + ); + + const headers = fetchMock.mock.calls[0]![1]!.headers as Record< + string, + string + >; + expect(headers['X-Revision']).toBeUndefined(); + abortController.abort(); + }); + + it('should call revision getter on each reconnect to get latest value', async () => { + vi.useFakeTimers(); + let requestCount = 0; + let currentRevision = 5; + + fetchMock.mockImplementation(() => { + requestCount++; + const nextRevision = currentRevision + 1; + return ndjsonResponse( + [ + { + type: 'datafile', + data: { + projectId: 'test', + definitions: {}, + revision: nextRevision, + }, + }, + ], + { keepOpen: requestCount >= 3 }, + ); + }); + + const abortController = new AbortController(); + + await connectStream( + { + host: HOST, + sdkKey: 'vf_test', + abortController, + fetch: fetchMock, + revision: () => currentRevision, + }, + { + onDatafile: (data) => { + // Simulate controller updating revision from received datafile + currentRevision = (data as Record) + .revision as number; + }, + }, + ); + + // First request should send revision 5 + const h0 = fetchMock.mock.calls[0]![1]!.headers as Record; + expect(h0['X-Revision']).toBe('5'); + + // Advance past reconnection backoff + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(0); + + // Second request should send the updated revision (6), not the initial (5) + const h1 = fetchMock.mock.calls[1]![1]!.headers as Record; + expect(h1['X-Revision']).toBe('6'); + + // Advance past reconnection backoff again + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(0); + + // Third request should send the updated revision (7) + const h2 = fetchMock.mock.calls[2]![1]!.headers as Record; + expect(h2['X-Revision']).toBe('7'); + + abortController.abort(); + vi.useRealTimers(); + }); + }); + + describe('primed message', () => { + it('should resolve init promise when primed message is received', async () => { + const primedMsg = { + type: 'primed' as const, + revision: 33, + projectId: 'prj_test', + environment: 'production', + }; + + fetchMock.mockImplementation(() => ndjsonResponse([primedMsg])); + + const abortController = new AbortController(); + const onDatafile = vi.fn(); + const onPrimed = vi.fn(); + + await connectStream( + { + host: HOST, + sdkKey: 'vf_test', + abortController, + fetch: fetchMock, + revision: () => 33, + }, + { onDatafile, onPrimed }, + ); + + expect(onDatafile).not.toHaveBeenCalled(); + expect(onPrimed).toHaveBeenCalledWith(primedMsg); + abortController.abort(); + }); + + it('should call onPrimed but not onDatafile for primed messages', async () => { + const primedMsg = { + type: 'primed' as const, + revision: 5, + projectId: 'prj_test', + environment: 'production', + }; + + fetchMock.mockImplementation(() => + ndjsonResponse([ + primedMsg, + { type: 'datafile', data: { projectId: 'test', definitions: {} } }, + ]), + ); + + const abortController = new AbortController(); + const onDatafile = vi.fn(); + const onPrimed = vi.fn(); + + await connectStream( + { + host: HOST, + sdkKey: 'vf_test', + abortController, + fetch: fetchMock, + revision: () => 5, + }, + { onDatafile, onPrimed }, + ); + + // Wait for all messages to be processed + await vi.waitFor(() => { + expect(onDatafile).toHaveBeenCalledTimes(1); + }); + + expect(onPrimed).toHaveBeenCalledTimes(1); + expect(onPrimed).toHaveBeenCalledWith(primedMsg); + abortController.abort(); + }); + + it('should reset ping timeout on primed message', async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let streamController: ReadableStreamDefaultController; + + fetchMock.mockImplementation((_input, init) => + streamResponse( + new ReadableStream({ + start(c) { + streamController = c; + c.enqueue( + new TextEncoder().encode( + `${JSON.stringify({ + type: 'primed', + revision: 1, + projectId: 'prj_test', + environment: 'production', + })}\n`, + ), + ); + init?.signal?.addEventListener('abort', () => { + c.close(); + }); + }, + }), + ), + ); + + const abortController = new AbortController(); + + await connectStream( + { + host: HOST, + sdkKey: 'vf_test', + abortController, + fetch: fetchMock, + revision: () => 1, + }, + { onDatafile: vi.fn(), onPrimed: vi.fn() }, + ); + + // Send pings at 30s intervals (before the 90s timeout) + for (let i = 0; i < 4; i++) { + await vi.advanceTimersByTimeAsync(30_000); + streamController!.enqueue( + new TextEncoder().encode(`${JSON.stringify({ type: 'ping' })}\n`), + ); + await vi.advanceTimersByTimeAsync(0); + } + + // 120s elapsed but no timeout because pings kept resetting it + expect(warnSpy).not.toHaveBeenCalledWith( + '@vercel/flags-core: Ping timeout, reconnecting', + ); + + abortController.abort(); + warnSpy.mockRestore(); + vi.useRealTimers(); + }); + }); }); diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 258352a8..03ab8999 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -2,17 +2,29 @@ import { version } from '../../package.json'; import type { BundledDefinitions } from '../types'; import { sleep } from '../utils/sleep'; +export type PrimedMessage = { + type: 'primed'; + revision: number; + projectId: string; + environment: string; +}; + export type StreamMessage = | { type: 'datafile'; data: BundledDefinitions } + | PrimedMessage | { type: 'ping' }; const MAX_RETRY_COUNT = 15; -const BASE_DELAY_MS = 1000; -const MAX_DELAY_MS = 60_000; +const BASE_RETRY_DELAY_MS = 1000; +const MAX_RETRY_DELAY_MS = 60_000; +const PING_TIMEOUT_MS = 90_000; function backoff(retryCount: number): number { if (retryCount === 1) return 0; - const delay = Math.min(BASE_DELAY_MS * 2 ** (retryCount - 2), MAX_DELAY_MS); + const delay = Math.min( + BASE_RETRY_DELAY_MS * 2 ** (retryCount - 2), + MAX_RETRY_DELAY_MS, + ); return delay + Math.random() * 1000; } @@ -24,7 +36,8 @@ export class UnauthorizedError extends Error { } export type StreamCallbacks = { - onMessage: (data: BundledDefinitions) => void; + onDatafile: (data: BundledDefinitions) => void; + onPrimed?: (message: PrimedMessage) => void; onDisconnect?: () => void; }; @@ -33,6 +46,8 @@ export type StreamConfig = { sdkKey: string; abortController: AbortController; fetch?: typeof globalThis.fetch; + /** Returns the current revision number to send as X-Revision header */ + revision?: () => number | undefined; }; /** @@ -50,7 +65,7 @@ export async function connectStream( abortController, fetch: fetchFn = globalThis.fetch, } = config; - const { onMessage, onDisconnect } = callbacks; + const { onDatafile, onPrimed, onDisconnect } = callbacks; let retryCount = 0; let lastAttemptTime = 0; @@ -76,15 +91,42 @@ export async function connectStream( break; } + // Per-connection abort controller — allows ping timeout to abort a single + // connection without stopping the entire retry loop. + const connectionAbort = new AbortController(); + const onMainAbort = (): void => connectionAbort.abort(); + abortController.signal.addEventListener('abort', onMainAbort, { + once: true, + }); + + let pingTimeoutId: ReturnType | undefined; + // Reference to the response body so the ping timeout can cancel it + // to break out of the for-await loop. + let responseBody: ReadableStream | undefined; + const resetPingTimeout = (): void => { + if (pingTimeoutId !== undefined) clearTimeout(pingTimeoutId); + if (!initialDataReceived) return; + pingTimeoutId = setTimeout(() => { + console.warn('@vercel/flags-core: Ping timeout, reconnecting'); + responseBody?.cancel().catch(() => {}); + connectionAbort.abort(); + }, PING_TIMEOUT_MS); + }; + try { lastAttemptTime = Date.now(); + const headers: Record = { + Authorization: `Bearer ${sdkKey}`, + 'User-Agent': `VercelFlagsCore/${version}`, + 'X-Retry-Attempt': String(retryCount), + }; + const revision = config.revision?.(); + if (revision !== undefined) { + headers['X-Revision'] = String(revision); + } const response = await fetchFn(`${host}/v1/stream`, { - headers: { - Authorization: `Bearer ${sdkKey}`, - 'User-Agent': `VercelFlagsCore/${version}`, - 'X-Retry-Attempt': String(retryCount), - }, - signal: abortController.signal, + headers, + signal: connectionAbort.signal, }); if (!response.ok) { @@ -103,58 +145,96 @@ export async function connectStream( throw new Error('stream body was not present'); } + responseBody = response.body; + const reader = response.body.getReader(); const decoder = new TextDecoder(); const bufferChunks: string[] = []; - for await (const chunk of response.body) { - if (abortController.signal.aborted) break; - - bufferChunks.push(decoder.decode(chunk, { stream: true })); - const combined = bufferChunks.join(''); - bufferChunks.length = 0; - const lines = combined.split('\n'); - bufferChunks.push(lines.pop()!); - - for (const line of lines) { - if (line === '') continue; - - let message: StreamMessage; - try { - message = JSON.parse(line) as StreamMessage; - } catch { - console.warn( - '@vercel/flags-core: Failed to parse stream message, skipping', - ); - continue; - } + // Allow the ping timeout (or main abort) to cancel the reader, + // which breaks the read loop immediately even on a zombie connection. + const onConnectionAbort = (): void => { + reader.cancel().catch(() => {}); + }; + connectionAbort.signal.addEventListener('abort', onConnectionAbort, { + once: true, + }); + + try { + while (true) { + const { done, value: chunk } = await reader.read(); + if (done || abortController.signal.aborted) break; + + bufferChunks.push(decoder.decode(chunk, { stream: true })); + const combined = bufferChunks.join(''); + bufferChunks.length = 0; + const lines = combined.split('\n'); + bufferChunks.push(lines.pop()!); - if (message.type === 'datafile') { - onMessage(message.data); - retryCount = 0; - if (!initialDataReceived) { - initialDataReceived = true; - resolveInit!(); + for (const line of lines) { + if (line === '') continue; + + let message: StreamMessage; + try { + message = JSON.parse(line) as StreamMessage; + } catch { + console.warn( + '@vercel/flags-core: Failed to parse stream message, skipping', + ); + continue; + } + + if (message.type === 'datafile') { + onDatafile(message.data); + retryCount = 0; + if (!initialDataReceived) { + initialDataReceived = true; + resolveInit!(); + } + resetPingTimeout(); } - } - // Pings prove the connection is alive — reset retry count - // once initial data has been received - if (message.type === 'ping' && initialDataReceived) { - retryCount = 0; + // Primed means the server confirmed our revision is current, + // so no full datafile is needed. Treat it like initial data + // for init resolution purposes. + if (message.type === 'primed') { + onPrimed?.(message); + retryCount = 0; + if (!initialDataReceived) { + initialDataReceived = true; + resolveInit!(); + } + resetPingTimeout(); + } + + // Pings prove the connection is alive — reset retry count + // once initial data has been received + if (message.type === 'ping' && initialDataReceived) { + retryCount = 0; + resetPingTimeout(); + } } } + } finally { + connectionAbort.signal.removeEventListener( + 'abort', + onConnectionAbort, + ); } // Stream ended normally (server closed connection) - reconnect + clearTimeout(pingTimeoutId); + abortController.signal.removeEventListener('abort', onMainAbort); if (!abortController.signal.aborted) { onDisconnect?.(); retryCount++; const elapsed = Date.now() - lastAttemptTime; - const minGap = Math.max(0, BASE_DELAY_MS - elapsed); + const minGap = Math.max(0, BASE_RETRY_DELAY_MS - elapsed); await sleep(Math.max(backoff(retryCount), minGap)); continue; } } catch (error) { + clearTimeout(pingTimeoutId); + abortController.signal.removeEventListener('abort', onMainAbort); if (abortController.signal.aborted) { break; } @@ -162,7 +242,7 @@ export async function connectStream( onDisconnect?.(); retryCount++; const elapsed = Date.now() - lastAttemptTime; - const minGap = Math.max(0, BASE_DELAY_MS - elapsed); + const minGap = Math.max(0, BASE_RETRY_DELAY_MS - elapsed); await sleep(Math.max(backoff(retryCount), minGap)); } } diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index 99adc80e..93fba85c 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,15 +1,11 @@ import type { DatafileInput } from '../types'; -import { connectStream } from './stream-connection'; +import type { NormalizedOptions } from './normalized-options'; +import { connectStream, type PrimedMessage } from './stream-connection'; import { TypedEmitter } from './typed-emitter'; -export type StreamSourceConfig = { - host: string; - sdkKey: string; - fetch?: typeof globalThis.fetch; -}; - export type StreamSourceEvents = { data: (data: DatafileInput) => void; + primed: (message: PrimedMessage) => void; connected: () => void; disconnected: () => void; }; @@ -19,18 +15,20 @@ export type StreamSourceEvents = { * Wraps connectStream() and emits typed events. */ export class StreamSource extends TypedEmitter { - private config: StreamSourceConfig; + private options: NormalizedOptions; + private revision: () => number | undefined; private abortController: AbortController | undefined; private promise: Promise | undefined; - constructor(config: StreamSourceConfig) { + constructor(options: NormalizedOptions, revision: () => number | undefined) { super(); - this.config = config; + this.options = options; + this.revision = revision; } /** * Start the stream connection. - * Returns a promise that resolves when the first datafile message arrives. + * Returns a promise that resolves when the first datafile or primed message arrives. * If already started, returns the existing promise. */ start(): Promise { @@ -56,16 +54,21 @@ export class StreamSource extends TypedEmitter { try { const promise = connectStream( { - host: this.config.host, - sdkKey: this.config.sdkKey, + host: this.options.host, + sdkKey: this.options.sdkKey, abortController, - fetch: this.config.fetch, + fetch: this.options.fetch, + revision: this.revision, }, { - onMessage: (newData) => { + onDatafile: (newData) => { this.emit('data', newData); this.emit('connected'); }, + onPrimed: (message) => { + this.emit('primed', message); + this.emit('connected'); + }, onDisconnect: () => { this.emit('disconnected'); }, diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 32c96af5..c3fc9220 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -32,6 +32,8 @@ export type DatafileInput = Packed.Data & { * Some older responses might return a string instead of a number. Both will be timestamps. */ configUpdatedAt?: number | string; + /** Version number of the data */ + revision?: number; }; /** Datafile with metrics attached (returned by the client) */ From b1d1d231fb8e0ef7ff5fd44530f8f4d056fa8330 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 24 Feb 2026 11:47:17 +0200 Subject: [PATCH 57/65] fixes --- .../vercel-flags-core/src/black-box.test.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index f817309a..307b7920 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -637,7 +637,7 @@ describe('Controller (black-box)', () => { await client.shutdown(); }); - it('should use bundled definitions when stream is slow after init timeout', async () => { + it('should fall back to bundled when stream times out', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.mocked(readBundledDefinitions).mockResolvedValue({ @@ -669,6 +669,7 @@ describe('Controller (black-box)', () => { const result = await client.evaluate('flagA'); expect(result.value).toBe(true); expect(result.metrics?.source).toBe('embedded'); + expect(result.metrics?.connectionState).toBe('disconnected'); expect(warnSpy).toHaveBeenCalledWith( '@vercel/flags-core: Stream initialization timeout, falling back', @@ -718,7 +719,7 @@ describe('Controller (black-box)', () => { warnSpy.mockRestore(); }); - it('should suppress usage tracking and not retry on 401', async () => { + it('should fast-fail on 401 without waiting for stream timeout', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled(), @@ -1222,15 +1223,16 @@ describe('Controller (black-box)', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const initTimeoutMs = 1_500; const client = createClient(sdkKey, { fetch: fetchMock, - stream: { initTimeoutMs: 1_500 }, + stream: { initTimeoutMs }, polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, }); // initialize() waits for stream, falls back after 1.5s timeout const initPromise = client.initialize(); - await vi.advanceTimersByTimeAsync(1_500); + await vi.advanceTimersByTimeAsync(initTimeoutMs); await initPromise; const after = new Date(); @@ -1491,7 +1493,8 @@ describe('Controller (black-box)', () => { datafile: providedDatafile, }); - // Server responds with primed (our revision is current) + // Server responds with primed (our revision is current), + // which resolves initialize() without sending a full datafile const initPromise = client.initialize(); await new Promise((r) => setTimeout(r, 0)); stream.push({ @@ -1502,15 +1505,12 @@ describe('Controller (black-box)', () => { }); await initPromise; - // First evaluate uses provided datafile - const result1 = await client.evaluate('flagA'); - expect(result1.value).toBe(false); // variant 0 from provided - - // After primed, still uses the same data - const result2 = await client.evaluate('flagA'); - expect(result2.value).toBe(false); // still variant 0 - expect(result2.metrics?.connectionState).toBe('connected'); - expect(result2.metrics?.mode).toBe('streaming'); + // Primed confirms the data is current — value is unchanged, + // state is connected and streaming + const result = await client.evaluate('flagA'); + expect(result.value).toBe(false); // variant 0 from provided + expect(result.metrics?.connectionState).toBe('connected'); + expect(result.metrics?.mode).toBe('streaming'); stream.close(); await client.shutdown(); From 667f8100c4fed74b7761ac1d870e8fd13633ce91 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 24 Feb 2026 13:26:56 +0200 Subject: [PATCH 58/65] rm state machine --- .../vercel-flags-core/src/controller/index.ts | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 2b502b79..ad02e56e 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -47,49 +47,6 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { /** * Explicit states for the controller state machine. - * - * - * ┌──────┐ - * │ idle │ - * └──┬───┘ - * ┌───────────┴──────────────────────────────┐ - * initialize() initialize() - * (runtime) (build step) - * │ │ - * ▼ ▼ - * ┌─────────────────────┐ ┌───────────────┐ - * │ initializing:stream │ │ build:loading │ - * └──┬──────────────┬───┘ └──────┬────────┘ - * │ │ │ - * success timeout/fail │ - * │ │ ▼ - * ▼ ▼ ┌─────────────┐ - * ┌───────────┐ ┌───────────────────────┐ │ build:ready │ - * │ streaming │ │ initializing:polling │ └─────────────┘ - * └─────┬─────┘ └──┬───────────────┬────┘ - * │ success timeout/fail - * disconnect │ │ - * │ ▼ ▼ - * │ ┌─────────┐ ┌────────────────────────┐ - * │ │ polling │ │ initializing:fallback │ - * │ └─────────┘ └──┬──────────────────┬──┘ - * │ success fail - * │ │ │ - * ▼ ▼ ▼ - * ┌───────────────────────────────────────────────┐ - * │ degraded │ - * └───────────────────────┬───────────────────────┘ - * │ - * stream reconnects - * │ - * ▼ - * ┌───────────┐ - * │ streaming │ (recovery) - * └───────────┘ - * - * Any state ──shutdown()──▶ ┌──────────┐ - * │ shutdown │ - * └──────────┘ */ type State = | 'idle' From 8942906330be452c9851ef7ff5547c8d775057a7 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 24 Feb 2026 13:31:37 +0200 Subject: [PATCH 59/65] await first poll before resoliving init --- packages/vercel-flags-core/CLAUDE.md | 15 ++++++++++----- packages/vercel-flags-core/src/black-box.test.ts | 15 ++++++++++----- .../vercel-flags-core/src/controller/index.ts | 11 +++++------ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index bdabacf5..69fb0314 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -114,8 +114,8 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu Key behaviors: - Bundled definitions are loaded eagerly so their revision can be sent to the stream via `X-Revision` header -- When streaming is enabled and data already exists (bundled or provided), `initialize()` still waits for stream confirmation (`primed` or `datafile`) up to `initTimeoutMs`, then falls back to existing data on timeout -- For polling or offline mode with existing data, `initialize()` returns immediately +- When streaming or polling is enabled and data already exists (bundled or provided), `initialize()` still waits for fresh data (stream confirmation or first poll) up to `initTimeoutMs`, then falls back to existing data on timeout +- For offline mode with existing data, `initialize()` returns immediately - **Never stream AND poll simultaneously** - If stream reconnects while polling → stop polling - If stream disconnects → start polling (if enabled) @@ -178,18 +178,23 @@ pnpm test:integration - **No stderr leaks**: every `console.warn` and `console.error` the implementation emits must be captured by a spy (`vi.spyOn(console, 'warn').mockImplementation(() => {})`) and asserted. A test that produces stderr output is broken. - **Tests should complete in milliseconds**, not seconds. If a test takes ~3s, it's hitting a real timeout instead of advancing fake timers. -### initialize() blocks on stream confirmation +### initialize() blocks on stream/poll confirmation -`initialize()` waits for a stream message (`primed` or `datafile`) up to `initTimeoutMs` before resolving, even when bundled data or a provided datafile is available. This means: +`initialize()` waits for fresh data before resolving, even when bundled data or a provided datafile is available: +- **Streaming**: waits for a stream message (`primed` or `datafile`) up to `initTimeoutMs` +- **Polling**: waits for the first poll response up to `initTimeoutMs` + +This means: - **With fake timers**: call `client.initialize()` (or `client.evaluate()` which triggers lazy init), then `await vi.advanceTimersByTimeAsync(initTimeoutMs)` to trigger the timeout fallback. -- **With real timers (`vi.useRealTimers()`)**: you MUST push a stream message before awaiting `initialize()`, otherwise it blocks for the real 3s timeout: +- **With real timers (`vi.useRealTimers()`)**: for streaming, you MUST push a stream message before awaiting `initialize()`, otherwise it blocks for the real 3s timeout: ```typescript const initPromise = client.initialize(); await new Promise((r) => setTimeout(r, 0)); // let stream connect stream.push({ type: 'primed', revision: 42, projectId: 'prj_123', environment: 'production' }); await initPromise; // resolves immediately ``` + For polling, `initialize()` will await the first poll (which resolves immediately if `fetchMock` responds synchronously). ### Prefer evaluate-driven tests over explicit initialize() diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 307b7920..f8ca008a 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -1073,19 +1073,24 @@ describe('Controller (black-box)', () => { datafile: providedDatafile, }); + // initialize() now waits for the first poll before resolving await client.initialize(); - // First evaluate uses provided datafile (variant 1 = true) + // The initial poll during initialize() already fetched fresh data + expect(pollCount).toBe(1); + + // First evaluate uses polled data (variant 0 = false), since the + // poll during init returned newer data (configUpdatedAt: 2 > 1) const result1 = await client.evaluate('flagA'); - expect(result1.value).toBe(true); + expect(result1.value).toBe(false); expect(result1.metrics?.source).toBe('in-memory'); - // Advance past a poll interval to trigger update + // Advance past a poll interval to trigger another update await vi.advanceTimersByTimeAsync(30_000); - expect(pollCount).toBeGreaterThanOrEqual(1); + expect(pollCount).toBe(2); - // Second evaluate uses polled data (variant 0 = false) + // Still uses polled data const result2 = await client.evaluate('flagA'); expect(result2.value).toBe(false); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index ad02e56e..94748e37 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -267,17 +267,16 @@ export class Controller implements ControllerInterface { } // If we already have data (from provided datafile or bundled definitions), - // start updates. For streams, wait for confirmation (primed or datafile) - // so we know we have fresh data. For polling or no-updates, start in the - // background and return immediately since we already have usable data. + // start updates. Both streaming and polling wait for initial data before + // being considered initialized, so we know we have fresh data. + // For no-updates (offline), return immediately since we already have usable data. if (this.data) { if (this.options.stream.enabled) { this.transition('initializing:stream'); await this.tryInitializeStream(); } else if (this.options.polling.enabled) { - this.pollingSource.startInterval(); - void this.pollingSource.poll(); - this.transition('polling'); + this.transition('initializing:polling'); + await this.tryInitializePolling(); } else { this.transition('degraded'); } From 261b9a11e2d2616745388feb723ab72d98418a02 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 24 Feb 2026 13:33:07 +0200 Subject: [PATCH 60/65] rm outdated comment --- packages/vercel-flags-core/src/controller/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 94748e37..a484cffd 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -125,7 +125,7 @@ export class Controller implements ControllerInterface { this.options = normalizeOptions(options); - // Create source modules (or use injected ones for testing) + // Create source modules this.streamSource = new StreamSource( this.options, () => this.data?.revision, From c4e76a5ef88c831cab7d99325004ef14ffb67e90 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 24 Feb 2026 14:07:22 +0200 Subject: [PATCH 61/65] avoid leaking _origin --- packages/vercel-flags-core/src/controller/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index a484cffd..d0dbe929 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -316,11 +316,12 @@ export class Controller implements ControllerInterface { const [result, cacheStatus] = await this.resolveData(); const readMs = Date.now() - startTime; - const source = originToMetricsSource(result._origin); + const { _origin, ...data } = result; + const source = originToMetricsSource(_origin); this.trackRead(startTime, cacheHadDefinitions, isFirstRead, source); return { - ...result, + ...data, metrics: { readMs, source, From 81ccad32c681fe7cd3ba666286ca66449081b6c0 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Tue, 24 Feb 2026 17:17:54 +0100 Subject: [PATCH 62/65] changeset --- .changeset/ripe-signs-teach.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ripe-signs-teach.md diff --git a/.changeset/ripe-signs-teach.md b/.changeset/ripe-signs-teach.md new file mode 100644 index 00000000..8c6024ff --- /dev/null +++ b/.changeset/ripe-signs-teach.md @@ -0,0 +1,5 @@ +--- +"@vercel/flags-core": minor +--- + +Refactor client From 20600cbbc23a89558507f1e4a2711a367b4d4e42 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 26 Feb 2026 16:38:40 +0200 Subject: [PATCH 63/65] avoid log on reconnect due to missed pings --- .../vercel-flags-core/src/controller/stream-connection.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 03ab8999..1633979f 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -238,7 +238,11 @@ export async function connectStream( if (abortController.signal.aborted) { break; } - console.error('@vercel/flags-core: Stream error', error); + // Ping timeout aborts only the per-connection controller; this is + // an expected reconnect, not a real error — skip the noisy log. + if (!connectionAbort.signal.aborted) { + console.error('@vercel/flags-core: Stream error', error); + } onDisconnect?.(); retryCount++; const elapsed = Date.now() - lastAttemptTime; From 5c710ed9ce66a25d25657ca8df9bc28ac021a327 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 26 Feb 2026 16:48:26 +0200 Subject: [PATCH 64/65] don't warn on missed pings --- .../vercel-flags-core/src/black-box.test.ts | 6 ----- .../src/controller/stream-connection.test.ts | 26 +++++-------------- .../src/controller/stream-connection.ts | 1 - 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index f8ca008a..a1f71573 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -1728,7 +1728,6 @@ describe('Controller (black-box)', () => { return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const client = createClient(sdkKey, { @@ -1762,15 +1761,10 @@ describe('Controller (black-box)', () => { const result2 = await client.evaluate('flagA'); expect(result2.metrics?.connectionState).toBe('disconnected'); - expect(warnSpy).toHaveBeenCalledWith( - '@vercel/flags-core: Ping timeout, reconnecting', - ); - // Should have attempted reconnection expect(streamCount).toBeGreaterThanOrEqual(2); await client.shutdown(); - warnSpy.mockRestore(); errorSpy.mockRestore(); }); diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index 27526415..5093984e 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -538,7 +538,6 @@ describe('connectStream', () => { afterEach(() => vi.useRealTimers()); it('should abort connection when no messages received within ping timeout', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); let requestCount = 0; fetchMock.mockImplementation((_input, init) => { @@ -576,20 +575,15 @@ describe('connectStream', () => { await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(0); - expect(warnSpy).toHaveBeenCalledWith( - '@vercel/flags-core: Ping timeout, reconnecting', - ); expect(onDisconnect).toHaveBeenCalled(); // Should have attempted reconnection expect(requestCount).toBeGreaterThanOrEqual(2); abortController.abort(); - warnSpy.mockRestore(); }); it('should reset timeout on each ping', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); let streamController: ReadableStreamDefaultController; fetchMock.mockImplementation((_input, init) => @@ -625,16 +619,13 @@ describe('connectStream', () => { } // 150s total elapsed but no timeout because pings kept resetting it - expect(warnSpy).not.toHaveBeenCalledWith( - '@vercel/flags-core: Ping timeout, reconnecting', - ); + // Verify no reconnection was attempted (still on the original connection) + expect(fetchMock).toHaveBeenCalledTimes(1); abortController.abort(); - warnSpy.mockRestore(); }); it('should not start timeout before initial data received', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); fetchMock.mockImplementation((_input, init) => @@ -661,16 +652,14 @@ describe('connectStream', () => { await vi.advanceTimersByTimeAsync(90_000); await vi.advanceTimersByTimeAsync(0); - expect(warnSpy).not.toHaveBeenCalledWith( - '@vercel/flags-core: Ping timeout, reconnecting', - ); + // No reconnection should have been triggered (timeout only starts after initial data) + expect(fetchMock).toHaveBeenCalledTimes(1); abortController.abort(); await expect(promise).rejects.toThrow( 'stream: aborted before receiving data', ); - warnSpy.mockRestore(); errorSpy.mockRestore(); }); }); @@ -891,7 +880,6 @@ describe('connectStream', () => { it('should reset ping timeout on primed message', async () => { vi.useFakeTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); let streamController: ReadableStreamDefaultController; fetchMock.mockImplementation((_input, init) => @@ -940,12 +928,10 @@ describe('connectStream', () => { } // 120s elapsed but no timeout because pings kept resetting it - expect(warnSpy).not.toHaveBeenCalledWith( - '@vercel/flags-core: Ping timeout, reconnecting', - ); + // Verify no reconnection was attempted (still on the original connection) + expect(fetchMock).toHaveBeenCalledTimes(1); abortController.abort(); - warnSpy.mockRestore(); vi.useRealTimers(); }); }); diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 1633979f..2f533052 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -107,7 +107,6 @@ export async function connectStream( if (pingTimeoutId !== undefined) clearTimeout(pingTimeoutId); if (!initialDataReceived) return; pingTimeoutId = setTimeout(() => { - console.warn('@vercel/flags-core: Ping timeout, reconnecting'); responseBody?.cancel().catch(() => {}); connectionAbort.abort(); }, PING_TIMEOUT_MS); From dcfb39ef56d4ad000bf2dbbcf893df467c4d7b5b Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 26 Feb 2026 21:25:03 +0100 Subject: [PATCH 65/65] Update event reporting for control rewrite (#289) * Update event reporting for control rewrite * Log ingest response * Use string for revision --- .../vercel-flags-core/src/black-box.test.ts | 51 +++++++++++++++++++ .../vercel-flags-core/src/controller/index.ts | 7 +++ .../src/utils/usage-tracker.ts | 24 ++++++++- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index a1f71573..4b3531e5 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -205,6 +205,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'build', + revision: '1', + environment: 'test', }, }, ]), @@ -246,6 +249,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'build', + revision: '1', + environment: 'test', }, }, ]), @@ -346,6 +352,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'stream', + revision: '1', + environment: 'test', }, }, ]), @@ -420,6 +429,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'build', + revision: '1', + environment: 'test', }, }, ]), @@ -1189,6 +1201,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'offline', + revision: '1', + environment: 'test', }, }, ]), @@ -1265,6 +1280,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'offline', + revision: '1', + environment: 'test', }, }, ]), @@ -2120,6 +2138,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'stream', + revision: '1', + environment: 'test', }, }, ]), @@ -2491,6 +2512,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 2000, + mode: 'stream', + revision: '1', + environment: 'test', }, }, ]), @@ -3160,6 +3184,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'stream', + revision: '1', + environment: 'test', }, }, ]), @@ -3223,6 +3250,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'stream', + revision: '1', + environment: 'test', }, }, { @@ -3235,6 +3265,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'stream', + revision: '1', + environment: 'test', }, }, { @@ -3247,6 +3280,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'stream', + revision: '1', + environment: 'test', }, }, ]), @@ -3399,6 +3435,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'offline', + revision: '1', + environment: 'test', }, }, ]), @@ -3618,6 +3657,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 2, + mode: 'build', + revision: '2', + environment: 'test', }, }, ]), @@ -3659,6 +3701,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, + mode: 'build', + revision: '1', + environment: 'test', }, }, ]), @@ -3722,6 +3767,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 5, + mode: 'stream', + revision: '1', + environment: 'test', }, }, ]), @@ -3778,6 +3826,9 @@ describe('Controller (black-box)', () => { cacheIsBlocking: false, duration: 0, configUpdatedAt: 2, + mode: 'build', + revision: '2', + environment: 'test', }, }, ]), diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index d0dbe929..ee8bc35c 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -784,17 +784,24 @@ export class Controller implements ControllerInterface { : this.state === 'polling' ? 'REFRESHING' : 'NONE'; + const mode = this.mode; const trackOptions: TrackReadOptions = { configOrigin, cacheStatus: cacheHadDefinitions ? 'HIT' : 'MISS', cacheAction, cacheIsBlocking: !cacheHadDefinitions, duration: Date.now() - startTime, + mode: + mode === 'streaming' ? 'stream' : mode === 'polling' ? 'poll' : mode, }; const configUpdatedAt = this.data?.configUpdatedAt; if (typeof configUpdatedAt === 'number') { trackOptions.configUpdatedAt = configUpdatedAt; } + const revision = this.data?.revision; + if (typeof revision === 'number') { + trackOptions.revision = revision; + } if (isFirstRead) { trackOptions.cacheIsFirstRead = true; } diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index 1d6c5654..63436ec6 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -18,13 +18,16 @@ export interface FlagsConfigReadEvent { region?: string; invocationHost?: string; vercelRequestId?: string; - cacheStatus?: 'HIT' | 'MISS' | 'BYPASS'; + cacheStatus?: 'HIT' | 'MISS' | 'BYPASS' | 'STALE'; cacheAction?: 'REFRESHING' | 'FOLLOWING' | 'NONE'; cacheIsBlocking?: boolean; cacheIsFirstRead?: boolean; duration?: number; configUpdatedAt?: number; - configOrigin?: 'in-memory' | 'embedded'; + configOrigin?: 'in-memory' | 'embedded' | 'poll' | 'stream' | 'constructor'; + mode?: 'poll' | 'stream' | 'build' | 'offline'; + revision?: string; + environment?: string; }; } @@ -91,6 +94,10 @@ export interface TrackReadOptions { duration?: number; /** Timestamp when the config was last updated */ configUpdatedAt?: number; + /** The mode the SDK is operating in */ + mode?: 'poll' | 'stream' | 'build' | 'offline'; + /** Revision of the config */ + revision?: number; } /** @@ -174,6 +181,18 @@ export class UsageTracker { if (options.configUpdatedAt !== undefined) { event.payload.configUpdatedAt = options.configUpdatedAt; } + if (options.mode !== undefined) { + event.payload.mode = options.mode; + } + if (options.revision !== undefined) { + event.payload.revision = String(options.revision); + } + } + + const environment = + process.env.VERCEL_ENV || process.env.NODE_ENV || undefined; + if (environment) { + event.payload.environment = environment; } this.batcher.events.push(event); @@ -266,6 +285,7 @@ export class UsageTracker { debugLog( '@vercel/flags-core: Failed to send events:', response.statusText, + await response.text(), ); this.requeue(eventsToSend); }