From 7332270a2ad4b560aa33baf05c0a97e79bf7c617 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Mon, 14 Sep 2026 18:30:25 +0200 Subject: [PATCH 01/10] [vercel-flags-core] add vercel client mode --- .../src/controller/header-source.ts | 128 ++++++++++++++++++ .../vercel-flags-core/src/controller/index.ts | 35 ++++- packages/vercel-flags-core/src/types.ts | 2 +- .../src/utils/usage/flags-config-read.ts | 2 +- 4 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 packages/vercel-flags-core/src/controller/header-source.ts diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts new file mode 100644 index 00000000..0efd879a --- /dev/null +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -0,0 +1,128 @@ +import type { BundledDefinitions, DatafileInput, Metrics } from '../types'; +import { getRequestContext } from '../utils/request-context'; +import { fetchDatafile } from './fetch-datafile'; +import type { NormalizedOptions } from './normalized-options'; +import { type TaggedData, tagData } from './tagged-data'; +import { TypedEmitter } from './typed-emitter'; + +export type HeaderSourceEvents = { + data: (data: DatafileInput) => void; +}; + +/** + * Manages a lazy pulling of flag data from the flags service using the version header. + */ +export class HeaderSource extends TypedEmitter { + private options: NormalizedOptions; + private abortController: AbortController | undefined; + private promise: + | Promise<[TaggedData, Metrics['cacheStatus']] | undefined> + | undefined; + + constructor(options: NormalizedOptions) { + super(); + + this.options = options; + } + + private fetchDatafile(): Promise { + const abortController = new AbortController(); + this.abortController = abortController; + + abortController.signal.addEventListener( + 'abort', + () => { + if (this.abortController === abortController) { + this.promise = undefined; + this.abortController = undefined; + } + }, + { once: true }, + ); + + try { + return fetchDatafile(this.options); + } catch (error) { + this.promise = undefined; + this.abortController = undefined; + throw error; + } + } + + private getUpdatedAtHeader(projectId: string) { + const ctx = getRequestContext(); + + const header = + ctx.headers?.['x-vercel-flags-config-versions'] ?? + ctx.headers?.['x-vercel-flags-config-versions']; + + if (!header) { + return; + } + + const value = header + .split(';') + .find((p) => p.startsWith(`flags_${projectId}=`)) + ?.split('=')[1]; + + return Number(value); + } + + private async resolveData( + currentData: TaggedData, + ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { + // current datafile has no timestamp, this shouldn't happen + if (!currentData.configUpdatedAt) { + return; + } + + const updatedAtHeader = this.getUpdatedAtHeader(currentData.projectId); + if (!updatedAtHeader) { + return; + } + + const currentUpdatedAt = Number(currentData.configUpdatedAt); + + // header is older than current data + if (updatedAtHeader <= currentUpdatedAt) { + return [currentData, 'HIT']; + } + + // header is within 10 seconds of current data, we can revalidate in the background + if (updatedAtHeader <= currentUpdatedAt + 10_000) { + this.fetchDatafile().then((data) => { + this.emit('data', data); + }); + + return [currentData, 'STALE']; + } + + const data = await this.fetchDatafile(); + this.emit('data', data); + + return [tagData(data, 'fetched'), 'MISS']; + } + + read( + currentData: TaggedData, + ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { + if (this.promise) return this.promise; + + this.promise = this.resolveData(currentData); + + return this.promise; + } + + isAvailable(projectId: string): boolean { + return !!this.getUpdatedAtHeader(projectId); + } + + /** + * Stop the stream connection. + */ + stop(): void { + this.abortController?.abort(); + this.abortController = undefined; + this.promise = undefined; + } +} diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 5f55c126..60580854 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -11,6 +11,7 @@ import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; import { UsageTracker } from '../utils/usage-tracker'; import { BundledSource } from './bundled-source'; import { fetchDatafile } from './fetch-datafile'; +import { HeaderSource } from './header-source'; import { type ControllerOptions, type NormalizedOptions, @@ -57,6 +58,7 @@ type State = | 'initializing:fallback' | 'streaming' | 'polling' + | 'vercel' | 'degraded' | 'build:loading' | 'build:ready' @@ -85,6 +87,11 @@ type State = * - Uses polling exclusively * - Same fallback chains as streaming mode * + * **Runtime - vercel mode** (request context has matching x-vercel-flags-config-versions header) + * - Uses the header value to determine if the current data is fresh + * - Revalidates in the background if the header value is within 10 seconds of the current configUpdatedAt + * - Blocking fetch if header is newer than current configUpdatedAt + * * **Runtime — offline mode** (neither stream nor polling): * - Init fallback: constructor datafile → bundled → one-time fetch → throw * - Read fallback: in-memory value → constructor datafile → bundled → one-time fetch → throw @@ -108,6 +115,7 @@ export class Controller implements ControllerInterface { private streamSource: StreamSource; private pollingSource: PollingSource; private bundledSource: BundledSource; + private headerSource: HeaderSource; // Usage tracking private usageTracker: UsageTracker; @@ -136,6 +144,8 @@ export class Controller implements ControllerInterface { readBundledDefinitions, }); + this.headerSource = new HeaderSource(this.options); + // Wire source events to state machine this.wireSourceEvents(); @@ -178,6 +188,11 @@ export class Controller implements ControllerInterface { private onPollError = (error: Error) => { console.error('@vercel/flags-core: Poll failed:', error); }; + private onFetchedData = (data: DatafileInput) => { + if (this.isNewerData(data)) { + this.data = tagData(data, 'fetched'); + } + }; // --------------------------------------------------------------------------- // Source event wiring @@ -188,8 +203,11 @@ export class Controller implements ControllerInterface { this.streamSource.on('primed', this.onStreamPrimed); this.streamSource.on('connected', this.onStreamConnected); this.streamSource.on('disconnected', this.onStreamDisconnected); + this.pollingSource.on('data', this.onPollData); this.pollingSource.on('error', this.onPollError); + + this.headerSource.on('data', this.onFetchedData); } private unwireSourceEvents(): void { @@ -197,8 +215,11 @@ export class Controller implements ControllerInterface { this.streamSource.off('primed', this.onStreamPrimed); this.streamSource.off('connected', this.onStreamConnected); this.streamSource.off('disconnected', this.onStreamDisconnected); + this.pollingSource.off('data', this.onPollData); this.pollingSource.off('error', this.onPollError); + + this.headerSource.off('data', this.onFetchedData); } // --------------------------------------------------------------------------- @@ -220,6 +241,8 @@ export class Controller implements ControllerInterface { return 'streaming'; case 'polling': return 'polling'; + case 'vercel': + return 'vercel'; default: return 'offline'; } @@ -269,7 +292,9 @@ export class Controller implements ControllerInterface { // being considered initialized, so we know we have fresh data. // For no-updates (offline), return immediately since we already have usable data. if (this.data) { - if (this.options.stream.enabled) { + if (this.headerSource.isAvailable(this.data.projectId)) { + this.transition('vercel'); + } else if (this.options.stream.enabled) { this.transition('initializing:stream'); await this.tryInitializeStream(); } else if (this.options.polling.enabled) { @@ -442,6 +467,14 @@ export class Controller implements ControllerInterface { } if (this.data) { + if (this.mode === 'vercel') { + const data = await this.headerSource.read(this.data); + + if (data) { + return data; + } + } + const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; return [this.data, cacheStatus]; } diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 2d6dc0be..3ba7c294 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -70,7 +70,7 @@ export type Metrics = { /** Whether the stream is currently connected */ connectionState: 'connected' | 'disconnected'; /** The current operating mode of the client */ - mode: 'streaming' | 'polling' | 'build' | 'offline'; + mode: 'streaming' | 'polling' | 'build' | 'vercel' | 'offline'; /** Time in ms for the pure flag evaluation logic (only present on EvaluationResult) */ evaluationMs?: number; }; diff --git a/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts index c9657343..578a6e29 100644 --- a/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts +++ b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts @@ -16,7 +16,7 @@ export interface TrackReadOptions { /** Timestamp when the config was last updated */ configUpdatedAt?: number; /** The mode the SDK is operating in */ - mode?: 'poll' | 'stream' | 'build' | 'offline'; + mode?: 'poll' | 'stream' | 'build' | 'vercel' | 'offline'; /** Revision of the config */ revision?: number; } From 923ea094675dea0c61dd927784ba76f9dac3af86 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Tue, 15 Sep 2026 10:03:39 +0200 Subject: [PATCH 02/10] stuff --- .../src/controller/header-source.test.ts | 415 ++++++++++++++++++ .../src/controller/header-source.ts | 66 +-- .../vercel-flags-core/src/controller/index.ts | 1 + .../src/utils/usage/flags-config-read.ts | 2 +- .../src/vercel-mode.black-box.test.ts | 383 ++++++++++++++++ 5 files changed, 835 insertions(+), 32 deletions(-) create mode 100644 packages/vercel-flags-core/src/controller/header-source.test.ts create mode 100644 packages/vercel-flags-core/src/vercel-mode.black-box.test.ts diff --git a/packages/vercel-flags-core/src/controller/header-source.test.ts b/packages/vercel-flags-core/src/controller/header-source.test.ts new file mode 100644 index 00000000..ab0dffe7 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/header-source.test.ts @@ -0,0 +1,415 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BundledDefinitions, DatafileInput } from '../types'; +import { getRequestContext } from '../utils/request-context'; +import { fetchDatafile } from './fetch-datafile'; +import { HeaderSource } from './header-source'; +import { normalizeOptions } from './normalized-options'; +import { tagData } from './tagged-data'; + +vi.mock('../utils/request-context', () => ({ getRequestContext: vi.fn() })); +vi.mock('./fetch-datafile', () => ({ fetchDatafile: vi.fn() })); + +const PROJECT_ID = 'prj_test'; +const CURRENT_TIMESTAMP = 1_700_000_000_000; +const HEADER = 'x-vercel-flags-config-versions'; + +function datafile(configUpdatedAt = CURRENT_TIMESTAMP): BundledDefinitions { + return { + projectId: PROJECT_ID, + environment: 'production', + definitions: {}, + configUpdatedAt, + digest: `digest-${configUpdatedAt}`, + revision: configUpdatedAt - CURRENT_TIMESTAMP + 1, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +// Drain promise continuations, without sleeps, polling, or wall-clock timestamps. +function settlePromises() { + return new Promise((resolve) => setImmediate(resolve)); +} + +function setHeader(value: string | undefined) { + vi.mocked(getRequestContext).mockReturnValue({ + ctx: {}, + headers: value === undefined ? undefined : { [HEADER]: value }, + }); +} + +function setVersion(timestamp: number) { + setHeader(`flags_${PROJECT_ID}=${timestamp}`); +} + +let source: HeaderSource; +let onData: ReturnType void>>; + +beforeEach(() => { + vi.resetAllMocks(); + setVersion(CURRENT_TIMESTAMP); + vi.mocked(fetchDatafile).mockResolvedValue( + datafile(CURRENT_TIMESTAMP + 20_000), + ); + source = new HeaderSource( + normalizeOptions({ + auth: { + resolveToken: async () => 'vf_test', + resolveBundledDefinitionsLookup: async () => ({ + type: 'project-id', + projectId: PROJECT_ID, + }), + }, + // Even an accidental call through to real fetchDatafile cannot use the network. + fetch: vi + .fn() + .mockRejectedValue(new Error('Unexpected fetch')), + stream: false, + polling: false, + buildStep: false, + }), + ); + onData = vi.fn<(data: DatafileInput) => void>(); + source.on('data', onData); +}); + +afterEach(() => { + source.stop(); +}); + +describe('HeaderSource', () => { + describe('project-specific version header', () => { + it.each([ + `flags_other=${CURRENT_TIMESTAMP + 20_000};flags_${PROJECT_ID}=${CURRENT_TIMESTAMP}`, + `flags_${PROJECT_ID}=${CURRENT_TIMESTAMP};flags_other=${CURRENT_TIMESTAMP + 20_000}`, + `flags_${PROJECT_ID}_suffix=${CURRENT_TIMESTAMP + 20_000};flags_${PROJECT_ID}=${CURRENT_TIMESTAMP}`, + ])('selects the exact project from %s', async (header) => { + setHeader(header); + const current = tagData(datafile(), 'provided'); + + expect(source.isAvailable(PROJECT_ID)).toBe(true); + await expect(source.read(current)).resolves.toEqual([current, 'HIT']); + expect(fetchDatafile).not.toHaveBeenCalled(); + }); + + it.each([ + ['absent header', undefined], + ['empty header', ''], + ['another project', `flags_other=${CURRENT_TIMESTAMP}`], + [ + 'project prefix only', + `flags_${PROJECT_ID}_suffix=${CURRENT_TIMESTAMP}`, + ], + ['missing equals sign', `flags_${PROJECT_ID}`], + ['empty value', `flags_${PROJECT_ID}=`], + ['whitespace value', `flags_${PROJECT_ID}= `], + ['non-numeric value', `flags_${PROJECT_ID}=invalid`], + [ + 'numeric prefix with garbage', + `flags_${PROJECT_ID}=${CURRENT_TIMESTAMP}ms`, + ], + ['NaN', `flags_${PROJECT_ID}=NaN`], + ['infinite value', `flags_${PROJECT_ID}=Infinity`], + ['overflowing value', `flags_${PROJECT_ID}=1e309`], + ['negative timestamp', `flags_${PROJECT_ID}=-1`], + ])('ignores %s', async (_label, header) => { + setHeader(header); + const result = await source.read(tagData(datafile(), 'provided')); + + expect.soft(source.isAvailable(PROJECT_ID)).toBe(false); + expect.soft(result).toBeUndefined(); + expect.soft(fetchDatafile).not.toHaveBeenCalled(); + expect.soft(onData).not.toHaveBeenCalled(); + }); + }); + + describe('timestamp boundaries', () => { + it.each([ + -1, 0, + ])('returns HIT without fetching for delta %i ms', async (delta) => { + setVersion(CURRENT_TIMESTAMP + delta); + const current = tagData(datafile(), 'provided'); + + const result = await source.read(current); + + expect(result).toEqual([current, 'HIT']); + expect(result?.[0]).toBe(current); + expect(fetchDatafile).not.toHaveBeenCalled(); + expect(onData).not.toHaveBeenCalled(); + }); + + it.each([ + 1, 9_999, 10_000, + ])('returns STALE immediately and emits background data for delta %i ms', async (delta) => { + setVersion(CURRENT_TIMESTAMP + delta); + const current = tagData(datafile(), 'provided'); + const fresh = datafile(CURRENT_TIMESTAMP + delta); + const pending = deferred(); + vi.mocked(fetchDatafile).mockReturnValueOnce(pending.promise); + + const result = await source.read(current); + + expect(result).toEqual([current, 'STALE']); + expect(result?.[0]).toBe(current); + expect(fetchDatafile).toHaveBeenCalledTimes(1); + expect(onData).not.toHaveBeenCalled(); + pending.resolve(fresh); + await settlePromises(); + expect(onData).toHaveBeenCalledExactlyOnceWith(fresh); + }); + + it.each([ + 10_001, 20_000, + ])('blocks and emits fetched data with MISS for delta %i ms', async (delta) => { + setVersion(CURRENT_TIMESTAMP + delta); + const fresh = datafile(CURRENT_TIMESTAMP + delta); + const pending = deferred(); + vi.mocked(fetchDatafile).mockReturnValueOnce(pending.promise); + const settled = vi.fn(); + const read = source.read(tagData(datafile(), 'provided')); + void read.then(settled); + await settlePromises(); + + expect(settled).not.toHaveBeenCalled(); + expect(onData).not.toHaveBeenCalled(); + pending.resolve(fresh); + await expect(read).resolves.toEqual([ + { ...fresh, _origin: 'fetched' }, + 'MISS', + ]); + expect(fetchDatafile).toHaveBeenCalledTimes(1); + expect(onData).toHaveBeenCalledExactlyOnceWith(fresh); + }); + + it('accepts legacy string timestamps in current data', async () => { + const current = tagData( + { ...datafile(), configUpdatedAt: String(CURRENT_TIMESTAMP) }, + 'provided', + ); + + await expect(source.read(current)).resolves.toEqual([current, 'HIT']); + expect(fetchDatafile).not.toHaveBeenCalled(); + }); + + it('returns undefined without fetching when current data has no timestamp', async () => { + setVersion(CURRENT_TIMESTAMP + 20_000); + const current = tagData( + { ...datafile(), configUpdatedAt: undefined }, + 'provided', + ); + + await expect(source.read(current)).resolves.toBeUndefined(); + expect(fetchDatafile).not.toHaveBeenCalled(); + expect(onData).not.toHaveBeenCalled(); + }); + }); + + describe('fetch deduplication and subsequent reads', () => { + it('shares one pending blocking fetch across concurrent readers', async () => { + setVersion(CURRENT_TIMESTAMP + 20_000); + const current = tagData(datafile(), 'provided'); + const pending = deferred(); + const fresh = datafile(CURRENT_TIMESTAMP + 20_000); + vi.mocked(fetchDatafile).mockReturnValueOnce(pending.promise); + + const reads = [ + source.read(current), + source.read(current), + source.read(current), + ]; + + expect(fetchDatafile).toHaveBeenCalledTimes(1); + pending.resolve(fresh); + const results = await Promise.all(reads); + for (const result of results) { + expect(result).toEqual([{ ...fresh, _origin: 'fetched' }, 'MISS']); + } + expect(onData).toHaveBeenCalledExactlyOnceWith(fresh); + }); + + it('shares background fetches even after the STALE read has settled', async () => { + setVersion(CURRENT_TIMESTAMP + 1); + const current = tagData(datafile(), 'provided'); + const pending = deferred(); + const fresh = datafile(CURRENT_TIMESTAMP + 1); + vi.mocked(fetchDatafile).mockReturnValueOnce(pending.promise); + + const reads = await Promise.all([ + source.read(current), + source.read(current), + ]); + expect(reads).toEqual([ + [current, 'STALE'], + [current, 'STALE'], + ]); + await expect(source.read(current)).resolves.toEqual([current, 'STALE']); + expect(fetchDatafile).toHaveBeenCalledTimes(1); + pending.resolve(fresh); + await settlePromises(); + expect(onData).toHaveBeenCalledExactlyOnceWith(fresh); + }); + + it('rechecks the next request header after a HIT', async () => { + const current = tagData(datafile(), 'provided'); + await expect(source.read(current)).resolves.toEqual([current, 'HIT']); + setVersion(CURRENT_TIMESTAMP + 20_000); + + const result = await source.read(current); + + expect(result?.[1]).toBe('MISS'); + expect(fetchDatafile).toHaveBeenCalledTimes(1); + }); + + it('rechecks availability when the next request has no header', async () => { + const current = tagData(datafile(), 'provided'); + await expect(source.read(current)).resolves.toEqual([current, 'HIT']); + setHeader(undefined); + + await expect(source.read(current)).resolves.toBeUndefined(); + expect(fetchDatafile).not.toHaveBeenCalled(); + }); + + it('rechecks a request after an earlier read had no header', async () => { + const current = tagData(datafile(), 'provided'); + setHeader(undefined); + await expect(source.read(current)).resolves.toBeUndefined(); + setVersion(CURRENT_TIMESTAMP); + + await expect(source.read(current)).resolves.toEqual([current, 'HIT']); + }); + + it('uses updated current data after a background fetch', async () => { + setVersion(CURRENT_TIMESTAMP + 1); + const current = tagData(datafile(), 'provided'); + const fresh = datafile(CURRENT_TIMESTAMP + 1); + vi.mocked(fetchDatafile).mockResolvedValueOnce(fresh); + await expect(source.read(current)).resolves.toEqual([current, 'STALE']); + await settlePromises(); + const updated = tagData(fresh, 'fetched'); + + const result = await source.read(updated); + + expect(result).toEqual([updated, 'HIT']); + expect(result?.[0]).toBe(updated); + expect(fetchDatafile).toHaveBeenCalledTimes(1); + }); + + it('returns HIT rather than replaying MISS after a blocking fetch', async () => { + setVersion(CURRENT_TIMESTAMP + 20_000); + const first = await source.read(tagData(datafile(), 'provided')); + expect(first?.[1]).toBe('MISS'); + const updated = tagData(datafile(CURRENT_TIMESTAMP + 20_000), 'fetched'); + + const second = await source.read(updated); + + expect(second).toEqual([updated, 'HIT']); + expect(second?.[0]).toBe(updated); + expect(fetchDatafile).toHaveBeenCalledTimes(1); + }); + + it('starts another fetch when a later request requires newer data', async () => { + setVersion(CURRENT_TIMESTAMP + 20_000); + await source.read(tagData(datafile(), 'provided')); + const updated = tagData(datafile(CURRENT_TIMESTAMP + 20_000), 'fetched'); + const newer = datafile(CURRENT_TIMESTAMP + 40_000); + setVersion(newer.configUpdatedAt); + vi.mocked(fetchDatafile).mockResolvedValueOnce(newer); + + const result = await source.read(updated); + + expect(fetchDatafile).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ ...newer, _origin: 'fetched' }, 'MISS']); + expect(onData).toHaveBeenCalledTimes(2); + }); + + it('retries after a rejected blocking fetch', async () => { + setVersion(CURRENT_TIMESTAMP + 20_000); + const current = tagData(datafile(), 'provided'); + const failure = new Error('Blocking fetch failed'); + const fresh = datafile(CURRENT_TIMESTAMP + 20_000); + vi.mocked(fetchDatafile) + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce(fresh); + await expect(source.read(current)).rejects.toBe(failure); + expect(onData).not.toHaveBeenCalled(); + + await expect(source.read(current)).resolves.toEqual([ + { ...fresh, _origin: 'fetched' }, + 'MISS', + ]); + expect(fetchDatafile).toHaveBeenCalledTimes(2); + expect(onData).toHaveBeenCalledExactlyOnceWith(fresh); + }); + + it('handles background rejection and retries on a later read', async () => { + setVersion(CURRENT_TIMESTAMP + 1); + const current = tagData(datafile(), 'provided'); + const pending = deferred(); + const fresh = datafile(CURRENT_TIMESTAMP + 1); + vi.mocked(fetchDatafile) + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(fresh); + await expect(source.read(current)).resolves.toEqual([current, 'STALE']); + + // Do not swallow rejections from HeaderSource: Vitest must report an + // unhandled rejection if the background refresh has no error handler. + pending.reject(new Error('HeaderSource background refresh failed')); + await settlePromises(); + expect(onData).not.toHaveBeenCalled(); + await expect(source.read(current)).resolves.toEqual([current, 'STALE']); + await settlePromises(); + expect(fetchDatafile).toHaveBeenCalledTimes(2); + expect(onData).toHaveBeenCalledExactlyOnceWith(fresh); + }); + }); + + describe('stop', () => { + it.each([ + 1, 20_000, + ])('aborts a pending fetch for delta %i ms', async (delta) => { + setVersion(CURRENT_TIMESTAMP + delta); + const pending = deferred(); + vi.mocked(fetchDatafile).mockReturnValueOnce(pending.promise); + const read = source.read(tagData(datafile(), 'provided')); + const outcome = read.catch(() => undefined); + await settlePromises(); + const signal = vi.mocked(fetchDatafile).mock.calls[0]?.[0].signal; + + source.stop(); + // Settle even if the transport ignores cancellation, keeping tests isolated. + pending.resolve(datafile(CURRENT_TIMESTAMP + delta)); + await outcome; + await settlePromises(); + + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(true); + }); + + it.each([ + 1, 20_000, + ])('suppresses late data emissions for delta %i ms', async (delta) => { + setVersion(CURRENT_TIMESTAMP + delta); + const pending = deferred(); + vi.mocked(fetchDatafile).mockReturnValueOnce(pending.promise); + const read = source.read(tagData(datafile(), 'provided')); + const outcome = read.catch(() => undefined); + await settlePromises(); + expect(onData).not.toHaveBeenCalled(); + + source.stop(); + pending.resolve(datafile(CURRENT_TIMESTAMP + delta)); + await outcome; + await settlePromises(); + + expect(onData).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 0efd879a..b03cdefa 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,3 +1,4 @@ +import { waitUntil } from '@vercel/functions'; import type { BundledDefinitions, DatafileInput, Metrics } from '../types'; import { getRequestContext } from '../utils/request-context'; import { fetchDatafile } from './fetch-datafile'; @@ -15,9 +16,7 @@ export type HeaderSourceEvents = { export class HeaderSource extends TypedEmitter { private options: NormalizedOptions; private abortController: AbortController | undefined; - private promise: - | Promise<[TaggedData, Metrics['cacheStatus']] | undefined> - | undefined; + private promise: Promise | undefined; constructor(options: NormalizedOptions) { super(); @@ -26,27 +25,28 @@ export class HeaderSource extends TypedEmitter { } private fetchDatafile(): Promise { + // Share only the transport work, not request-specific freshness decisions. + if (this.promise) return this.promise; + const abortController = new AbortController(); this.abortController = abortController; - - abortController.signal.addEventListener( - 'abort', - () => { + this.promise = fetchDatafile({ + ...this.options, + signal: abortController.signal, + }) + .then((data) => { + this.emit('data', data); + return data; + }) + .finally(() => { + // An older, aborted fetch must not clear a newer request's work. if (this.abortController === abortController) { this.promise = undefined; this.abortController = undefined; } - }, - { once: true }, - ); - - try { - return fetchDatafile(this.options); - } catch (error) { - this.promise = undefined; - this.abortController = undefined; - throw error; - } + }); + + return this.promise; } private getUpdatedAtHeader(projectId: string) { @@ -54,18 +54,21 @@ export class HeaderSource extends TypedEmitter { const header = ctx.headers?.['x-vercel-flags-config-versions'] ?? - ctx.headers?.['x-vercel-flags-config-versions']; + ctx.headers?.['flags-config-versions']; if (!header) { return; } + const prefix = `flags_${projectId}=`; const value = header .split(';') - .find((p) => p.startsWith(`flags_${projectId}=`)) - ?.split('=')[1]; + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)) + ?.slice(prefix.length); + const timestamp = Number(value); - return Number(value); + return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; } private async resolveData( @@ -90,15 +93,20 @@ export class HeaderSource extends TypedEmitter { // header is within 10 seconds of current data, we can revalidate in the background if (updatedAtHeader <= currentUpdatedAt + 10_000) { - this.fetchDatafile().then((data) => { - this.emit('data', data); + const pending = this.fetchDatafile(); + const signal = this.abortController?.signal; + const background = pending.catch((error) => { + if (!signal?.aborted) { + console.error('@vercel/flags-core: Header refresh failed:', error); + } }); + waitUntil(background); + return [currentData, 'STALE']; } const data = await this.fetchDatafile(); - this.emit('data', data); return [tagData(data, 'fetched'), 'MISS']; } @@ -106,11 +114,7 @@ export class HeaderSource extends TypedEmitter { read( currentData: TaggedData, ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { - if (this.promise) return this.promise; - - this.promise = this.resolveData(currentData); - - return this.promise; + return this.resolveData(currentData); } isAvailable(projectId: string): boolean { @@ -118,7 +122,7 @@ export class HeaderSource extends TypedEmitter { } /** - * Stop the stream connection. + * Abort the current header-driven fetch and discard its pending work. */ stop(): void { this.abortController?.abort(); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 60580854..93f1ddca 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -369,6 +369,7 @@ export class Controller implements ControllerInterface { this.unwireSourceEvents(); this.streamSource.stop(); this.pollingSource.stop(); + this.headerSource.stop(); this.data = this.options.datafile ? tagData(this.options.datafile, 'provided') : undefined; diff --git a/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts index 578a6e29..9b9930f6 100644 --- a/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts +++ b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts @@ -36,7 +36,7 @@ export class FlagsConfigReadEvent implements UsageEvent { duration?: number; configUpdatedAt?: number; configOrigin?: 'in-memory' | 'embedded' | 'poll' | 'stream' | 'constructor'; - mode?: 'poll' | 'stream' | 'build' | 'offline'; + mode?: TrackReadOptions['mode']; revision?: string; environment?: string; }; diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts new file mode 100644 index 00000000..f0fda65c --- /dev/null +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -0,0 +1,383 @@ +/** Public-API coverage: real client, controller, header source and fetch helper. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type BundledDefinitions, + createClient, + type FlagsClient, +} from './index.default'; +import { setRequestContext } from './test-utils'; +import { readBundledDefinitions } from './utils/read-bundled-definitions'; + +// Only the filesystem boundary is replaced; no controller/source is mocked. +vi.mock('./utils/read-bundled-definitions', () => ({ + readBundledDefinitions: vi.fn(), +})); + +const TIMESTAMP = 1_700_000_000_000; +const PROJECT_ID = 'prj_header_test'; +const HEADER = 'x-vercel-flags-config-versions'; +const SDK_KEY = 'vf_server_header_test'; + +function datafile(timestamp = TIMESTAMP, enabled = false): BundledDefinitions { + return { + definitions: { + feature: { + environments: { production: enabled ? 1 : 0 }, + variants: [false, true], + }, + }, + segments: {}, + projectId: PROJECT_ID, + environment: 'production', + configUpdatedAt: timestamp, + digest: `digest-${timestamp}`, + revision: timestamp - TIMESTAMP + 1, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const clients = new Set(); +const dataFetch = vi.fn(); +const transport = vi.fn(); +let cleanupContext = () => {}; + +function setVersion(timestamp?: number) { + cleanupContext(); + cleanupContext = setRequestContext( + timestamp === undefined + ? {} + : { [HEADER]: `flags_other=1;flags_${PROJECT_ID}=${timestamp}` }, + ); +} + +function client(options: Parameters[1] = {}) { + const instance = createClient(SDK_KEY, { + datafile: datafile(), + buildStep: false, + fetch: transport, + ...options, + }); + clients.add(instance); + return instance; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(TIMESTAMP); + vi.stubEnv('VERCEL_ENV', 'production'); + vi.mocked(readBundledDefinitions).mockReset(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: null, + state: 'missing-file', + }); + dataFetch.mockReset(); + dataFetch.mockRejectedValue(new Error('Unexpected datafile fetch')); + transport.mockReset(); + transport.mockImplementation((input, init) => { + const url = String(input); + if (url === 'https://flags.vercel.com/v1/datafile') { + return dataFetch(input, init); + } + if (url === 'https://flags.vercel.com/v1/ingest') { + return Promise.resolve(new Response()); + } + return Promise.reject(new Error(`Unexpected request: ${url}`)); + }); + setVersion(TIMESTAMP); +}); + +afterEach(async () => { + try { + await Promise.all([...clients].map((instance) => instance.shutdown())); + } finally { + clients.clear(); + cleanupContext(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + } +}); + +describe('Vercel mode (black-box)', () => { + it.each([ + 'provided', + 'bundled', + ] as const)('uses fresh %s definitions without opening a stream or polling', async (origin) => { + const bundled = datafile(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: bundled, + state: 'ok', + }); + const instance = client({ + datafile: origin === 'provided' ? bundled : undefined, + }); + await instance.initialize(); + + const result = await instance.evaluate('feature'); + + expect(result.value).toBe(false); + expect(result.metrics).toMatchObject({ + mode: 'vercel', + source: origin === 'provided' ? 'in-memory' : 'embedded', + cacheStatus: 'HIT', + connectionState: 'disconnected', + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(dataFetch).not.toHaveBeenCalled(); + expect( + transport.mock.calls.every(([url]) => String(url).endsWith('/v1/ingest')), + ).toBe(true); + }); + + it.each([ + undefined, + 'flags_other=1700000000000', + `flags_${PROJECT_ID}=invalid`, + ])('keeps the configured offline fallback when the matching header is unavailable: %s', async (header) => { + cleanupContext(); + cleanupContext = setRequestContext(header ? { [HEADER]: header } : {}); + const instance = client({ stream: false, polling: false }); + + const result = await instance.evaluate('feature'); + + expect(result.value).toBe(false); + expect(result.metrics).toMatchObject({ + mode: 'offline', + cacheStatus: 'STALE', + }); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it('does not enable runtime header refresh during a build', async () => { + setVersion(TIMESTAMP + 20_000); + const instance = client({ buildStep: true }); + + const result = await instance.evaluate('feature'); + + expect(result.value).toBe(false); + expect(result.metrics?.mode).toBe('build'); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it.each([ + 1, 10_000, + ])('serves stale data immediately at delta %i ms, then exposes the background update', async (delta) => { + setVersion(TIMESTAMP + delta); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + + const first = await instance.evaluate('feature'); + expect(first.value).toBe(false); + expect(first.metrics).toMatchObject({ + mode: 'vercel', + cacheStatus: 'STALE', + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect((await instance.evaluate('feature')).value).toBe(false); + expect(dataFetch).toHaveBeenCalledTimes(1); + + pending.resolve(Response.json(datafile(TIMESTAMP + delta, true))); + await vi.advanceTimersByTimeAsync(0); + const second = await instance.evaluate('feature'); + + expect(second.value).toBe(true); + expect(second.metrics).toMatchObject({ + source: 'remote', + cacheStatus: 'HIT', + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe( + TIMESTAMP + delta, + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('blocks beyond the 10-second boundary and shares one fetch across evaluate and bulkEvaluate', async () => { + setVersion(TIMESTAMP + 10_001); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + const settled = vi.fn(); + const single = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + const bulk = instance.bulkEvaluate([ + { key: 'feature', defaultValue: false }, + ]); + await vi.advanceTimersByTimeAsync(0); + + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + headers: expect.objectContaining({ + Authorization: `Bearer ${SDK_KEY}`, + 'X-Vercel-Env': 'production', + }), + signal: expect.any(AbortSignal), + }, + ); + pending.resolve(Response.json(datafile(TIMESTAMP + 10_001, true))); + const [result, results] = await Promise.all([single, bulk]); + + for (const evaluation of [result, results.feature]) { + expect(evaluation.value).toBe(true); + expect(evaluation.metrics).toMatchObject({ + mode: 'vercel', + source: 'remote', + cacheStatus: 'MISS', + }); + } + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('rechecks new request versions instead of caching the first HIT forever', async () => { + const instance = client(); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + + setVersion(TIMESTAMP + 20_000); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 20_000, true)), + ); + const second = await instance.evaluate('feature'); + expect(second.value).toBe(true); + expect(second.metrics?.cacheStatus).toBe('MISS'); + + setVersion(TIMESTAMP + 40_000); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 40_000, false)), + ); + const third = await instance.evaluate('feature'); + expect(third.value).toBe(false); + expect(third.metrics?.cacheStatus).toBe('MISS'); + expect(dataFetch).toHaveBeenCalledTimes(2); + + setVersion(); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('does not make a fresh request wait on another requests blocking refresh', async () => { + const instance = client(); + await instance.initialize(); + setVersion(TIMESTAMP + 20_000); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const blocking = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + + setVersion(TIMESTAMP); + const hitSettled = vi.fn(); + const hit = instance.evaluate('feature').then((result) => { + hitSettled(result); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + // Settle the transport even when the independence assertion fails. + const completedBeforeFetch = hitSettled.mock.calls.length; + pending.resolve(Response.json(datafile(TIMESTAMP + 20_000, true))); + const [blockingResult, hitResult] = await Promise.all([blocking, hit]); + + expect(completedBeforeFetch).toBe(1); + expect(hitResult.value).toBe(false); + expect(hitResult.metrics?.cacheStatus).toBe('HIT'); + expect(blockingResult.value).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('retries a failed blocking refresh instead of poisoning subsequent evaluations', async () => { + setVersion(TIMESTAMP + 20_000); + dataFetch.mockResolvedValueOnce( + new Response(null, { status: 503, statusText: 'Service Unavailable' }), + ); + const instance = client(); + + const failed = await instance.evaluate('feature', false); + expect(failed.value).toBe(false); + expect(failed.reason).toBe('error'); + expect(failed.errorMessage).toContain('Service Unavailable'); + + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 20_000, true)), + ); + const recovered = await instance.evaluate('feature'); + expect(recovered.value).toBe(true); + expect(recovered.metrics?.cacheStatus).toBe('MISS'); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('contains background fetch errors and retries without losing cached data', async () => { + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + expect((await instance.evaluate('feature')).value).toBe(false); + + pending.reject(new Error('Network unavailable')); + await vi.advanceTimersByTimeAsync(0); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 1, true)), + ); + expect((await instance.evaluate('feature')).value).toBe(false); + await vi.advanceTimersByTimeAsync(0); + + expect((await instance.evaluate('feature')).value).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('aborts an in-flight header refresh on shutdown', async () => { + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + await instance.evaluate('feature'); + const signal = dataFetch.mock.calls[0][1]?.signal; + + await instance.shutdown(); + clients.delete(instance); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await vi.advanceTimersByTimeAsync(0); + + expect(signal?.aborted).toBe(true); + }); + + it('reports vercel mode in config-read telemetry', async () => { + const instance = client(); + await instance.evaluate('feature'); + await instance.shutdown(); + clients.delete(instance); + + const events = transport.mock.calls + .filter(([url]) => String(url).endsWith('/v1/ingest')) + .flatMap(([, init]) => JSON.parse(String(init?.body))); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'FLAGS_CONFIG_READ', + payload: expect.objectContaining({ + mode: 'vercel', + configUpdatedAt: TIMESTAMP, + cacheAction: 'NONE', + }), + }), + ]), + ); + }); +}); From da0b044c2f2efb1c61900c43f21fa21813a3ef20 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Tue, 15 Sep 2026 10:13:50 +0200 Subject: [PATCH 03/10] fix(flags-core): handle cancelled header refreshes and add changeset --- .changeset/header-driven-vercel-mode.md | 5 +++ .../src/controller/header-source.test.ts | 40 +++++++++++++++++++ .../src/controller/header-source.ts | 2 + .../src/vercel-mode.black-box.test.ts | 6 +-- 4 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 .changeset/header-driven-vercel-mode.md diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md new file mode 100644 index 00000000..ea8ce784 --- /dev/null +++ b/.changeset/header-driven-vercel-mode.md @@ -0,0 +1,5 @@ +--- +"@vercel/flags-core": minor +--- + +Add a header-driven `vercel` client mode that uses request config-version timestamps instead of streaming or polling. Reuse fresh definitions, refresh in the background when the request version is up to 10 seconds newer than the cached configuration, and block for a refresh when the gap is larger. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. diff --git a/packages/vercel-flags-core/src/controller/header-source.test.ts b/packages/vercel-flags-core/src/controller/header-source.test.ts index ab0dffe7..ff9084df 100644 --- a/packages/vercel-flags-core/src/controller/header-source.test.ts +++ b/packages/vercel-flags-core/src/controller/header-source.test.ts @@ -372,6 +372,46 @@ describe('HeaderSource', () => { }); describe('stop', () => { + it.each([ + 1, 20_000, + ])('keeps a restarted fetch isolated from a late aborted fetch for delta %i ms', async (delta) => { + setVersion(CURRENT_TIMESTAMP + delta); + const current = tagData(datafile(), 'provided'); + const abandoned = deferred(); + const pending = deferred(); + const fresh = datafile(CURRENT_TIMESTAMP + delta); + vi.mocked(fetchDatafile) + .mockReturnValueOnce(abandoned.promise) + .mockReturnValueOnce(pending.promise); + const abandonedOutcome = source + .read(current) + .catch((error: unknown) => error); + await settlePromises(); + + source.stop(); + const restarted = source.read(current); + abandoned.resolve(fresh); + const outcome = await abandonedOutcome; + await settlePromises(); + + expect(onData).not.toHaveBeenCalled(); + if (delta > 10_000) { + expect(outcome).toMatchObject({ name: 'AbortError' }); + } + const concurrent = source.read(current); + expect(fetchDatafile).toHaveBeenCalledTimes(2); + pending.resolve(fresh); + await Promise.all([restarted, concurrent]); + await settlePromises(); + + expect(onData).toHaveBeenCalledExactlyOnceWith(fresh); + await expect(source.read(tagData(fresh, 'fetched'))).resolves.toEqual([ + fresh, + 'HIT', + ]); + expect(fetchDatafile).toHaveBeenCalledTimes(2); + }); + it.each([ 1, 20_000, ])('aborts a pending fetch for delta %i ms', async (delta) => { diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index b03cdefa..83054604 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -35,6 +35,8 @@ export class HeaderSource extends TypedEmitter { signal: abortController.signal, }) .then((data) => { + // A transport may finish after stop() even if it ignores cancellation. + abortController.signal.throwIfAborted(); this.emit('data', data); return data; }) diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index f0fda65c..50417acc 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -231,8 +231,8 @@ describe('Vercel mode (black-box)', () => { const [result, results] = await Promise.all([single, bulk]); for (const evaluation of [result, results.feature]) { - expect(evaluation.value).toBe(true); - expect(evaluation.metrics).toMatchObject({ + expect(evaluation?.value).toBe(true); + expect(evaluation?.metrics).toMatchObject({ mode: 'vercel', source: 'remote', cacheStatus: 'MISS', @@ -348,7 +348,7 @@ describe('Vercel mode (black-box)', () => { dataFetch.mockReturnValueOnce(pending.promise); const instance = client(); await instance.evaluate('feature'); - const signal = dataFetch.mock.calls[0][1]?.signal; + const signal = dataFetch.mock.calls[0]?.[1]?.signal; await instance.shutdown(); clients.delete(instance); From 004baf818ff46c6e558e9b6c6cdb3e07db531b73 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Tue, 15 Sep 2026 10:25:46 +0200 Subject: [PATCH 04/10] fix --- packages/vercel-flags-core/src/controller/header-source.ts | 7 +++++-- packages/vercel-flags-core/src/controller/index.ts | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 83054604..cdf5ce09 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -55,8 +55,11 @@ export class HeaderSource extends TypedEmitter { const ctx = getRequestContext(); const header = - ctx.headers?.['x-vercel-flags-config-versions'] ?? - ctx.headers?.['flags-config-versions']; + ctx.headers?.['x-vercel-edge-config-versions'] ?? + ctx.headers?.['edge-config-versions']; + // const header = + // ctx.headers?.['x-vercel-flags-config-versions'] ?? + // ctx.headers?.['flags-config-versions']; if (!header) { return; diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 93f1ddca..1473bf99 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -469,10 +469,10 @@ export class Controller implements ControllerInterface { if (this.data) { if (this.mode === 'vercel') { - const data = await this.headerSource.read(this.data); + const result = await this.headerSource.read(this.data); - if (data) { - return data; + if (result) { + return result; } } From 0fd2f412e9eb0c496a4db0b9de5ae4107f1bb1e0 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Tue, 15 Sep 2026 11:38:43 +0200 Subject: [PATCH 05/10] debug --- .changeset/header-driven-vercel-mode.md | 2 + packages/vercel-flags-core/README.md | 24 +++++++ .../src/controller/header-source.ts | 66 +++++++++++++++++-- .../vercel-flags-core/src/controller/index.ts | 44 +++++++++++++ .../src/controller/stream-source.ts | 34 +++++++++- packages/vercel-flags-core/src/utils/debug.ts | 12 ++++ 6 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 packages/vercel-flags-core/src/utils/debug.ts diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index ea8ce784..7c71fe6a 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -3,3 +3,5 @@ --- Add a header-driven `vercel` client mode that uses request config-version timestamps instead of streaming or polling. Reuse fresh definitions, refresh in the background when the request version is up to 10 seconds newer than the cached configuration, and block for a refresh when the gap is larger. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. + +Add opt-in controller, stream, and header-source diagnostics with `DEBUG=@vercel/flags-core` to show data origins, connection lifecycle, and refresh decisions without logging credentials or flag values. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index aa5189ef..ae0f8ed7 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -59,6 +59,30 @@ const client = createClient(process.env.FLAGS!, { This option is sent only to the metrics ingestion endpoint. It does not select the environment used for flag evaluation. +## Debugging data sources + +Set `DEBUG=@vercel/flags-core` in your application's server environment (for +example, in `.env.local` or the Vercel project's environment variables), then +restart or redeploy the app: + +```bash +DEBUG=@vercel/flags-core pnpm dev +``` + +Debug logs are written with `console.log` and labeled `[controller]`, +`[stream-source]`, or `[header-source]`. They show initialization settings and +state transitions, the origin and cache status of each read, stream connections +and disconnections, and header freshness decisions (`serve-cached`, +`background-refresh`, or `blocking-refresh`). Missing headers, unmatched projects, +and invalid timestamps are reported with a reason instead of raw header values. + +The controller's `origin` distinguishes `stream`, `poll`, `provided`, `bundled`, +and `fetched` data; `mode` identifies the active update strategy. Debug metadata +includes project IDs, revisions, and timestamps, but not SDK keys, tokens, flag +values, or full datafiles. This setting also enables the existing ingest debug +logging. Unset `DEBUG` to disable debug output; normal warnings and errors are +unaffected. + ## OpenFeature An OpenFeature-compatible provider is available at `@vercel/flags-core/openfeature`: diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index cdf5ce09..15e4d1c5 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,5 +1,6 @@ import { waitUntil } from '@vercel/functions'; import type { BundledDefinitions, DatafileInput, Metrics } from '../types'; +import { debugLog } from '../utils/debug'; import { getRequestContext } from '../utils/request-context'; import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; @@ -26,8 +27,12 @@ export class HeaderSource extends TypedEmitter { private fetchDatafile(): Promise { // Share only the transport work, not request-specific freshness decisions. - if (this.promise) return this.promise; + if (this.promise) { + debugLog('header-source', 'Reusing pending refresh'); + return this.promise; + } + debugLog('header-source', 'Starting refresh'); const abortController = new AbortController(); this.abortController = abortController; this.promise = fetchDatafile({ @@ -37,9 +42,20 @@ export class HeaderSource extends TypedEmitter { .then((data) => { // A transport may finish after stop() even if it ignores cancellation. abortController.signal.throwIfAborted(); + debugLog('header-source', 'Refresh completed', { + projectId: data.projectId, + configUpdatedAt: Number(data.configUpdatedAt), + revision: data.revision, + }); this.emit('data', data); return data; }) + .catch((error) => { + debugLog('header-source', 'Refresh failed', { + aborted: abortController.signal.aborted, + }); + throw error; + }) .finally(() => { // An older, aborted fetch must not clear a newer request's work. if (this.abortController === abortController) { @@ -54,14 +70,20 @@ export class HeaderSource extends TypedEmitter { private getUpdatedAtHeader(projectId: string) { const ctx = getRequestContext(); - const header = - ctx.headers?.['x-vercel-edge-config-versions'] ?? - ctx.headers?.['edge-config-versions']; + const headerName = + ctx.headers?.['x-vercel-edge-config-versions'] != null + ? 'x-vercel-edge-config-versions' + : 'edge-config-versions'; + const header = ctx.headers?.[headerName]; // const header = // ctx.headers?.['x-vercel-flags-config-versions'] ?? // ctx.headers?.['flags-config-versions']; if (!header) { + debugLog('header-source', 'Header unavailable', { + projectId, + reason: 'missing-header', + }); return; } @@ -73,7 +95,21 @@ export class HeaderSource extends TypedEmitter { ?.slice(prefix.length); const timestamp = Number(value); - return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; + if (!Number.isFinite(timestamp) || timestamp <= 0) { + debugLog('header-source', 'Header unavailable', { + projectId, + headerName, + reason: value === undefined ? 'project-not-found' : 'invalid-timestamp', + }); + return; + } + + debugLog('header-source', 'Header version available', { + projectId, + headerName, + timestamp, + }); + return timestamp; } private async resolveData( @@ -81,6 +117,10 @@ export class HeaderSource extends TypedEmitter { ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { // current datafile has no timestamp, this shouldn't happen if (!currentData.configUpdatedAt) { + debugLog('header-source', 'Skipping header refresh', { + projectId: currentData.projectId, + reason: 'missing-data-timestamp', + }); return; } @@ -90,6 +130,19 @@ export class HeaderSource extends TypedEmitter { } const currentUpdatedAt = Number(currentData.configUpdatedAt); + const deltaMs = updatedAtHeader - currentUpdatedAt; + debugLog('header-source', 'Freshness decision', { + projectId: currentData.projectId, + currentUpdatedAt, + headerUpdatedAt: updatedAtHeader, + deltaMs, + action: + updatedAtHeader <= currentUpdatedAt + ? 'serve-cached' + : updatedAtHeader <= currentUpdatedAt + 10_000 + ? 'background-refresh' + : 'blocking-refresh', + }); // header is older than current data if (updatedAtHeader <= currentUpdatedAt) { @@ -130,6 +183,9 @@ export class HeaderSource extends TypedEmitter { * Abort the current header-driven fetch and discard its pending work. */ stop(): void { + debugLog('header-source', 'Stopping header refresh', { + pending: this.promise !== undefined, + }); this.abortController?.abort(); this.abortController = undefined; this.promise = undefined; diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 1473bf99..f6f0e2a2 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 { debugLog } from '../utils/debug'; import { readBundledDefinitions } from '../utils/read-bundled-definitions'; import type { TrackReadOptions } from '../utils/usage/flags-config-read'; import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; @@ -227,6 +228,12 @@ export class Controller implements ControllerInterface { // --------------------------------------------------------------------------- private transition(to: State): void { + debugLog('controller', 'State changed', { + from: this.state, + to, + projectId: this.data?.projectId, + origin: this.data?._origin, + }); this.state = to; } @@ -261,6 +268,14 @@ export class Controller implements ControllerInterface { * Offline mode (neither): datafile → bundled → one-time fetch */ async initialize(): Promise { + debugLog('controller', 'Initializing', { + buildStep: this.options.buildStep, + streamEnabled: this.options.stream.enabled, + pollingEnabled: this.options.polling.enabled, + hasData: this.data !== undefined, + projectId: this.data?.projectId, + origin: this.data?._origin, + }); if (this.options.buildStep) { this.transition('build:loading'); await this.initializeForBuildStep(); @@ -306,6 +321,9 @@ export class Controller implements ControllerInterface { return; } + debugLog('controller', 'Header mode unavailable', { + reason: 'no-definitions', + }); // Try the configured primary source (stream or poll, never both) if (this.options.stream.enabled) { this.transition('initializing:stream'); @@ -340,6 +358,15 @@ export class Controller implements ControllerInterface { const readMs = Date.now() - startTime; const source = originToMetricsSource(result._origin); + debugLog('controller', 'Read resolved', { + projectId: result.projectId, + mode: this.mode, + source, + origin: result._origin, + cacheStatus, + configUpdatedAt: parseConfigUpdatedAt(result.configUpdatedAt), + revision: result.revision, + }); this.trackRead(startTime, cacheHadDefinitions, isFirstRead, source); if (this.dataViewSource !== result) { @@ -422,6 +449,15 @@ export class Controller implements ControllerInterface { } const source = originToMetricsSource(result._origin); + debugLog('controller', 'Datafile resolved', { + projectId: result.projectId, + mode: this.mode, + source, + origin: result._origin, + cacheStatus, + configUpdatedAt: parseConfigUpdatedAt(result.configUpdatedAt), + revision: result.revision, + }); if (this.dataViewSource !== result) { const { _origin, ...rest } = result; @@ -521,6 +557,14 @@ export class Controller implements ControllerInterface { clearTimeout(timeoutId!); if (result === 'timeout') { + debugLog( + 'controller', + 'Stream initialization timed out; using fallback', + { + timeoutMs: this.options.stream.initTimeoutMs, + origin: this.data?._origin, + }, + ); console.warn( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', ); diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index b0d63912..f38bbeba 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,4 +1,5 @@ import type { DatafileInput } from '../types'; +import { debugLog } from '../utils/debug'; import type { NormalizedOptions } from './normalized-options'; import { connectStream, type PrimedMessage } from './stream-connection'; import { TypedEmitter } from './typed-emitter'; @@ -32,8 +33,12 @@ export class StreamSource extends TypedEmitter { * If already started, returns the existing promise. */ start(): Promise { - if (this.promise) return this.promise; + if (this.promise) { + debugLog('stream-source', 'Reusing stream connection'); + return this.promise; + } + debugLog('stream-source', 'Starting stream connection'); const abortController = new AbortController(); this.abortController = abortController; @@ -62,22 +67,42 @@ export class StreamSource extends TypedEmitter { }, { onDatafile: (newData) => { + debugLog('stream-source', 'Connected with datafile', { + projectId: newData.projectId, + configUpdatedAt: Number(newData.configUpdatedAt), + revision: newData.revision, + }); this.emit('data', newData); this.emit('connected'); }, onPrimed: (message) => { + debugLog('stream-source', 'Connected with current revision', { + projectId: message.projectId, + revision: message.revision, + }); this.emit('primed', message); this.emit('connected'); }, onDisconnect: () => { + debugLog('stream-source', 'Disconnected', { + aborted: abortController.signal.aborted, + }); this.emit('disconnected'); }, }, ); - this.promise = promise; - return promise; + this.promise = promise.catch((error) => { + debugLog('stream-source', 'Stream initialization failed', { + aborted: abortController.signal.aborted, + }); + throw error; + }); + return this.promise; } catch (error) { + debugLog('stream-source', 'Stream initialization failed', { + aborted: abortController.signal.aborted, + }); this.promise = undefined; this.abortController = undefined; throw error; @@ -88,6 +113,9 @@ export class StreamSource extends TypedEmitter { * Stop the stream connection. */ stop(): void { + debugLog('stream-source', 'Stopping stream connection', { + active: this.abortController !== undefined, + }); this.abortController?.abort(); this.abortController = undefined; this.promise = undefined; diff --git a/packages/vercel-flags-core/src/utils/debug.ts b/packages/vercel-flags-core/src/utils/debug.ts new file mode 100644 index 00000000..f77f454e --- /dev/null +++ b/packages/vercel-flags-core/src/utils/debug.ts @@ -0,0 +1,12 @@ +type DebugSource = 'controller' | 'stream-source' | 'header-source'; +type DebugDetails = Record; + +/** Keep debug payloads limited to operational metadata, never credentials or flags. */ +export function debugLog( + source: DebugSource, + message: string, + details: DebugDetails = {}, +): void { + if (!process.env.DEBUG?.includes('@vercel/flags-core')) return; + console.log(`@vercel/flags-core [${source}] ${message}`, details); +} From 48afe11165cdb7495c41c87a2e0bf8680c7d57e3 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Tue, 15 Sep 2026 12:21:50 +0200 Subject: [PATCH 06/10] feat(flags-core): add bundled source debug diagnostics --- .changeset/header-driven-vercel-mode.md | 2 +- packages/vercel-flags-core/README.md | 6 +- .../src/bundled-source.black-box.test.ts | 217 ++++++++++++++++++ .../src/controller/bundled-source.ts | 30 ++- .../src/controller/header-source.test.ts | 2 +- .../vercel-flags-core/src/controller/index.ts | 8 +- packages/vercel-flags-core/src/utils/debug.ts | 6 +- .../src/vercel-mode.black-box.test.ts | 2 +- 8 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 packages/vercel-flags-core/src/bundled-source.black-box.test.ts diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 7c71fe6a..74eafe1d 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -4,4 +4,4 @@ Add a header-driven `vercel` client mode that uses request config-version timestamps instead of streaming or polling. Reuse fresh definitions, refresh in the background when the request version is up to 10 seconds newer than the cached configuration, and block for a refresh when the gap is larger. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. -Add opt-in controller, stream, and header-source diagnostics with `DEBUG=@vercel/flags-core` to show data origins, connection lifecycle, and refresh decisions without logging credentials or flag values. +Add opt-in controller, stream, header-source, and bundled-source diagnostics with `DEBUG=@vercel/flags-core` to show data origins, connection lifecycle, and refresh decisions without logging credentials or flag values. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index ae0f8ed7..9a7d9d48 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -70,11 +70,15 @@ DEBUG=@vercel/flags-core pnpm dev ``` Debug logs are written with `console.log` and labeled `[controller]`, -`[stream-source]`, or `[header-source]`. They show initialization settings and +`[stream-source]`, `[header-source]`, or `[bundled-source]`. They show initialization settings and state transitions, the origin and cache status of each read, stream connections and disconnections, and header freshness decisions (`serve-cached`, `background-refresh`, or `blocking-refresh`). Missing headers, unmatched projects, and invalid timestamps are reported with a reason instead of raw header values. +Bundled-source logs show load attempts, reuse of a cached or pending lookup, +and the project, environment, timestamp, and revision of loaded definitions. +When definitions are unavailable, the reason is `missing-file`, `missing-entry`, +or `unexpected-error`; raw errors and bundle contents are not logged. The controller's `origin` distinguishes `stream`, `poll`, `provided`, `bundled`, and `fetched` data; `mode` identifies the active update strategy. Debug metadata diff --git a/packages/vercel-flags-core/src/bundled-source.black-box.test.ts b/packages/vercel-flags-core/src/bundled-source.black-box.test.ts new file mode 100644 index 00000000..f99e2a13 --- /dev/null +++ b/packages/vercel-flags-core/src/bundled-source.black-box.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type BundledDefinitions, + createClient, + type FlagsClient, +} from './index.default'; +import { setRequestContext } from './test-utils'; +import type { BundledDefinitionsResult } from './types'; +import { readBundledDefinitions } from './utils/read-bundled-definitions'; + +// Keep the client and source real; replace only the optional filesystem module. +vi.mock('./utils/read-bundled-definitions', () => ({ + readBundledDefinitions: vi.fn(), +})); + +const SDK_KEY = 'vf_server_private_bundle_key'; +const clients = new Set(); +let cleanupContext = () => {}; + +function definitions(): BundledDefinitions { + return { + projectId: 'prj_bundle', + environment: 'production', + configUpdatedAt: 1_700_000_000_000, + revision: 42, + digest: 'private-bundle-digest', + definitions: { + 'private-flag-key': { + environments: { production: 0 }, + variants: ['private-flag-value'], + }, + }, + }; +} + +function client() { + const instance = createClient(SDK_KEY, { + stream: false, + polling: false, + buildStep: false, + fetch: vi.fn().mockImplementation((input) => { + if (String(input) === 'https://flags.vercel.com/v1/ingest') { + return Promise.resolve(new Response()); + } + return Promise.reject(new Error('Unexpected network request')); + }), + }); + clients.add(instance); + return instance; +} + +function expectLog(message: string, details: Record = {}) { + expect(console.log).toHaveBeenCalledWith( + `@vercel/flags-core [bundled-source] ${message}`, + expect.objectContaining(details), + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubEnv('DEBUG', '@vercel/flags-core'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.mocked(readBundledDefinitions).mockReset(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: definitions(), + }); + cleanupContext = setRequestContext({}); +}); + +afterEach(async () => { + try { + await Promise.all([...clients].map((instance) => instance.shutdown())); + } finally { + clients.clear(); + cleanupContext(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + } +}); + +describe('bundled-source diagnostics (black-box)', () => { + it('logs loaded metadata and the bundle actually used for evaluation', async () => { + const instance = client(); + const result = await instance.evaluate('private-flag-key'); + expect(result.value).toBe('private-flag-value'); + expect(result.metrics?.source).toBe('embedded'); + expectLog('Loading bundled definitions'); + expectLog('Bundled definitions loaded', { + projectId: 'prj_bundle', + environment: 'production', + configUpdatedAt: 1_700_000_000_000, + revision: 42, + }); + expect(console.log).toHaveBeenCalledWith( + '@vercel/flags-core [controller] Read resolved', + expect.objectContaining({ origin: 'bundled', source: 'embedded' }), + ); + await instance.getFallbackDatafile(); + expectLog('Reusing cached or pending lookup'); + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + + const output = JSON.stringify(vi.mocked(console.log).mock.calls); + for (const secret of [ + SDK_KEY, + 'private-flag-key', + 'private-flag-value', + 'private-bundle-digest', + ]) { + expect(output).not.toContain(secret); + } + }); + + it('shares a pending lookup without logging another load attempt', async () => { + let resolve!: (value: BundledDefinitionsResult) => void; + vi.mocked(readBundledDefinitions).mockReturnValueOnce( + new Promise((done) => { + resolve = done; + }), + ); + const instance = client(); + const first = instance.getFallbackDatafile(); + const second = instance.getFallbackDatafile(); + expectLog('Reusing cached or pending lookup'); + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + const bundled = definitions(); + resolve({ state: 'ok', definitions: bundled }); + await expect(Promise.all([first, second])).resolves.toEqual([ + bundled, + bundled, + ]); + const loadLogs = vi + .mocked(console.log) + .mock.calls.filter( + ([message]) => + message === + '@vercel/flags-core [bundled-source] Loading bundled definitions', + ); + expect(loadLogs).toHaveLength(1); + expectLog('Bundled definitions loaded'); + }); + + it.each([ + ['missing-file', 'FallbackNotFoundError'], + ['missing-entry', 'FallbackEntryNotFoundError'], + ] as const)('reports %s while preserving the public error and cached result', async (state, name) => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: null, + state, + }); + const instance = client(); + await expect(instance.getFallbackDatafile()).rejects.toMatchObject({ + name, + }); + expectLog('Bundled definitions unavailable', { reason: state }); + await expect(instance.getFallbackDatafile()).rejects.toMatchObject({ + name, + }); + expectLog('Reusing cached or pending lookup'); + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + }); + + it('reports unexpected errors without exposing the error payload', async () => { + const error = new Error(`private-error-details ${SDK_KEY}`); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: null, + state: 'unexpected-error', + error, + }); + await expect(client().getFallbackDatafile()).rejects.toThrow(error.message); + expectLog('Bundled definitions unavailable', { + reason: 'unexpected-error', + }); + const output = JSON.stringify(vi.mocked(console.log).mock.calls); + expect(output).not.toContain('private-error-details'); + expect(output).not.toContain(SDK_KEY); + }); + + it('logs a rejected lookup without changing its error or caching behavior', async () => { + const error = new Error(`private-rejection ${SDK_KEY}`); + vi.mocked(readBundledDefinitions).mockRejectedValue(error); + const instance = client(); + await expect(instance.getFallbackDatafile()).rejects.toBe(error); + expectLog('Bundled definitions lookup failed'); + await expect(instance.getFallbackDatafile()).rejects.toBe(error); + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + const output = JSON.stringify(vi.mocked(console.log).mock.calls); + expect(output).not.toContain('private-rejection'); + expect(output).not.toContain(SDK_KEY); + }); + + it.each([ + undefined, + '', + 'other-package', + ])('stays silent when DEBUG is %s', async (value) => { + vi.stubEnv('DEBUG', value); + const instance = client(); + await instance.getFallbackDatafile(); + await instance.getFallbackDatafile(); + await instance.shutdown(); + clients.delete(instance); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('can disable diagnostics after a bundle is cached', async () => { + const instance = client(); + await instance.getFallbackDatafile(); + expectLog('Bundled definitions loaded'); + vi.mocked(console.log).mockClear(); + vi.stubEnv('DEBUG', undefined); + await instance.getFallbackDatafile(); + expect(console.log).not.toHaveBeenCalled(); + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vercel-flags-core/src/controller/bundled-source.ts b/packages/vercel-flags-core/src/controller/bundled-source.ts index 139dbc79..4fe87d0e 100644 --- a/packages/vercel-flags-core/src/controller/bundled-source.ts +++ b/packages/vercel-flags-core/src/controller/bundled-source.ts @@ -4,6 +4,7 @@ import type { BundledDefinitionsResult, DatafileInput, } from '../types'; +import { debugLog } from '../utils/debug'; import type { readBundledDefinitions } from '../utils/read-bundled-definitions'; import type { Auth } from './auth'; @@ -72,9 +73,34 @@ export class BundledSource { } private getResult(): Promise { - if (!this.promise) { - this.promise = this.options.readBundledDefinitions(this.options.auth); + if (this.promise) { + debugLog('bundled-source', 'Reusing cached or pending lookup'); + return this.promise; } + + debugLog('bundled-source', 'Loading bundled definitions'); + this.promise = this.options.readBundledDefinitions(this.options.auth).then( + (result) => { + if (result.state === 'ok') { + debugLog('bundled-source', 'Bundled definitions loaded', { + projectId: result.definitions.projectId, + environment: result.definitions.environment, + configUpdatedAt: Number(result.definitions.configUpdatedAt), + revision: result.definitions.revision, + }); + } else { + debugLog('bundled-source', 'Bundled definitions unavailable', { + reason: result.state, + }); + } + return result; + }, + (error) => { + // Error messages can contain credentials or bundled flag data. + debugLog('bundled-source', 'Bundled definitions lookup failed'); + throw error; + }, + ); return this.promise; } } diff --git a/packages/vercel-flags-core/src/controller/header-source.test.ts b/packages/vercel-flags-core/src/controller/header-source.test.ts index ff9084df..d54d6845 100644 --- a/packages/vercel-flags-core/src/controller/header-source.test.ts +++ b/packages/vercel-flags-core/src/controller/header-source.test.ts @@ -11,7 +11,7 @@ vi.mock('./fetch-datafile', () => ({ fetchDatafile: vi.fn() })); const PROJECT_ID = 'prj_test'; const CURRENT_TIMESTAMP = 1_700_000_000_000; -const HEADER = 'x-vercel-flags-config-versions'; +const HEADER = 'x-vercel-edge-config-versions'; function datafile(configUpdatedAt = CURRENT_TIMESTAMP): BundledDefinitions { return { diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index f6f0e2a2..0091123f 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -321,9 +321,11 @@ export class Controller implements ControllerInterface { return; } - debugLog('controller', 'Header mode unavailable', { - reason: 'no-definitions', - }); + debugLog( + 'controller', + 'No data available — attempting to initialize from primary source or fallbacks', + ); + // Try the configured primary source (stream or poll, never both) if (this.options.stream.enabled) { this.transition('initializing:stream'); diff --git a/packages/vercel-flags-core/src/utils/debug.ts b/packages/vercel-flags-core/src/utils/debug.ts index f77f454e..3e5c6d60 100644 --- a/packages/vercel-flags-core/src/utils/debug.ts +++ b/packages/vercel-flags-core/src/utils/debug.ts @@ -1,4 +1,8 @@ -type DebugSource = 'controller' | 'stream-source' | 'header-source'; +type DebugSource = + | 'controller' + | 'stream-source' + | 'header-source' + | 'bundled-source'; type DebugDetails = Record; /** Keep debug payloads limited to operational metadata, never credentials or flags. */ diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 50417acc..c8af5ca8 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -15,7 +15,7 @@ vi.mock('./utils/read-bundled-definitions', () => ({ const TIMESTAMP = 1_700_000_000_000; const PROJECT_ID = 'prj_header_test'; -const HEADER = 'x-vercel-flags-config-versions'; +const HEADER = 'x-vercel-edge-config-versions'; const SDK_KEY = 'vf_server_header_test'; function datafile(timestamp = TIMESTAMP, enabled = false): BundledDefinitions { From 25154ede50165c41f4517fda7e74930e7416ae4a Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 16 Sep 2026 11:18:23 +0200 Subject: [PATCH 07/10] fix(flags-core): use singular flags config version headers --- .changeset/header-driven-vercel-mode.md | 2 +- packages/vercel-flags-core/README.md | 2 + .../src/controller/header-source.test.ts | 53 ++++++++++++++++++- .../src/controller/header-source.ts | 9 ++-- .../vercel-flags-core/src/controller/index.ts | 2 +- .../src/vercel-mode.black-box.test.ts | 38 ++++++++++++- 6 files changed, 96 insertions(+), 10 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 74eafe1d..8fa672a7 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -2,6 +2,6 @@ "@vercel/flags-core": minor --- -Add a header-driven `vercel` client mode that uses request config-version timestamps instead of streaming or polling. Reuse fresh definitions, refresh in the background when the request version is up to 10 seconds newer than the cached configuration, and block for a refresh when the gap is larger. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. +Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-version` or `flags-config-version` request header instead of streaming or polling. Reuse fresh definitions, refresh in the background when the request version is up to 10 seconds newer than the cached configuration, and block for a refresh when the gap is larger. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. Add opt-in controller, stream, header-source, and bundled-source diagnostics with `DEBUG=@vercel/flags-core` to show data origins, connection lifecycle, and refresh decisions without logging credentials or flag values. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 9a7d9d48..195a2a7c 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -75,6 +75,8 @@ state transitions, the origin and cache status of each read, stream connections and disconnections, and header freshness decisions (`serve-cached`, `background-refresh`, or `blocking-refresh`). Missing headers, unmatched projects, and invalid timestamps are reported with a reason instead of raw header values. +The header source reads `x-vercel-flags-config-version` or `flags-config-version`, +with the `x-vercel-` header taking precedence when both are present. Bundled-source logs show load attempts, reuse of a cached or pending lookup, and the project, environment, timestamp, and revision of loaded definitions. When definitions are unavailable, the reason is `missing-file`, `missing-entry`, diff --git a/packages/vercel-flags-core/src/controller/header-source.test.ts b/packages/vercel-flags-core/src/controller/header-source.test.ts index d54d6845..d0080f5f 100644 --- a/packages/vercel-flags-core/src/controller/header-source.test.ts +++ b/packages/vercel-flags-core/src/controller/header-source.test.ts @@ -11,7 +11,7 @@ vi.mock('./fetch-datafile', () => ({ fetchDatafile: vi.fn() })); const PROJECT_ID = 'prj_test'; const CURRENT_TIMESTAMP = 1_700_000_000_000; -const HEADER = 'x-vercel-edge-config-versions'; +const HEADER = 'x-vercel-flags-config-version'; function datafile(configUpdatedAt = CURRENT_TIMESTAMP): BundledDefinitions { return { @@ -86,6 +86,57 @@ afterEach(() => { }); describe('HeaderSource', () => { + describe('header names', () => { + it.each([ + HEADER, + 'flags-config-version', + ])('reads %s', async (headerName) => { + vi.mocked(getRequestContext).mockReturnValue({ + ctx: {}, + headers: { [headerName]: `flags_${PROJECT_ID}=${CURRENT_TIMESTAMP}` }, + }); + const current = tagData(datafile(), 'provided'); + + expect(source.isAvailable(PROJECT_ID)).toBe(true); + await expect(source.read(current)).resolves.toEqual([current, 'HIT']); + expect(fetchDatafile).not.toHaveBeenCalled(); + }); + + it('prefers the x-vercel header when both names are present', async () => { + vi.mocked(getRequestContext).mockReturnValue({ + ctx: {}, + headers: { + [HEADER]: `flags_${PROJECT_ID}=${CURRENT_TIMESTAMP}`, + 'flags-config-version': `flags_${PROJECT_ID}=${CURRENT_TIMESTAMP + 20_000}`, + }, + }); + const current = tagData(datafile(), 'provided'); + + await expect(source.read(current)).resolves.toEqual([current, 'HIT']); + expect(fetchDatafile).not.toHaveBeenCalled(); + }); + + it.each([ + 'x-vercel-edge-config-versions', + 'edge-config-versions', + 'x-vercel-flags-config-versions', + 'flags-config-versions', + ])('ignores the obsolete header %s', async (headerName) => { + vi.mocked(getRequestContext).mockReturnValue({ + ctx: {}, + headers: { + [headerName]: `flags_${PROJECT_ID}=${CURRENT_TIMESTAMP + 20_000}`, + }, + }); + + expect(source.isAvailable(PROJECT_ID)).toBe(false); + await expect( + source.read(tagData(datafile(), 'provided')), + ).resolves.toBeUndefined(); + expect(fetchDatafile).not.toHaveBeenCalled(); + }); + }); + describe('project-specific version header', () => { it.each([ `flags_other=${CURRENT_TIMESTAMP + 20_000};flags_${PROJECT_ID}=${CURRENT_TIMESTAMP}`, diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 15e4d1c5..466bc0d2 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -71,13 +71,10 @@ export class HeaderSource extends TypedEmitter { const ctx = getRequestContext(); const headerName = - ctx.headers?.['x-vercel-edge-config-versions'] != null - ? 'x-vercel-edge-config-versions' - : 'edge-config-versions'; + ctx.headers?.['x-vercel-flags-config-version'] != null + ? 'x-vercel-flags-config-version' + : 'flags-config-version'; const header = ctx.headers?.[headerName]; - // const header = - // ctx.headers?.['x-vercel-flags-config-versions'] ?? - // ctx.headers?.['flags-config-versions']; if (!header) { debugLog('header-source', 'Header unavailable', { diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 0091123f..9cdef6c8 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -88,7 +88,7 @@ type State = * - Uses polling exclusively * - Same fallback chains as streaming mode * - * **Runtime - vercel mode** (request context has matching x-vercel-flags-config-versions header) + * **Runtime - vercel mode** (request context has a matching x-vercel-flags-config-version or flags-config-version header) * - Uses the header value to determine if the current data is fresh * - Revalidates in the background if the header value is within 10 seconds of the current configUpdatedAt * - Blocking fetch if header is newer than current configUpdatedAt diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index c8af5ca8..6b443ad7 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -15,7 +15,7 @@ vi.mock('./utils/read-bundled-definitions', () => ({ const TIMESTAMP = 1_700_000_000_000; const PROJECT_ID = 'prj_header_test'; -const HEADER = 'x-vercel-edge-config-versions'; +const HEADER = 'x-vercel-flags-config-version'; const SDK_KEY = 'vf_server_header_test'; function datafile(timestamp = TIMESTAMP, enabled = false): BundledDefinitions { @@ -107,6 +107,42 @@ afterEach(async () => { }); describe('Vercel mode (black-box)', () => { + it.each([ + HEADER, + 'flags-config-version', + ])('initializes and refreshes using %s', async (headerName) => { + cleanupContext(); + cleanupContext = setRequestContext({ + [headerName]: `flags_${PROJECT_ID}=${TIMESTAMP}`, + }); + const instance = client(); + + const initial = await instance.evaluate('feature'); + expect(initial.value).toBe(false); + expect(initial.metrics).toMatchObject({ + mode: 'vercel', + cacheStatus: 'HIT', + }); + expect(dataFetch).not.toHaveBeenCalled(); + + cleanupContext(); + cleanupContext = setRequestContext({ + [headerName]: `flags_${PROJECT_ID}=${TIMESTAMP + 20_000}`, + }); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 20_000, true)), + ); + + const refreshed = await instance.evaluate('feature'); + expect(refreshed.value).toBe(true); + expect(refreshed.metrics).toMatchObject({ + mode: 'vercel', + source: 'remote', + cacheStatus: 'MISS', + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + it.each([ 'provided', 'bundled', From 3c12c2c04d969483c003f2f31f41d384d13b74fd Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 16 Sep 2026 11:44:50 +0200 Subject: [PATCH 08/10] revert(flags-core): remove source debug logging --- .changeset/header-driven-vercel-mode.md | 2 - packages/vercel-flags-core/README.md | 27 +-- .../src/bundled-source.black-box.test.ts | 217 ------------------ .../src/controller/bundled-source.ts | 30 +-- .../src/controller/header-source.ts | 66 +----- .../vercel-flags-core/src/controller/index.ts | 46 ---- .../src/controller/stream-source.ts | 34 +-- packages/vercel-flags-core/src/utils/debug.ts | 16 -- 8 files changed, 11 insertions(+), 427 deletions(-) delete mode 100644 packages/vercel-flags-core/src/bundled-source.black-box.test.ts delete mode 100644 packages/vercel-flags-core/src/utils/debug.ts diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 8fa672a7..6e70a419 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -3,5 +3,3 @@ --- Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-version` or `flags-config-version` request header instead of streaming or polling. Reuse fresh definitions, refresh in the background when the request version is up to 10 seconds newer than the cached configuration, and block for a refresh when the gap is larger. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. - -Add opt-in controller, stream, header-source, and bundled-source diagnostics with `DEBUG=@vercel/flags-core` to show data origins, connection lifecycle, and refresh decisions without logging credentials or flag values. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 195a2a7c..ca54b589 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -59,35 +59,10 @@ const client = createClient(process.env.FLAGS!, { This option is sent only to the metrics ingestion endpoint. It does not select the environment used for flag evaluation. -## Debugging data sources +## Configuration version headers -Set `DEBUG=@vercel/flags-core` in your application's server environment (for -example, in `.env.local` or the Vercel project's environment variables), then -restart or redeploy the app: - -```bash -DEBUG=@vercel/flags-core pnpm dev -``` - -Debug logs are written with `console.log` and labeled `[controller]`, -`[stream-source]`, `[header-source]`, or `[bundled-source]`. They show initialization settings and -state transitions, the origin and cache status of each read, stream connections -and disconnections, and header freshness decisions (`serve-cached`, -`background-refresh`, or `blocking-refresh`). Missing headers, unmatched projects, -and invalid timestamps are reported with a reason instead of raw header values. The header source reads `x-vercel-flags-config-version` or `flags-config-version`, with the `x-vercel-` header taking precedence when both are present. -Bundled-source logs show load attempts, reuse of a cached or pending lookup, -and the project, environment, timestamp, and revision of loaded definitions. -When definitions are unavailable, the reason is `missing-file`, `missing-entry`, -or `unexpected-error`; raw errors and bundle contents are not logged. - -The controller's `origin` distinguishes `stream`, `poll`, `provided`, `bundled`, -and `fetched` data; `mode` identifies the active update strategy. Debug metadata -includes project IDs, revisions, and timestamps, but not SDK keys, tokens, flag -values, or full datafiles. This setting also enables the existing ingest debug -logging. Unset `DEBUG` to disable debug output; normal warnings and errors are -unaffected. ## OpenFeature diff --git a/packages/vercel-flags-core/src/bundled-source.black-box.test.ts b/packages/vercel-flags-core/src/bundled-source.black-box.test.ts deleted file mode 100644 index f99e2a13..00000000 --- a/packages/vercel-flags-core/src/bundled-source.black-box.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - type BundledDefinitions, - createClient, - type FlagsClient, -} from './index.default'; -import { setRequestContext } from './test-utils'; -import type { BundledDefinitionsResult } from './types'; -import { readBundledDefinitions } from './utils/read-bundled-definitions'; - -// Keep the client and source real; replace only the optional filesystem module. -vi.mock('./utils/read-bundled-definitions', () => ({ - readBundledDefinitions: vi.fn(), -})); - -const SDK_KEY = 'vf_server_private_bundle_key'; -const clients = new Set(); -let cleanupContext = () => {}; - -function definitions(): BundledDefinitions { - return { - projectId: 'prj_bundle', - environment: 'production', - configUpdatedAt: 1_700_000_000_000, - revision: 42, - digest: 'private-bundle-digest', - definitions: { - 'private-flag-key': { - environments: { production: 0 }, - variants: ['private-flag-value'], - }, - }, - }; -} - -function client() { - const instance = createClient(SDK_KEY, { - stream: false, - polling: false, - buildStep: false, - fetch: vi.fn().mockImplementation((input) => { - if (String(input) === 'https://flags.vercel.com/v1/ingest') { - return Promise.resolve(new Response()); - } - return Promise.reject(new Error('Unexpected network request')); - }), - }); - clients.add(instance); - return instance; -} - -function expectLog(message: string, details: Record = {}) { - expect(console.log).toHaveBeenCalledWith( - `@vercel/flags-core [bundled-source] ${message}`, - expect.objectContaining(details), - ); -} - -beforeEach(() => { - vi.useFakeTimers(); - vi.stubEnv('DEBUG', '@vercel/flags-core'); - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.mocked(readBundledDefinitions).mockReset(); - vi.mocked(readBundledDefinitions).mockResolvedValue({ - state: 'ok', - definitions: definitions(), - }); - cleanupContext = setRequestContext({}); -}); - -afterEach(async () => { - try { - await Promise.all([...clients].map((instance) => instance.shutdown())); - } finally { - clients.clear(); - cleanupContext(); - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - vi.useRealTimers(); - } -}); - -describe('bundled-source diagnostics (black-box)', () => { - it('logs loaded metadata and the bundle actually used for evaluation', async () => { - const instance = client(); - const result = await instance.evaluate('private-flag-key'); - expect(result.value).toBe('private-flag-value'); - expect(result.metrics?.source).toBe('embedded'); - expectLog('Loading bundled definitions'); - expectLog('Bundled definitions loaded', { - projectId: 'prj_bundle', - environment: 'production', - configUpdatedAt: 1_700_000_000_000, - revision: 42, - }); - expect(console.log).toHaveBeenCalledWith( - '@vercel/flags-core [controller] Read resolved', - expect.objectContaining({ origin: 'bundled', source: 'embedded' }), - ); - await instance.getFallbackDatafile(); - expectLog('Reusing cached or pending lookup'); - expect(readBundledDefinitions).toHaveBeenCalledTimes(1); - - const output = JSON.stringify(vi.mocked(console.log).mock.calls); - for (const secret of [ - SDK_KEY, - 'private-flag-key', - 'private-flag-value', - 'private-bundle-digest', - ]) { - expect(output).not.toContain(secret); - } - }); - - it('shares a pending lookup without logging another load attempt', async () => { - let resolve!: (value: BundledDefinitionsResult) => void; - vi.mocked(readBundledDefinitions).mockReturnValueOnce( - new Promise((done) => { - resolve = done; - }), - ); - const instance = client(); - const first = instance.getFallbackDatafile(); - const second = instance.getFallbackDatafile(); - expectLog('Reusing cached or pending lookup'); - expect(readBundledDefinitions).toHaveBeenCalledTimes(1); - const bundled = definitions(); - resolve({ state: 'ok', definitions: bundled }); - await expect(Promise.all([first, second])).resolves.toEqual([ - bundled, - bundled, - ]); - const loadLogs = vi - .mocked(console.log) - .mock.calls.filter( - ([message]) => - message === - '@vercel/flags-core [bundled-source] Loading bundled definitions', - ); - expect(loadLogs).toHaveLength(1); - expectLog('Bundled definitions loaded'); - }); - - it.each([ - ['missing-file', 'FallbackNotFoundError'], - ['missing-entry', 'FallbackEntryNotFoundError'], - ] as const)('reports %s while preserving the public error and cached result', async (state, name) => { - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: null, - state, - }); - const instance = client(); - await expect(instance.getFallbackDatafile()).rejects.toMatchObject({ - name, - }); - expectLog('Bundled definitions unavailable', { reason: state }); - await expect(instance.getFallbackDatafile()).rejects.toMatchObject({ - name, - }); - expectLog('Reusing cached or pending lookup'); - expect(readBundledDefinitions).toHaveBeenCalledTimes(1); - }); - - it('reports unexpected errors without exposing the error payload', async () => { - const error = new Error(`private-error-details ${SDK_KEY}`); - vi.mocked(readBundledDefinitions).mockResolvedValue({ - definitions: null, - state: 'unexpected-error', - error, - }); - await expect(client().getFallbackDatafile()).rejects.toThrow(error.message); - expectLog('Bundled definitions unavailable', { - reason: 'unexpected-error', - }); - const output = JSON.stringify(vi.mocked(console.log).mock.calls); - expect(output).not.toContain('private-error-details'); - expect(output).not.toContain(SDK_KEY); - }); - - it('logs a rejected lookup without changing its error or caching behavior', async () => { - const error = new Error(`private-rejection ${SDK_KEY}`); - vi.mocked(readBundledDefinitions).mockRejectedValue(error); - const instance = client(); - await expect(instance.getFallbackDatafile()).rejects.toBe(error); - expectLog('Bundled definitions lookup failed'); - await expect(instance.getFallbackDatafile()).rejects.toBe(error); - expect(readBundledDefinitions).toHaveBeenCalledTimes(1); - const output = JSON.stringify(vi.mocked(console.log).mock.calls); - expect(output).not.toContain('private-rejection'); - expect(output).not.toContain(SDK_KEY); - }); - - it.each([ - undefined, - '', - 'other-package', - ])('stays silent when DEBUG is %s', async (value) => { - vi.stubEnv('DEBUG', value); - const instance = client(); - await instance.getFallbackDatafile(); - await instance.getFallbackDatafile(); - await instance.shutdown(); - clients.delete(instance); - expect(console.log).not.toHaveBeenCalled(); - }); - - it('can disable diagnostics after a bundle is cached', async () => { - const instance = client(); - await instance.getFallbackDatafile(); - expectLog('Bundled definitions loaded'); - vi.mocked(console.log).mockClear(); - vi.stubEnv('DEBUG', undefined); - await instance.getFallbackDatafile(); - expect(console.log).not.toHaveBeenCalled(); - expect(readBundledDefinitions).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/vercel-flags-core/src/controller/bundled-source.ts b/packages/vercel-flags-core/src/controller/bundled-source.ts index 4fe87d0e..139dbc79 100644 --- a/packages/vercel-flags-core/src/controller/bundled-source.ts +++ b/packages/vercel-flags-core/src/controller/bundled-source.ts @@ -4,7 +4,6 @@ import type { BundledDefinitionsResult, DatafileInput, } from '../types'; -import { debugLog } from '../utils/debug'; import type { readBundledDefinitions } from '../utils/read-bundled-definitions'; import type { Auth } from './auth'; @@ -73,34 +72,9 @@ export class BundledSource { } private getResult(): Promise { - if (this.promise) { - debugLog('bundled-source', 'Reusing cached or pending lookup'); - return this.promise; + if (!this.promise) { + this.promise = this.options.readBundledDefinitions(this.options.auth); } - - debugLog('bundled-source', 'Loading bundled definitions'); - this.promise = this.options.readBundledDefinitions(this.options.auth).then( - (result) => { - if (result.state === 'ok') { - debugLog('bundled-source', 'Bundled definitions loaded', { - projectId: result.definitions.projectId, - environment: result.definitions.environment, - configUpdatedAt: Number(result.definitions.configUpdatedAt), - revision: result.definitions.revision, - }); - } else { - debugLog('bundled-source', 'Bundled definitions unavailable', { - reason: result.state, - }); - } - return result; - }, - (error) => { - // Error messages can contain credentials or bundled flag data. - debugLog('bundled-source', 'Bundled definitions lookup failed'); - throw error; - }, - ); return this.promise; } } diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 466bc0d2..df409725 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,6 +1,5 @@ import { waitUntil } from '@vercel/functions'; import type { BundledDefinitions, DatafileInput, Metrics } from '../types'; -import { debugLog } from '../utils/debug'; import { getRequestContext } from '../utils/request-context'; import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; @@ -27,12 +26,8 @@ export class HeaderSource extends TypedEmitter { private fetchDatafile(): Promise { // Share only the transport work, not request-specific freshness decisions. - if (this.promise) { - debugLog('header-source', 'Reusing pending refresh'); - return this.promise; - } + if (this.promise) return this.promise; - debugLog('header-source', 'Starting refresh'); const abortController = new AbortController(); this.abortController = abortController; this.promise = fetchDatafile({ @@ -42,20 +37,9 @@ export class HeaderSource extends TypedEmitter { .then((data) => { // A transport may finish after stop() even if it ignores cancellation. abortController.signal.throwIfAborted(); - debugLog('header-source', 'Refresh completed', { - projectId: data.projectId, - configUpdatedAt: Number(data.configUpdatedAt), - revision: data.revision, - }); this.emit('data', data); return data; }) - .catch((error) => { - debugLog('header-source', 'Refresh failed', { - aborted: abortController.signal.aborted, - }); - throw error; - }) .finally(() => { // An older, aborted fetch must not clear a newer request's work. if (this.abortController === abortController) { @@ -70,17 +54,11 @@ export class HeaderSource extends TypedEmitter { private getUpdatedAtHeader(projectId: string) { const ctx = getRequestContext(); - const headerName = - ctx.headers?.['x-vercel-flags-config-version'] != null - ? 'x-vercel-flags-config-version' - : 'flags-config-version'; - const header = ctx.headers?.[headerName]; + const header = + ctx.headers?.['x-vercel-flags-config-version'] ?? + ctx.headers?.['flags-config-version']; if (!header) { - debugLog('header-source', 'Header unavailable', { - projectId, - reason: 'missing-header', - }); return; } @@ -92,21 +70,7 @@ export class HeaderSource extends TypedEmitter { ?.slice(prefix.length); const timestamp = Number(value); - if (!Number.isFinite(timestamp) || timestamp <= 0) { - debugLog('header-source', 'Header unavailable', { - projectId, - headerName, - reason: value === undefined ? 'project-not-found' : 'invalid-timestamp', - }); - return; - } - - debugLog('header-source', 'Header version available', { - projectId, - headerName, - timestamp, - }); - return timestamp; + return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; } private async resolveData( @@ -114,10 +78,6 @@ export class HeaderSource extends TypedEmitter { ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { // current datafile has no timestamp, this shouldn't happen if (!currentData.configUpdatedAt) { - debugLog('header-source', 'Skipping header refresh', { - projectId: currentData.projectId, - reason: 'missing-data-timestamp', - }); return; } @@ -127,19 +87,6 @@ export class HeaderSource extends TypedEmitter { } const currentUpdatedAt = Number(currentData.configUpdatedAt); - const deltaMs = updatedAtHeader - currentUpdatedAt; - debugLog('header-source', 'Freshness decision', { - projectId: currentData.projectId, - currentUpdatedAt, - headerUpdatedAt: updatedAtHeader, - deltaMs, - action: - updatedAtHeader <= currentUpdatedAt - ? 'serve-cached' - : updatedAtHeader <= currentUpdatedAt + 10_000 - ? 'background-refresh' - : 'blocking-refresh', - }); // header is older than current data if (updatedAtHeader <= currentUpdatedAt) { @@ -180,9 +127,6 @@ export class HeaderSource extends TypedEmitter { * Abort the current header-driven fetch and discard its pending work. */ stop(): void { - debugLog('header-source', 'Stopping header refresh', { - pending: this.promise !== undefined, - }); this.abortController?.abort(); this.abortController = undefined; this.promise = undefined; diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 9cdef6c8..cfb6440b 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -5,7 +5,6 @@ import type { DatafileInput, Metrics, } from '../types'; -import { debugLog } from '../utils/debug'; import { readBundledDefinitions } from '../utils/read-bundled-definitions'; import type { TrackReadOptions } from '../utils/usage/flags-config-read'; import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; @@ -228,12 +227,6 @@ export class Controller implements ControllerInterface { // --------------------------------------------------------------------------- private transition(to: State): void { - debugLog('controller', 'State changed', { - from: this.state, - to, - projectId: this.data?.projectId, - origin: this.data?._origin, - }); this.state = to; } @@ -268,14 +261,6 @@ export class Controller implements ControllerInterface { * Offline mode (neither): datafile → bundled → one-time fetch */ async initialize(): Promise { - debugLog('controller', 'Initializing', { - buildStep: this.options.buildStep, - streamEnabled: this.options.stream.enabled, - pollingEnabled: this.options.polling.enabled, - hasData: this.data !== undefined, - projectId: this.data?.projectId, - origin: this.data?._origin, - }); if (this.options.buildStep) { this.transition('build:loading'); await this.initializeForBuildStep(); @@ -321,11 +306,6 @@ export class Controller implements ControllerInterface { return; } - debugLog( - 'controller', - 'No data available — attempting to initialize from primary source or fallbacks', - ); - // Try the configured primary source (stream or poll, never both) if (this.options.stream.enabled) { this.transition('initializing:stream'); @@ -360,15 +340,6 @@ export class Controller implements ControllerInterface { const readMs = Date.now() - startTime; const source = originToMetricsSource(result._origin); - debugLog('controller', 'Read resolved', { - projectId: result.projectId, - mode: this.mode, - source, - origin: result._origin, - cacheStatus, - configUpdatedAt: parseConfigUpdatedAt(result.configUpdatedAt), - revision: result.revision, - }); this.trackRead(startTime, cacheHadDefinitions, isFirstRead, source); if (this.dataViewSource !== result) { @@ -451,15 +422,6 @@ export class Controller implements ControllerInterface { } const source = originToMetricsSource(result._origin); - debugLog('controller', 'Datafile resolved', { - projectId: result.projectId, - mode: this.mode, - source, - origin: result._origin, - cacheStatus, - configUpdatedAt: parseConfigUpdatedAt(result.configUpdatedAt), - revision: result.revision, - }); if (this.dataViewSource !== result) { const { _origin, ...rest } = result; @@ -559,14 +521,6 @@ export class Controller implements ControllerInterface { clearTimeout(timeoutId!); if (result === 'timeout') { - debugLog( - 'controller', - 'Stream initialization timed out; using fallback', - { - timeoutMs: this.options.stream.initTimeoutMs, - origin: this.data?._origin, - }, - ); console.warn( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', ); diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index f38bbeba..b0d63912 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,5 +1,4 @@ import type { DatafileInput } from '../types'; -import { debugLog } from '../utils/debug'; import type { NormalizedOptions } from './normalized-options'; import { connectStream, type PrimedMessage } from './stream-connection'; import { TypedEmitter } from './typed-emitter'; @@ -33,12 +32,8 @@ export class StreamSource extends TypedEmitter { * If already started, returns the existing promise. */ start(): Promise { - if (this.promise) { - debugLog('stream-source', 'Reusing stream connection'); - return this.promise; - } + if (this.promise) return this.promise; - debugLog('stream-source', 'Starting stream connection'); const abortController = new AbortController(); this.abortController = abortController; @@ -67,42 +62,22 @@ export class StreamSource extends TypedEmitter { }, { onDatafile: (newData) => { - debugLog('stream-source', 'Connected with datafile', { - projectId: newData.projectId, - configUpdatedAt: Number(newData.configUpdatedAt), - revision: newData.revision, - }); this.emit('data', newData); this.emit('connected'); }, onPrimed: (message) => { - debugLog('stream-source', 'Connected with current revision', { - projectId: message.projectId, - revision: message.revision, - }); this.emit('primed', message); this.emit('connected'); }, onDisconnect: () => { - debugLog('stream-source', 'Disconnected', { - aborted: abortController.signal.aborted, - }); this.emit('disconnected'); }, }, ); - this.promise = promise.catch((error) => { - debugLog('stream-source', 'Stream initialization failed', { - aborted: abortController.signal.aborted, - }); - throw error; - }); - return this.promise; + this.promise = promise; + return promise; } catch (error) { - debugLog('stream-source', 'Stream initialization failed', { - aborted: abortController.signal.aborted, - }); this.promise = undefined; this.abortController = undefined; throw error; @@ -113,9 +88,6 @@ export class StreamSource extends TypedEmitter { * Stop the stream connection. */ stop(): void { - debugLog('stream-source', 'Stopping stream connection', { - active: this.abortController !== undefined, - }); this.abortController?.abort(); this.abortController = undefined; this.promise = undefined; diff --git a/packages/vercel-flags-core/src/utils/debug.ts b/packages/vercel-flags-core/src/utils/debug.ts deleted file mode 100644 index 3e5c6d60..00000000 --- a/packages/vercel-flags-core/src/utils/debug.ts +++ /dev/null @@ -1,16 +0,0 @@ -type DebugSource = - | 'controller' - | 'stream-source' - | 'header-source' - | 'bundled-source'; -type DebugDetails = Record; - -/** Keep debug payloads limited to operational metadata, never credentials or flags. */ -export function debugLog( - source: DebugSource, - message: string, - details: DebugDetails = {}, -): void { - if (!process.env.DEBUG?.includes('@vercel/flags-core')) return; - console.log(`@vercel/flags-core [${source}] ${message}`, details); -} From b167598b771220f85a3c97efaaec996c6a2e1600 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 16 Sep 2026 12:04:24 +0200 Subject: [PATCH 09/10] fix(flags-core): use plural flags config version headers --- .changeset/header-driven-vercel-mode.md | 2 +- packages/vercel-flags-core/README.md | 2 +- .../src/controller/header-source.test.ts | 10 +++++----- .../vercel-flags-core/src/controller/header-source.ts | 4 ++-- packages/vercel-flags-core/src/controller/index.ts | 2 +- .../src/vercel-mode.black-box.test.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 6e70a419..06155d32 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -2,4 +2,4 @@ "@vercel/flags-core": minor --- -Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-version` or `flags-config-version` request header instead of streaming or polling. Reuse fresh definitions, refresh in the background when the request version is up to 10 seconds newer than the cached configuration, and block for a refresh when the gap is larger. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. +Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-versions` or `flags-config-versions` request header instead of streaming or polling. Reuse fresh definitions, refresh in the background when the request version is up to 10 seconds newer than the cached configuration, and block for a refresh when the gap is larger. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index ca54b589..55da0bff 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -61,7 +61,7 @@ the environment used for flag evaluation. ## Configuration version headers -The header source reads `x-vercel-flags-config-version` or `flags-config-version`, +The header source reads `x-vercel-flags-config-versions` or `flags-config-versions`, with the `x-vercel-` header taking precedence when both are present. ## OpenFeature diff --git a/packages/vercel-flags-core/src/controller/header-source.test.ts b/packages/vercel-flags-core/src/controller/header-source.test.ts index d0080f5f..46b8ec94 100644 --- a/packages/vercel-flags-core/src/controller/header-source.test.ts +++ b/packages/vercel-flags-core/src/controller/header-source.test.ts @@ -11,7 +11,7 @@ vi.mock('./fetch-datafile', () => ({ fetchDatafile: vi.fn() })); const PROJECT_ID = 'prj_test'; const CURRENT_TIMESTAMP = 1_700_000_000_000; -const HEADER = 'x-vercel-flags-config-version'; +const HEADER = 'x-vercel-flags-config-versions'; function datafile(configUpdatedAt = CURRENT_TIMESTAMP): BundledDefinitions { return { @@ -89,7 +89,7 @@ describe('HeaderSource', () => { describe('header names', () => { it.each([ HEADER, - 'flags-config-version', + 'flags-config-versions', ])('reads %s', async (headerName) => { vi.mocked(getRequestContext).mockReturnValue({ ctx: {}, @@ -107,7 +107,7 @@ describe('HeaderSource', () => { ctx: {}, headers: { [HEADER]: `flags_${PROJECT_ID}=${CURRENT_TIMESTAMP}`, - 'flags-config-version': `flags_${PROJECT_ID}=${CURRENT_TIMESTAMP + 20_000}`, + 'flags-config-versions': `flags_${PROJECT_ID}=${CURRENT_TIMESTAMP + 20_000}`, }, }); const current = tagData(datafile(), 'provided'); @@ -119,8 +119,8 @@ describe('HeaderSource', () => { it.each([ 'x-vercel-edge-config-versions', 'edge-config-versions', - 'x-vercel-flags-config-versions', - 'flags-config-versions', + 'x-vercel-flags-config-version', + 'flags-config-version', ])('ignores the obsolete header %s', async (headerName) => { vi.mocked(getRequestContext).mockReturnValue({ ctx: {}, diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index df409725..83054604 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -55,8 +55,8 @@ export class HeaderSource extends TypedEmitter { const ctx = getRequestContext(); const header = - ctx.headers?.['x-vercel-flags-config-version'] ?? - ctx.headers?.['flags-config-version']; + ctx.headers?.['x-vercel-flags-config-versions'] ?? + ctx.headers?.['flags-config-versions']; if (!header) { return; diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index cfb6440b..e8eedf02 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -87,7 +87,7 @@ type State = * - Uses polling exclusively * - Same fallback chains as streaming mode * - * **Runtime - vercel mode** (request context has a matching x-vercel-flags-config-version or flags-config-version header) + * **Runtime - vercel mode** (request context has a matching x-vercel-flags-config-versions or flags-config-versions header) * - Uses the header value to determine if the current data is fresh * - Revalidates in the background if the header value is within 10 seconds of the current configUpdatedAt * - Blocking fetch if header is newer than current configUpdatedAt diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 6b443ad7..4bb3a5bc 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -15,7 +15,7 @@ vi.mock('./utils/read-bundled-definitions', () => ({ const TIMESTAMP = 1_700_000_000_000; const PROJECT_ID = 'prj_header_test'; -const HEADER = 'x-vercel-flags-config-version'; +const HEADER = 'x-vercel-flags-config-versions'; const SDK_KEY = 'vf_server_header_test'; function datafile(timestamp = TIMESTAMP, enabled = false): BundledDefinitions { @@ -109,7 +109,7 @@ afterEach(async () => { describe('Vercel mode (black-box)', () => { it.each([ HEADER, - 'flags-config-version', + 'flags-config-versions', ])('initializes and refreshes using %s', async (headerName) => { cleanupContext(); cleanupContext = setRequestContext({ From 2e03f56280f244f68311a35cd5d4a2d40983d75c Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 16 Sep 2026 13:19:47 +0200 Subject: [PATCH 10/10] op --- packages/vercel-flags-core/src/controller/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index e8eedf02..101d5713 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -468,7 +468,7 @@ export class Controller implements ControllerInterface { } if (this.data) { - if (this.mode === 'vercel') { + if (this.headerSource.isAvailable(this.data.projectId)) { const result = await this.headerSource.read(this.data); if (result) {