From 9ee2d5f4202df2b1b2c2683001f93a28f9056b0a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 08:59:16 +0200 Subject: [PATCH 01/25] 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 275b30616e62dd911d6dc2e1d90a8dcc57a6722a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 09:04:44 +0200 Subject: [PATCH 02/25] 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 837178cb43813302ab1559b34fb5f99b1cf5453f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 09:16:44 +0200 Subject: [PATCH 03/25] 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 92ae0f3338413e69a5323fad5401677e3e557e40 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 09:22:51 +0200 Subject: [PATCH 04/25] 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 c860213bc531c1ff6c6ec6c447ad5411eda05622 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 09:26:14 +0200 Subject: [PATCH 05/25] 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 a02fdef04d36d8ce33fe64139db02040cbd4a00c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 15:16:50 +0200 Subject: [PATCH 06/25] 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 9a4362ebea93359277b53cd23ed3494ea42b9933 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 19:43:57 +0200 Subject: [PATCH 07/25] 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 99eb51bf..33259691 100644 --- a/packages/vercel-flags-core/package.json +++ b/packages/vercel-flags-core/package.json @@ -55,7 +55,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", @@ -65,9 +67,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 4241fe55f7a72693fec8cf22b1eb7f480510f0b2 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 19:56:32 +0200 Subject: [PATCH 08/25] 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 ad17ae44d189e89262756ec5918a744ed9ac5e2c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 22:29:11 +0200 Subject: [PATCH 09/25] 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 54e474ad0798fb82ec8f206f0efe69533f58d78a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 22:29:28 +0200 Subject: [PATCH 10/25] 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 6735d480b716edba9c973c8493544de8a9856771 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 14 Feb 2026 22:42:58 +0200 Subject: [PATCH 11/25] 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 727ffca327c7f4f8354402b064932e1d8c9cff9e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 17 Feb 2026 09:29:35 +0200 Subject: [PATCH 12/25] 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 df81b75f8bcf0d309893cf47a5ee025ed6f3fefc Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 19 Feb 2026 10:23:57 +0200 Subject: [PATCH 13/25] 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 c15c0beb22d478cff9ec763ce3c9f732c84b81e7 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 09:21:17 +0200 Subject: [PATCH 14/25] 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 227a07874ead4fe75963b55a07b6b4ae3f1f6b7e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 09:32:34 +0200 Subject: [PATCH 15/25] 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 deef1cc82e453e558a9c3f0c33164aed3afec7f0 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 10:44:21 +0200 Subject: [PATCH 16/25] 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 154a971c365dd19653633384808820b30d762748 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 10:53:33 +0200 Subject: [PATCH 17/25] 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 90ba851d8fc4a1c50bb95e55743e1bfae3ef54c9 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 10:57:20 +0200 Subject: [PATCH 18/25] 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 40cf01369d9e443392593b7b9883d82f7f7a4bc5 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 11:39:48 +0200 Subject: [PATCH 19/25] 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 617a90f7e4a38467449d6b1bf1fbfb1a627be71f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 12:11:04 +0200 Subject: [PATCH 20/25] 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 85825f37769a8bed1dc1a2d6b12dd1b5f8c1186e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 12:31:25 +0200 Subject: [PATCH 21/25] 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 b4c2e21fe3136489e2bf94cd24aa1b34a565a6fb Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 13:27:33 +0200 Subject: [PATCH 22/25] 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 73b9f7f00bd3202b41542400abae726f490e3d4f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 14:13:11 +0200 Subject: [PATCH 23/25] 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 706f4cdd2258e08dfcfa726bd1ca9b441259541f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 14:20:31 +0200 Subject: [PATCH 24/25] 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 fb5bece1e086fde8e069bdbdf194bd9a3785c148 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 20 Feb 2026 14:40:54 +0200 Subject: [PATCH 25/25] 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); } } }