diff --git a/.changeset/routed-config-version-init.md b/.changeset/routed-config-version-init.md new file mode 100644 index 00000000..06fc640e --- /dev/null +++ b/.changeset/routed-config-version-init.md @@ -0,0 +1,7 @@ +--- +'@vercel/flags-core': minor +--- + +Skip the stream or first-poll initialization wait when local flag definitions cover the routed config version, while updates continue in the background. + +Read `x-vercel-edge-config-versions` from the request context, falling back to `edge-config-versions` only when the primary header is absent. Compare the first valid exact `flags_` entry with local `configUpdatedAt`, skipping invalid matches. If no valid match exists, preserve the existing wait behavior. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index edb770f6..20680dd0 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -24,6 +24,7 @@ src/ │ ├── fetch-datafile.ts # HTTP datafile fetch │ ├── tagged-data.ts # Data origin tagging types/helpers │ ├── normalized-options.ts # Option normalization +│ ├── routed-init.ts # Routed config version comparison │ └── typed-emitter.ts # Lightweight typed event emitter ├── openfeature.*.ts # OpenFeature provider ├── test-utils.ts # Shared test helpers @@ -31,6 +32,8 @@ src/ │ ├── usage-tracker.ts │ ├── sdk-keys.ts │ ├── sleep.ts +│ ├── version-header.ts # Routed version header parser +│ ├── request-context.ts # Vercel request context access │ └── read-bundled-definitions.ts └── lib/ └── report-value.ts # Flag evaluation reporting to Vercel request context @@ -119,7 +122,7 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu Key behaviors: - Bundled definitions are loaded eagerly so their revision can be sent to the stream via `X-Revision` header -- When streaming or polling is enabled and data already exists (bundled or provided), `initialize()` still waits for fresh data (stream confirmation or first poll) up to `initTimeoutMs`, then falls back to existing data on timeout +- When streaming or polling is enabled and data already exists (bundled or provided), `initialize()` still waits for fresh data (stream confirmation or first poll) up to `initTimeoutMs`, then falls back to existing data on timeout — unless the routed config version shows the existing data is already current (see [Routed Config Version](#routed-config-version)) - For offline mode with existing data, `initialize()` returns immediately - **Never stream AND poll simultaneously** - If stream reconnects while polling → stop polling @@ -188,6 +191,7 @@ pnpm test:integration `initialize()` waits for fresh data before resolving, even when bundled data or a provided datafile is available: - **Streaming**: waits for a stream message (`primed` or `datafile`) up to `initTimeoutMs` - **Polling**: waits for the first poll response up to `initTimeoutMs` +- **Exception**: it resolves immediately when a version header shows local data covers the routed version (see [Routed Config Version](#routed-config-version)). Timeout tests must not set either header for the datafile's `projectId`. This means: @@ -278,6 +282,24 @@ The Controller tags all data with its origin using `tagData(data, origin)` from - Supports multiple simultaneous clients - Necessary as we can't pass functions to `'use cache'` wrappers +### Routed Config Version + +The Controller reads `x-vercel-edge-config-versions` from the request context, +falling back to `edge-config-versions` only when the primary header is absent. +A present primary remains authoritative even if empty, invalid, or missing the +project entry. Both headers use a semicolon-separated map of store names to +millisecond timestamps, e.g. `flags_prj_123=1758000000000`. + +- `utils/version-header.ts` selects the first valid exact `flags_${projectId}` entry, skipping invalid matches. +- If local `configUpdatedAt` is **>=** the routed version, `initialize()` resolves + immediately while stream/poll updates continue in the background. The state + stays `initializing:*` until the source connects. +- Missing context, project id, or valid entry, and unusable local timestamps + preserve the existing initialization wait. +- `FLAGS_CONFIG_READ.configRoutedInit` records `immediate`, `behind`, `invalid`, + or `unknown-local`, without ids or header values. It is omitted + when no routed version applies. + ### configUpdatedAt Guard The Controller rejects incoming data (from stream or poll) if its `configUpdatedAt` is older than or equal to the current in-memory data. This prevents stale updates from overwriting newer data. Accepts the update if either side lacks a `configUpdatedAt`. diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index f9ffea41..d7f13a13 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2934,6 +2934,646 @@ describe('Controller (black-box)', () => { }); }); + // --------------------------------------------------------------------------- + // Routed config version + // --------------------------------------------------------------------------- + describe('routed config version', () => { + function setRoutedVersions( + value: string, + header = 'x-vercel-edge-config-versions', + ): () => void { + return setRequestContext({ + host: 'example.com', + [header]: value, + }); + } + + function serveSilentStream(): void { + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) { + const body = new ReadableStream({ start() {} }); + return Promise.resolve(new Response(body, { status: 200 })); + } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + } + + function lastIngestPayloads(): Record[] { + const body = fetchMock.mock.lastCall?.[1]?.body as string; + return (JSON.parse(body) as { payload: Record }[]).map( + ({ payload }) => payload, + ); + } + + function servingVariant(variant: 0 | 1) { + return { + flagA: { + environments: { production: variant }, + variants: [false, true], + }, + }; + } + + it.each([ + 'x-vercel-edge-config-versions', + 'edge-config-versions', + ])('should initialize immediately when the loaded data covers the routed version from %s', async (header) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRoutedVersions( + 'ecfg_abc=9999;flags_prj_123=2000', + header, + ); + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ configUpdatedAt: 2000 }), + }); + + let settled = false; + const initPromise = Promise.resolve(client.initialize()).then(() => { + settled = true; + }); + // Flush microtasks without reaching the 3s stream init timeout. + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(true); + await initPromise; + + expect(warnSpy).not.toHaveBeenCalled(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: { ...streamRequestHeaders, 'X-Revision': '1' }, + signal: expect.any(AbortSignal), + }, + ); + + // The connection is not confirmed yet, so it must not be reported. + const before = await client.evaluate('flagA'); + expect(before.value).toBe(true); + expect(before.metrics?.source).toBe('in-memory'); + expect(before.metrics?.cacheStatus).toBe('STALE'); + expect(before.metrics?.connectionState).toBe('disconnected'); + expect(before.metrics?.mode).toBe('offline'); + + stream.push({ + type: 'primed', + revision: 1, + projectId: 'prj_123', + environment: 'production', + }); + await vi.advanceTimersByTimeAsync(0); + + const after = await client.evaluate('flagA'); + expect(after.metrics?.connectionState).toBe('connected'); + expect(after.metrics?.mode).toBe('streaming'); + + warnSpy.mockRestore(); + stream.close(); + await client.shutdown(); + cleanupCtx(); + }); + + it.each([ + 'flags_prj_123=2000', + 'flags_prj_123=2000;flags_prj_123=2000', + 'flags_prj_123=later;flags_prj_123=2000;flags_prj_123=3000', + ])('should initialize immediately when local data equals the first valid match (%s)', async (header) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRoutedVersions(header); + serveSilentStream(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ configUpdatedAt: 2000 }), + }); + + let settled = false; + const initPromise = Promise.resolve(client.initialize()).then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(true); + await initPromise; + + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + await client.shutdown(); + cleanupCtx(); + }); + + it('should initialize immediately from bundled definitions', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled({ configUpdatedAt: 2000 }), + }); + const cleanupCtx = setRoutedVersions('flags_prj_123=1999'); + serveSilentStream(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + + let settled = false; + const initPromise = Promise.resolve(client.initialize()).then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(true); + await initPromise; + + const result = await client.evaluate('flagA'); + expect(result.value).toBe(true); + expect(result.metrics?.source).toBe('embedded'); + expect(result.metrics?.connectionState).toBe('disconnected'); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + await client.shutdown(); + cleanupCtx(); + }); + + it('should initialize immediately in polling mode and keep polling in the background', async () => { + const cleanupCtx = setRoutedVersions('flags_prj_123=2000'); + + let resolveFirstPoll: (response: Response) => void = () => {}; + const firstPoll = new Promise((resolve) => { + resolveFirstPoll = resolve; + }); + const polled = makeBundled({ + configUpdatedAt: 3000, + definitions: servingVariant(0), + }); + + let pollCount = 0; + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/datafile')) { + pollCount++; + return pollCount === 1 + ? firstPoll + : Promise.resolve(Response.json(polled)); + } + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: { intervalMs: 30_000, initTimeoutMs: 3000 }, + datafile: makeBundled({ + configUpdatedAt: 2000, + definitions: servingVariant(1), + }), + }); + + let settled = false; + const initPromise = Promise.resolve(client.initialize()).then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(true); + await initPromise; + + expect(pollCount).toBe(1); + const before = await client.evaluate('flagA'); + expect(before.value).toBe(true); + expect(before.metrics?.connectionState).toBe('disconnected'); + + resolveFirstPoll(Response.json(polled)); + await vi.advanceTimersByTimeAsync(0); + const after = await client.evaluate('flagA'); + expect(after.value).toBe(false); + + await vi.advanceTimersByTimeAsync(30_000); + expect(pollCount).toBe(2); + + await client.shutdown(); + cleanupCtx(); + }); + + it('should not replace immediately initialized data with equal or older data', async () => { + const cleanupCtx = setRoutedVersions('flags_prj_123=2000'); + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ + configUpdatedAt: 2000, + definitions: servingVariant(1), + }), + }); + + await client.initialize(); + + stream.push({ + type: 'datafile', + data: makeBundled({ + configUpdatedAt: 2000, + definitions: servingVariant(0), + }), + }); + await vi.advanceTimersByTimeAsync(0); + expect((await client.evaluate('flagA')).value).toBe(true); + + stream.push({ + type: 'datafile', + data: makeBundled({ + configUpdatedAt: 1999, + definitions: servingVariant(0), + }), + }); + await vi.advanceTimersByTimeAsync(0); + expect((await client.evaluate('flagA')).value).toBe(true); + + stream.push({ + type: 'datafile', + data: makeBundled({ + configUpdatedAt: 2001, + definitions: servingVariant(0), + }), + }); + await vi.advanceTimersByTimeAsync(0); + expect((await client.evaluate('flagA')).value).toBe(false); + + stream.close(); + await client.shutdown(); + cleanupCtx(); + }); + + it.each([ + ['the header is empty', ''], + ['the project has no entry', 'ecfg_abc=1000;flags_prj_999=1000'], + [ + 'the entry key only overlaps', + 'flags_prj_1234=1000;xflags_prj_123=1000', + ], + ['the version is malformed', 'flags_prj_123=later'], + ['the version is negative', 'flags_prj_123=-1'], + ['the version is fractional', 'flags_prj_123=1000.5'], + ['the version is unsafe', 'flags_prj_123=9007199254740993'], + [ + 'all matching entries are invalid', + 'flags_prj_123=later;flags_prj_123=-1', + ], + [ + 'the first valid match is newer', + 'flags_prj_123=later;flags_prj_123=3000;flags_prj_123=1000', + ], + [ + 'the primary is empty despite a valid fallback', + '', + 'flags_prj_123=1000', + ], + [ + 'the primary has no project entry despite a valid fallback', + 'flags_prj_999=1000', + 'flags_prj_123=1000', + ], + [ + 'the primary is invalid despite a valid fallback', + 'flags_prj_123=later', + 'flags_prj_123=1000', + ], + [ + 'the primary is newer despite an older fallback', + 'flags_prj_123=3000', + 'flags_prj_123=1000', + ], + ])('should keep waiting for the stream when %s', async (_label, headerValue, fallback?: string) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRequestContext({ + host: 'example.com', + 'x-vercel-edge-config-versions': headerValue, + ...(fallback === undefined ? {} : { 'edge-config-versions': fallback }), + }); + serveSilentStream(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ configUpdatedAt: 2000 }), + }); + + let settled = false; + const initPromise = Promise.resolve(client.initialize()).then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(3000); + await initPromise; + expect(settled).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + + warnSpy.mockRestore(); + await client.shutdown(); + cleanupCtx(); + }); + + it('should keep waiting for the stream when the routed version is newer', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRoutedVersions('flags_prj_123=2001'); + serveSilentStream(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ configUpdatedAt: 2000 }), + }); + + let settled = false; + const initPromise = Promise.resolve(client.initialize()).then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(3000); + await initPromise; + expect(settled).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + + warnSpy.mockRestore(); + await client.shutdown(); + cleanupCtx(); + }); + + it('should keep waiting for the stream without a request context', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + serveSilentStream(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ configUpdatedAt: 2000 }), + }); + + let settled = false; + const initPromise = Promise.resolve(client.initialize()).then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(3000); + await initPromise; + expect(settled).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + + warnSpy.mockRestore(); + await client.shutdown(); + }); + + it('should keep waiting for the stream when the loaded data has no project id', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRoutedVersions('flags_prj_123=1000;flags_=1000'); + serveSilentStream(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ configUpdatedAt: 2000, projectId: '' }), + }); + + let settled = false; + const initPromise = Promise.resolve(client.initialize()).then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(3000); + await initPromise; + expect(settled).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + + warnSpy.mockRestore(); + await client.shutdown(); + cleanupCtx(); + }); + + it('should keep waiting for the stream when the loaded data has no configUpdatedAt', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRoutedVersions('flags_prj_123=1000'); + serveSilentStream(); + + const datafile = makeBundled(); + delete (datafile as Record).configUpdatedAt; + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile, + }); + + let settled = false; + const initPromise = Promise.resolve(client.initialize()).then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(3000); + await initPromise; + expect(settled).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + + warnSpy.mockRestore(); + await client.shutdown(); + cleanupCtx(); + }); + + it('should report the immediate outcome without ids or header values', async () => { + const cleanupCtx = setRoutedVersions('flags_prj_123=2000'); + serveSilentStream(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ configUpdatedAt: 2000 }), + }); + + await client.initialize(); + await client.evaluate('flagA'); + await client.shutdown(); + + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + invocationHost: 'example.com', + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 2000, + mode: 'offline', + revision: '1', + configRoutedInit: 'immediate', + environment: 'production', + }, + }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: undefined, + reason: 'paused', + evaluationCount: 1, + periodStartedAt: minuteBucketTs(date.getTime()), + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); + + const body = fetchMock.mock.lastCall?.[1]?.body as string; + expect(body).not.toContain('prj_123'); + expect(body).not.toContain('flags_prj_123=2000'); + + cleanupCtx(); + }); + + it.each([ + ['behind', 'flags_prj_123=2001'], + ['invalid', 'flags_prj_123=later'], + ['behind', 'flags_prj_123=later;flags_prj_123=3000;flags_prj_123=1000'], + ['invalid', 'flags_prj_123=later;flags_prj_123=-1'], + ])('should report the %s outcome', async (outcome, headerValue) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRoutedVersions(headerValue); + serveSilentStream(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ configUpdatedAt: 2000 }), + }); + + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(3000); + await initPromise; + + await client.evaluate('flagA'); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + warnSpy.mockRestore(); + await client.shutdown(); + + expect(lastIngestPayloads()[0]).toMatchObject({ + configRoutedInit: outcome, + }); + + cleanupCtx(); + }); + + it('should report the unknown-local outcome', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRoutedVersions('flags_prj_123=2000'); + serveSilentStream(); + + const datafile = makeBundled(); + delete (datafile as Record).configUpdatedAt; + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile, + }); + + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(3000); + await initPromise; + + await client.evaluate('flagA'); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + warnSpy.mockRestore(); + await client.shutdown(); + + expect(lastIngestPayloads()[0]).toMatchObject({ + configRoutedInit: 'unknown-local', + }); + + cleanupCtx(); + }); + + it('should not report an outcome when no routed version applies', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRequestContext({ host: 'example.com' }); + serveSilentStream(); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + datafile: makeBundled({ configUpdatedAt: 2000 }), + }); + + const initPromise = client.initialize(); + await vi.advanceTimersByTimeAsync(3000); + await initPromise; + + await client.evaluate('flagA'); + expect(warnSpy).toHaveBeenCalledWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + warnSpy.mockRestore(); + await client.shutdown(); + + expect(lastIngestPayloads()[0]).not.toHaveProperty('configRoutedInit'); + + cleanupCtx(); + }); + }); + // --------------------------------------------------------------------------- // Evaluate behavior // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 5f55c126..7c813d99 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -17,6 +17,7 @@ import { normalizeOptions, } from './normalized-options'; import { PollingSource } from './polling-source'; +import { decideRoutedInit, type RoutedInitOutcome } from './routed-init'; import { UnauthorizedError } from './stream-connection'; import { StreamSource } from './stream-source'; import { originToMetricsSource, type TaggedData, tagData } from './tagged-data'; @@ -120,6 +121,8 @@ export class Controller implements ControllerInterface { // Suppresses usage tracking when the SDK key is unauthorized private unauthorized = false; + private routedInitOutcome: RoutedInitOutcome | undefined; + constructor(options: ControllerOptions) { this.options = normalizeOptions(options); @@ -267,13 +270,22 @@ export class Controller implements ControllerInterface { // If we already have data (from provided datafile or bundled definitions), // start updates. Both streaming and polling wait for initial data before // being considered initialized, so we know we have fresh data. + // Skip the wait if local data already covers the routed version. // For no-updates (offline), return immediately since we already have usable data. if (this.data) { if (this.options.stream.enabled) { this.transition('initializing:stream'); + if (this.canInitializeFromLocalData()) { + this.startStreamInBackground(); + return; + } await this.tryInitializeStream(); } else if (this.options.polling.enabled) { this.transition('initializing:polling'); + if (this.canInitializeFromLocalData()) { + this.startPollingInBackground(); + return; + } await this.tryInitializePolling(); } else { this.transition('degraded'); @@ -449,6 +461,45 @@ export class Controller implements ControllerInterface { return this.resolveDataWithFallbacks(); } + // --------------------------------------------------------------------------- + // Routed config version + // --------------------------------------------------------------------------- + + private canInitializeFromLocalData(): boolean { + if (!this.data) return false; + + const decision = decideRoutedInit({ + projectId: this.data.projectId, + configUpdatedAt: this.data.configUpdatedAt, + }); + this.routedInitOutcome = decision.outcome; + + return decision.immediate; + } + + // Keep the initializing state until the stream emits connected. + private startStreamInBackground(): void { + try { + void this.streamSource.start().catch((error) => { + // Source events handle connection state; suppress usage for invalid keys. + if ( + error instanceof UnauthorizedError || + (error instanceof Error && error.message.includes('401')) + ) { + this.unauthorized = true; + } + }); + } catch { + // Local data covers this request even if starting the stream fails. + } + } + + // Start the interval first so stop() can abort the immediate poll. + private startPollingInBackground(): void { + this.pollingSource.startInterval(); + void this.pollingSource.poll(); + } + // --------------------------------------------------------------------------- // Stream initialization // --------------------------------------------------------------------------- @@ -814,6 +865,9 @@ export class Controller implements ControllerInterface { if (isFirstRead) { trackOptions.cacheIsFirstRead = true; } + if (this.routedInitOutcome !== undefined) { + trackOptions.configRoutedInit = this.routedInitOutcome; + } this.usageTracker.trackRead(trackOptions); } diff --git a/packages/vercel-flags-core/src/controller/routed-init.test.ts b/packages/vercel-flags-core/src/controller/routed-init.test.ts new file mode 100644 index 00000000..51ca0f20 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/routed-init.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { setRequestContext } from '../test-utils'; +import { decideRoutedInit } from './routed-init'; + +const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); + +function setRoutedVersions(value: string): () => void { + return setRequestContext({ + host: 'example.com', + 'x-vercel-edge-config-versions': value, + }); +} + +describe('decideRoutedInit', () => { + afterEach(() => { + delete (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT]; + }); + + describe('no decision (preserves existing behavior)', () => { + it('should not decide without a request context', () => { + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: false, outcome: undefined }); + }); + + it('should not decide when the request context has no headers', () => { + (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT] = { get: () => ({}) }; + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: false, outcome: undefined }); + }); + + it('should not decide when the header is absent', () => { + const cleanup = setRequestContext({ host: 'example.com' }); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: false, outcome: undefined }); + + cleanup(); + }); + + it('should not decide when the header is empty', () => { + const cleanup = setRoutedVersions(''); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: false, outcome: undefined }); + + cleanup(); + }); + + it('should not decide when the project has no entry', () => { + const cleanup = setRoutedVersions('ecfg_abc=3000;flags_prj_999=3000'); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: false, outcome: undefined }); + + cleanup(); + }); + + it.each([ + undefined, + null, + '', + 123, + ])('should not decide without a usable project id (%p)', (projectId) => { + const cleanup = setRoutedVersions('flags_prj_123=1000;flags_=1000'); + + expect(decideRoutedInit({ projectId, configUpdatedAt: 2000 })).toEqual({ + immediate: false, + outcome: undefined, + }); + + cleanup(); + }); + + it('should not decide when reading the request context throws', () => { + (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT] = { + get: () => { + throw new Error('boom'); + }, + }; + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: false, outcome: undefined }); + }); + }); + + describe('header selection', () => { + it.each([ + ['flags_prj_123=1000', true, 'immediate'], + ['flags_prj_123=3000', false, 'behind'], + ['flags_prj_123=later', false, 'invalid'], + ])('should use the fallback when the primary is absent (%s)', (value, immediate, outcome) => { + const cleanup = setRequestContext({ + host: 'example.com', + 'edge-config-versions': value, + }); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate, outcome }); + + cleanup(); + }); + + it.each([ + ['flags_prj_123=1000', 'flags_prj_123=3000', true, 'immediate'], + ['flags_prj_123=3000', 'flags_prj_123=1000', false, 'behind'], + ['flags_prj_123=later', 'flags_prj_123=1000', false, 'invalid'], + [ + 'flags_prj_123=9007199254740993', + 'flags_prj_123=1000', + false, + 'invalid', + ], + [ + 'flags_prj_123=1000;flags_prj_123=1000', + 'flags_prj_123=1000', + true, + 'immediate', + ], + ['flags_prj_999=1000', 'flags_prj_123=1000', false, undefined], + ['flags_prj_123', 'flags_prj_123=1000', false, undefined], + ['', 'flags_prj_123=1000', false, undefined], + ])('should honor a present primary header (%s) over fallback (%s)', (primary, fallback, immediate, outcome) => { + const cleanup = setRequestContext({ + host: 'example.com', + 'x-vercel-edge-config-versions': primary, + 'edge-config-versions': fallback, + }); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate, outcome }); + + cleanup(); + }); + }); + + describe('comparison', () => { + it('should initialize immediately when local data is newer', () => { + const cleanup = setRoutedVersions('flags_prj_123=2000'); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2001 }), + ).toEqual({ immediate: true, outcome: 'immediate' }); + + cleanup(); + }); + + it('should initialize immediately when local data is equal', () => { + const cleanup = setRoutedVersions('flags_prj_123=2000'); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: true, outcome: 'immediate' }); + + cleanup(); + }); + + it('should accept a numeric string as local timestamp', () => { + const cleanup = setRoutedVersions('flags_prj_123=2000'); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: ' 2000 ' }), + ).toEqual({ immediate: true, outcome: 'immediate' }); + + cleanup(); + }); + + it('should wait when local data is behind', () => { + const cleanup = setRoutedVersions('flags_prj_123=2001'); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: false, outcome: 'behind' }); + + cleanup(); + }); + + it('should only compare against the entry of the own project', () => { + const cleanup = setRoutedVersions( + 'flags_prj_999=9999;flags_prj_123=2000;ecfg_abc=9999', + ); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: true, outcome: 'immediate' }); + + cleanup(); + }); + }); + + describe('unsafe values', () => { + it.each([ + '', + 'later', + '-1', + '1.5', + '1e3', + '9007199254740993', + ])('should wait for a malformed routed version (%p)', (version) => { + const cleanup = setRoutedVersions(`flags_prj_123=${version}`); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: false, outcome: 'invalid' }); + + cleanup(); + }); + + it.each([ + ['flags_prj_123=1000;flags_prj_123=1000', true, 'immediate'], + [ + 'flags_prj_123=later;flags_prj_123=1000;flags_prj_123=3000', + true, + 'immediate', + ], + [ + 'flags_prj_123=later;flags_prj_123=3000;flags_prj_123=1000', + false, + 'behind', + ], + ['flags_prj_123=later;flags_prj_123=-1', false, 'invalid'], + ])('should use the first valid match when entries repeat (%s)', (header, immediate, outcome) => { + const cleanup = setRoutedVersions(header); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate, outcome }); + + cleanup(); + }); + + it.each([ + undefined, + null, + 'later', + '', + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 2, + ])('should wait for an unusable local timestamp (%p)', (configUpdatedAt) => { + const cleanup = setRoutedVersions('flags_prj_123=1000'); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt }), + ).toEqual({ immediate: false, outcome: 'unknown-local' }); + + cleanup(); + }); + }); +}); diff --git a/packages/vercel-flags-core/src/controller/routed-init.ts b/packages/vercel-flags-core/src/controller/routed-init.ts new file mode 100644 index 00000000..a2497e92 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/routed-init.ts @@ -0,0 +1,77 @@ +import { getRequestContext } from '../utils/request-context'; +import { + FALLBACK_VERSION_HEADER, + flagsConfigVersionKey, + parseConfigVersion, + selectConfigVersion, + VERSION_HEADER, +} from '../utils/version-header'; + +/** Low-cardinality metric outcome; never includes ids or header values. */ +export type RoutedInitOutcome = + | 'immediate' + | 'behind' + | 'invalid' + | 'unknown-local'; + +export type RoutedInitDecision = { + immediate: boolean; + /** Omitted when no routed version applies to this project. */ + outcome: RoutedInitOutcome | undefined; +}; + +const NO_DECISION: RoutedInitDecision = { + immediate: false, + outcome: undefined, +}; + +function parseLocalTimestamp(value: unknown): number | undefined { + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value >= 0 ? value : undefined; + } + if (typeof value === 'string') { + return parseConfigVersion(value.trim()); + } + return undefined; +} + +/** Skips the init wait only when local definitions cover the routed version. */ +export function decideRoutedInit(data: { + projectId: unknown; + configUpdatedAt: unknown; +}): RoutedInitDecision { + try { + const projectId = data.projectId; + if (typeof projectId !== 'string' || projectId === '') return NO_DECISION; + + const { ctx, headers } = getRequestContext(); + if (!ctx || !headers) return NO_DECISION; + + // A present primary header is authoritative, even if its entry is unusable. + const routed = selectConfigVersion( + headers[VERSION_HEADER] ?? headers[FALLBACK_VERSION_HEADER], + flagsConfigVersionKey(projectId), + ); + + switch (routed.status) { + case 'not-found': + return NO_DECISION; + case 'invalid': + return { immediate: false, outcome: 'invalid' }; + case 'found': + break; + } + + const local = parseLocalTimestamp(data.configUpdatedAt); + if (local === undefined) { + return { immediate: false, outcome: 'unknown-local' }; + } + + return local >= routed.version + ? { immediate: true, outcome: 'immediate' } + : { immediate: false, outcome: 'behind' }; + } catch { + // Never let the check itself break initialization. + return NO_DECISION; + } +} 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..d52dde7f 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 @@ -1,3 +1,4 @@ +import type { RoutedInitOutcome } from '../../controller/routed-init'; import type { UsageEvent } from './events'; export interface TrackReadOptions { @@ -19,6 +20,8 @@ export interface TrackReadOptions { mode?: 'poll' | 'stream' | 'build' | 'offline'; /** Revision of the config */ revision?: number; + /** Init comparison outcome; omitted when no routed version applies. */ + configRoutedInit?: RoutedInitOutcome; } export class FlagsConfigReadEvent implements UsageEvent { @@ -39,6 +42,7 @@ export class FlagsConfigReadEvent implements UsageEvent { mode?: 'poll' | 'stream' | 'build' | 'offline'; revision?: string; environment?: string; + configRoutedInit?: RoutedInitOutcome; }; constructor( @@ -81,6 +85,9 @@ export class FlagsConfigReadEvent implements UsageEvent { if (options.revision !== undefined) { this.payload.revision = String(options.revision); } + if (options.configRoutedInit !== undefined) { + this.payload.configRoutedInit = options.configRoutedInit; + } } const environment = diff --git a/packages/vercel-flags-core/src/utils/version-header.test.ts b/packages/vercel-flags-core/src/utils/version-header.test.ts new file mode 100644 index 00000000..54c94bae --- /dev/null +++ b/packages/vercel-flags-core/src/utils/version-header.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { + FALLBACK_VERSION_HEADER, + flagsConfigVersionKey, + parseConfigVersion, + selectConfigVersion, + VERSION_HEADER, +} from './version-header'; + +describe('version headers', () => { + it('should use the lower cased request header names', () => { + expect(VERSION_HEADER).toBe('x-vercel-edge-config-versions'); + expect(FALLBACK_VERSION_HEADER).toBe('edge-config-versions'); + }); +}); + +describe('flagsConfigVersionKey', () => { + it('should derive the key from the project id', () => { + expect(flagsConfigVersionKey('prj_123')).toBe('flags_prj_123'); + }); +}); + +describe('parseConfigVersion', () => { + it('should parse non-negative integers', () => { + expect(parseConfigVersion('0')).toBe(0); + expect(parseConfigVersion('1758000000000')).toBe(1758000000000); + expect(parseConfigVersion(String(Number.MAX_SAFE_INTEGER))).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + it('should reject malformed values', () => { + expect(parseConfigVersion('')).toBeUndefined(); + expect(parseConfigVersion('abc')).toBeUndefined(); + expect(parseConfigVersion('12abc')).toBeUndefined(); + expect(parseConfigVersion('1 2')).toBeUndefined(); + expect(parseConfigVersion('-1')).toBeUndefined(); + expect(parseConfigVersion('+1')).toBeUndefined(); + expect(parseConfigVersion('1.5')).toBeUndefined(); + expect(parseConfigVersion('1e3')).toBeUndefined(); + expect(parseConfigVersion('0x10')).toBeUndefined(); + expect(parseConfigVersion('NaN')).toBeUndefined(); + expect(parseConfigVersion('Infinity')).toBeUndefined(); + }); + + it('should reject values outside the safe integer range', () => { + expect(parseConfigVersion('9007199254740993')).toBeUndefined(); + expect(parseConfigVersion('1'.repeat(30))).toBeUndefined(); + }); +}); + +describe('selectConfigVersion', () => { + const key = flagsConfigVersionKey('prj_123'); + + it('should select the exact entry', () => { + expect(selectConfigVersion('flags_prj_123=1758000000000', key)).toEqual({ + status: 'found', + version: 1758000000000, + }); + }); + + it('should select the exact entry from a map of stores', () => { + expect( + selectConfigVersion( + 'ecfg_abc=1757000000000;flags_prj_123=1758000000000;ecfg_def=1', + key, + ), + ).toEqual({ status: 'found', version: 1758000000000 }); + }); + + it('should ignore surrounding whitespace and empty segments', () => { + expect( + selectConfigVersion( + ' ecfg_abc=1 ; flags_prj_123 = 1758000000000 ;;', + key, + ), + ).toEqual({ status: 'found', version: 1758000000000 }); + }); + + it('should not match keys that merely contain the derived key', () => { + expect( + selectConfigVersion( + 'flags_prj_1234=1;xflags_prj_123=2;flags_prj_12=3;flags_prj_123x=4', + key, + ), + ).toEqual({ status: 'not-found' }); + }); + + it('should be case sensitive', () => { + expect(selectConfigVersion('FLAGS_PRJ_123=1758000000000', key)).toEqual({ + status: 'not-found', + }); + }); + + it('should report not-found for a missing header', () => { + expect(selectConfigVersion(undefined, key)).toEqual({ + status: 'not-found', + }); + expect(selectConfigVersion('', key)).toEqual({ status: 'not-found' }); + }); + + it('should report not-found for an empty key', () => { + expect(selectConfigVersion('flags_=1758000000000', '')).toEqual({ + status: 'not-found', + }); + }); + + it('should ignore segments without a separator', () => { + expect(selectConfigVersion('flags_prj_123;ecfg_abc', key)).toEqual({ + status: 'not-found', + }); + }); + + it('should report invalid for a malformed version', () => { + expect(selectConfigVersion('flags_prj_123=', key)).toEqual({ + status: 'invalid', + }); + expect(selectConfigVersion('flags_prj_123=later', key)).toEqual({ + status: 'invalid', + }); + expect(selectConfigVersion('flags_prj_123=-1', key)).toEqual({ + status: 'invalid', + }); + expect(selectConfigVersion('flags_prj_123=9007199254740993', key)).toEqual({ + status: 'invalid', + }); + }); + + it('should keep the value of an entry containing separators', () => { + // Only the first `=` separates key from value. + expect(selectConfigVersion('flags_prj_123=1=2', key)).toEqual({ + status: 'invalid', + }); + }); + + it.each([ + ['flags_prj_123=1;flags_prj_123=2', 1], + ['flags_prj_123=2;flags_prj_123=1', 2], + ['flags_prj_123=1;flags_prj_123=1', 1], + ['flags_prj_123=nope;flags_prj_123=2', 2], + ['flags_prj_123=2;flags_prj_123=nope', 2], + [ + 'flags_prj_123=;flags_prj_123=-1;flags_prj_123=9007199254740993;flags_prj_123=0;flags_prj_123=2', + 0, + ], + ])('should select the first valid match in %s', (header, version) => { + expect(selectConfigVersion(header, key)).toEqual({ + status: 'found', + version, + }); + }); + + it('should report invalid when all matching entries are invalid', () => { + expect( + selectConfigVersion( + 'flags_prj_123=nope;flags_prj_999=2;flags_prj_123=-1', + key, + ), + ).toEqual({ status: 'invalid' }); + }); +}); diff --git a/packages/vercel-flags-core/src/utils/version-header.ts b/packages/vercel-flags-core/src/utils/version-header.ts new file mode 100644 index 00000000..2d52b896 --- /dev/null +++ b/packages/vercel-flags-core/src/utils/version-header.ts @@ -0,0 +1,44 @@ +export const VERSION_HEADER = 'x-vercel-edge-config-versions'; +export const FALLBACK_VERSION_HEADER = 'edge-config-versions'; + +export type ConfigVersionLookup = + | { status: 'found'; version: number } + | { status: 'not-found' } + | { status: 'invalid' }; + +const DIGITS = /^\d+$/; + +export function flagsConfigVersionKey(projectId: string): string { + return `flags_${projectId}`; +} + +/** Only non-negative safe integers can be compared reliably as timestamps. */ +export function parseConfigVersion(value: string): number | undefined { + if (!DIGITS.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +/** Selects the first valid exact-key match from a semicolon-separated map. */ +export function selectConfigVersion( + headerValue: string | undefined, + key: string, +): ConfigVersionLookup { + if (!headerValue || !key) return { status: 'not-found' }; + + let match: ConfigVersionLookup | undefined; + + for (const segment of headerValue.split(';')) { + const separatorIndex = segment.indexOf('='); + if (separatorIndex === -1) continue; + if (segment.slice(0, separatorIndex).trim() !== key) continue; + + const version = parseConfigVersion( + segment.slice(separatorIndex + 1).trim(), + ); + if (version !== undefined) return { status: 'found', version }; + match = { status: 'invalid' }; + } + + return match ?? { status: 'not-found' }; +}