diff --git a/.changeset/routed-config-version-init.md b/.changeset/routed-config-version-init.md new file mode 100644 index 00000000..bc6c8ccb --- /dev/null +++ b/.changeset/routed-config-version-init.md @@ -0,0 +1,9 @@ +--- +'@vercel/flags-core': minor +--- + +Skip waiting for a stream confirmation or first poll when the loaded flag definitions already cover the config version the request was routed to. + +The `x-vercel-edge-config-versions` request header carries a semicolon-separated map of store name to version. The client reads it from the existing Vercel request context, looks up the `flags_` entry derived from the loaded definitions, and — when the local `configUpdatedAt` is at or ahead of that version — resolves `initialize()` right away while the stream or poll keeps updating in the background. No new header or config id is involved. + +Everything else keeps the previous behavior: a missing request context, a project without an entry, a malformed or unsafe version, a duplicated entry, or definitions without a usable `configUpdatedAt` all wait for the stream or first poll as before. The client never reports a connection before it exists, and background updates still cannot replace newer definitions with equal or older ones. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index edb770f6..1636523d 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 +│ ├── edge-config-versions.ts # x-vercel-edge-config-versions 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 the `x-vercel-edge-config-versions` request context header shows the local data already covers the routed version (see [Routed Config Version](#routed-config-version)). Tests that rely on the timeout must not set that header for the datafile's `projectId`. This means: @@ -278,6 +282,35 @@ 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 + +Vercel attaches an `x-vercel-edge-config-versions` request header describing +which config version the request was routed to. It is a semicolon-separated map +of store name to version (a millisecond timestamp), e.g. +`flags_prj_123=1758000000000;ecfg_abc=1757000000000`. + +After local data is loaded (provided datafile or bundled definitions) but +before awaiting the stream or first poll, the Controller compares that version +against the local `configUpdatedAt`: + +- The header is read from the **existing** Vercel request context + (`utils/request-context.ts`) — no extra header is requested and no config id + is involved +- The map key is derived from the loaded data as `flags_${projectId}`; only an + exact key match counts (`utils/edge-config-versions.ts`) +- When the local `configUpdatedAt` is **>=** the routed version, `initialize()` + resolves immediately and the stream/poll keeps running in the background +- The state stays `initializing:*` until the source actually connects, so reads + never report `connected` before a connection exists +- Everything else preserves the previous behavior (wait up to `initTimeoutMs`): + no request context, no project id, no exact entry, a malformed or unsafe + version (non-integer, negative, beyond `Number.MAX_SAFE_INTEGER`), a + duplicated key, or local data without a usable `configUpdatedAt` +- The outcome is attached to `FLAGS_CONFIG_READ` events as `configRoutedInit` + (`immediate`, `behind`, `invalid`, `duplicate`, `unknown-local`) — a low + cardinality enum that never contains ids or header values, and is omitted when + no routed version applied + ### 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..acc121ac 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2934,6 +2934,616 @@ describe('Controller (black-box)', () => { }); }); + // --------------------------------------------------------------------------- + // Routed config version (x-vercel-edge-config-versions) + // --------------------------------------------------------------------------- + describe('routed config version', () => { + /** Request context carrying the routed config versions header. */ + function setRoutedVersions(value: string): () => void { + return setRequestContext({ + host: 'example.com', + 'x-vercel-edge-config-versions': value, + }); + } + + /** Serves a stream that connects but never sends a message. */ + 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}`)); + }); + } + + /** Reads the payloads of the last ingest request. */ + function lastIngestPayloads(): Record[] { + const body = fetchMock.mock.lastCall?.[1]?.body as string; + return (JSON.parse(body) as { payload: Record }[]).map( + ({ payload }) => payload, + ); + } + + /** Flag definition serving variant index `variant`. */ + function servingVariant(variant: 0 | 1) { + return { + flagA: { + environments: { production: variant }, + variants: [false, true], + }, + }; + } + + it('should initialize immediately when the loaded data covers the routed version', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRoutedVersions('ecfg_abc=9999;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 }), + }); + + 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; + + // No fallback warning — nothing timed out. + expect(warnSpy).not.toHaveBeenCalled(); + + // The stream is connecting in the background, with the local revision. + 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'); + + // Once the stream confirms the revision, the client reports connected. + 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('should initialize immediately when the loaded data equals the routed version', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cleanupCtx = setRoutedVersions('flags_prj_123=2000'); + 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; + + // A poll was started but has not answered yet — the local data is served + // and no connection is claimed. + expect(pollCount).toBe(1); + const before = await client.evaluate('flagA'); + expect(before.value).toBe(true); + expect(before.metrics?.connectionState).toBe('disconnected'); + + // The background poll updates the data once it answers. + resolveFirstPoll(Response.json(polled)); + await vi.advanceTimersByTimeAsync(0); + const after = await client.evaluate('flagA'); + expect(after.value).toBe(false); + + // The interval keeps refreshing. + 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(); + + // Equal configUpdatedAt — must not replace the loaded data. + stream.push({ + type: 'datafile', + data: makeBundled({ + configUpdatedAt: 2000, + definitions: servingVariant(0), + }), + }); + await vi.advanceTimersByTimeAsync(0); + expect((await client.evaluate('flagA')).value).toBe(true); + + // Older configUpdatedAt — must not replace the loaded data either. + stream.push({ + type: 'datafile', + data: makeBundled({ + configUpdatedAt: 1999, + definitions: servingVariant(0), + }), + }); + await vi.advanceTimersByTimeAsync(0); + expect((await client.evaluate('flagA')).value).toBe(true); + + // Newer data is applied. + 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'], + ['the entry is duplicated', 'flags_prj_123=2000;flags_prj_123=2000'], + ])('should keep waiting for the stream when %s', async (_label, 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 }), + }); + + 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', + }, + ); + + // Neither the project id nor the header value is ever ingested. + 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'], + ['duplicate', 'flags_prj_123=2000;flags_prj_123=2000'], + ])('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..a8ad68ff 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,10 @@ export class Controller implements ControllerInterface { // Suppresses usage tracking when the SDK key is unauthorized private unauthorized = false; + // Outcome of the routed config version check performed during + // initialization. Metrics only — undefined when no routed version applied. + private routedInitOutcome: RoutedInitOutcome | undefined; + constructor(options: ControllerOptions) { this.options = normalizeOptions(options); @@ -267,13 +272,25 @@ 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. + // Exception: when the config version this request was routed to is already + // covered by the local data, waiting cannot yield anything newer, so + // initialization completes right away and updates continue in the + // background. // 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 +466,66 @@ export class Controller implements ControllerInterface { return this.resolveDataWithFallbacks(); } + // --------------------------------------------------------------------------- + // Routed config version + // --------------------------------------------------------------------------- + + /** + * Checks whether the already loaded data covers the config version this + * request was routed to, in which case initialization does not have to wait + * for a stream confirmation or a first poll. + * + * Records the low cardinality outcome for metrics as a side effect. + */ + 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; + } + + /** + * Starts streaming without waiting for the first message. + * + * The state stays `initializing:stream` until the connection is actually + * established, so reads keep reporting `disconnected` until the stream + * emits `connected`. + */ + private startStreamInBackground(): void { + try { + void this.streamSource.start().catch((error) => { + // The connection reports itself through events; only remember an + // invalid SDK key so usage tracking stays suppressed. + if ( + error instanceof UnauthorizedError || + (error instanceof Error && error.message.includes('401')) + ) { + this.unauthorized = true; + } + }); + } catch { + // Starting the stream failed outright. Initialization still succeeds, + // like it does when the awaited start fails, because the loaded data is + // known to cover this request. + } + } + + /** + * Starts polling without waiting for the first response. + * + * The interval is started first so that the immediate poll is covered by the + * source's abort signal and can be cancelled by `stop()`. + */ + private startPollingInBackground(): void { + this.pollingSource.startInterval(); + void this.pollingSource.poll(); + } + // --------------------------------------------------------------------------- // Stream initialization // --------------------------------------------------------------------------- @@ -814,6 +891,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..4f668192 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/routed-init.test.ts @@ -0,0 +1,198 @@ +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'); + +/** Sets the routed config versions header on a fake 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('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('should wait when the routed entry is duplicated', () => { + const cleanup = setRoutedVersions( + 'flags_prj_123=1000;flags_prj_123=1000', + ); + + expect( + decideRoutedInit({ projectId: 'prj_123', configUpdatedAt: 2000 }), + ).toEqual({ immediate: false, outcome: 'duplicate' }); + + 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..f1ef507f --- /dev/null +++ b/packages/vercel-flags-core/src/controller/routed-init.ts @@ -0,0 +1,116 @@ +/** + * Decides whether locally available flag definitions are already current for + * the request being served, based on the config version the request was + * routed to (see `utils/edge-config-versions.ts`). + * + * When they are, the controller can finish initialization right away instead + * of waiting for a stream confirmation or a first poll, while updates keep + * arriving in the background. + */ + +import { + EDGE_CONFIG_VERSIONS_HEADER, + flagsConfigVersionKey, + parseConfigVersion, + selectConfigVersion, +} from '../utils/edge-config-versions'; +import { getRequestContext } from '../utils/request-context'; + +/** + * Low cardinality outcome of the routed config version check. + * + * Only describes the comparison — never carries project ids, store names or + * header values. `undefined` (no outcome) is used whenever no routed version + * applies to this project, which is the case for every request that is not + * routed through a config version. + */ +export type RoutedInitOutcome = + /** Local definitions are at or ahead of the routed version. */ + | 'immediate' + /** The routed version is newer than the local definitions. */ + | 'behind' + /** The routed version is malformed or outside the safe integer range. */ + | 'invalid' + /** The routed key is present more than once. */ + | 'duplicate' + /** The local definitions carry no usable `configUpdatedAt`. */ + | 'unknown-local'; + +export type RoutedInitDecision = { + /** + * True only when the local definitions are provably current for this + * request. False keeps the existing initialization behavior. + */ + immediate: boolean; + /** Outcome for metrics; `undefined` when no routed version applies. */ + outcome: RoutedInitOutcome | undefined; +}; + +const NO_DECISION: RoutedInitDecision = { + immediate: false, + outcome: undefined, +}; + +/** + * Parses a datafile `configUpdatedAt` into a timestamp that can be compared + * against a routed config version. Numbers and numeric strings are accepted; + * missing, malformed and unsafe values are rejected. + */ +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; +} + +/** + * Compares the locally loaded definitions against the config version this + * request was routed to. + * + * Returns no decision — preserving the existing initialization behavior — when + * there is no request context, no project id, or no exact entry for this + * project in the header. + */ +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; + + const routed = selectConfigVersion( + headers[EDGE_CONFIG_VERSIONS_HEADER], + flagsConfigVersionKey(projectId), + ); + + switch (routed.status) { + case 'not-found': + return NO_DECISION; + case 'invalid': + return { immediate: false, outcome: 'invalid' }; + case 'duplicate': + return { immediate: false, outcome: 'duplicate' }; + 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/edge-config-versions.test.ts b/packages/vercel-flags-core/src/utils/edge-config-versions.test.ts new file mode 100644 index 00000000..fbcb26ec --- /dev/null +++ b/packages/vercel-flags-core/src/utils/edge-config-versions.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { + EDGE_CONFIG_VERSIONS_HEADER, + flagsConfigVersionKey, + parseConfigVersion, + selectConfigVersion, +} from './edge-config-versions'; + +describe('EDGE_CONFIG_VERSIONS_HEADER', () => { + it('should be the lower cased request header name', () => { + expect(EDGE_CONFIG_VERSIONS_HEADER).toBe('x-vercel-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('should report duplicate entries instead of picking one', () => { + expect(selectConfigVersion('flags_prj_123=1;flags_prj_123=2', key)).toEqual( + { status: 'duplicate' }, + ); + }); + + it('should report duplicates even when the versions are equal', () => { + expect(selectConfigVersion('flags_prj_123=1;flags_prj_123=1', key)).toEqual( + { status: 'duplicate' }, + ); + }); + + it('should report duplicates even when one entry is malformed', () => { + expect( + selectConfigVersion('flags_prj_123=nope;flags_prj_123=2', key), + ).toEqual({ status: 'duplicate' }); + expect( + selectConfigVersion('flags_prj_123=2;flags_prj_123=nope', key), + ).toEqual({ status: 'duplicate' }); + }); +}); diff --git a/packages/vercel-flags-core/src/utils/edge-config-versions.ts b/packages/vercel-flags-core/src/utils/edge-config-versions.ts new file mode 100644 index 00000000..ecd5836b --- /dev/null +++ b/packages/vercel-flags-core/src/utils/edge-config-versions.ts @@ -0,0 +1,91 @@ +/** + * Parser for the `x-vercel-edge-config-versions` request header. + * + * Vercel attaches this header to incoming requests to describe which config + * version the request was routed to. It holds a semicolon-separated map of + * store name to version, where the version is a millisecond timestamp: + * + * ``` + * x-vercel-edge-config-versions: flags_prj_123=1758000000000;ecfg_abc=1757000000000 + * ``` + * + * Flag definitions of a Vercel project are stored under `flags_`. + * Only an exact key match counts — no prefix, suffix or substring matching — + * so an unrelated store can never be mistaken for the project's flags. + */ + +/** Name of the request header carrying the routed config versions. */ +export const EDGE_CONFIG_VERSIONS_HEADER = 'x-vercel-edge-config-versions'; + +/** Result of looking up a single entry of the versions map. */ +export type ConfigVersionLookup = + /** The key was present exactly once with a usable version. */ + | { status: 'found'; version: number } + /** The key was not present in the map. */ + | { status: 'not-found' } + /** The key was present but its version is malformed or unsafe. */ + | { status: 'invalid' } + /** The key was present more than once, so no version can be trusted. */ + | { status: 'duplicate' }; + +const DIGITS = /^\d+$/; + +/** + * Derives the versions-map key holding the flag definitions of a project. + */ +export function flagsConfigVersionKey(projectId: string): string { + return `flags_${projectId}`; +} + +/** + * Parses a config version into a timestamp that is safe to compare. + * + * Only non-negative integers within the safe integer range are accepted. + * Everything else (empty strings, signs, fractions, exponents, hex, `NaN`, + * `Infinity`, values beyond `Number.MAX_SAFE_INTEGER`) is rejected, since a + * timestamp that cannot be compared exactly must not drive any decision. + * + * The regex is anchored and matches a single character class, so it runs in + * linear time regardless of input length. + */ +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 entry with the exact `key` from a versions map header value. + * + * Surrounding whitespace of entries, keys and values is ignored (HTTP list + * values may be padded), empty segments are skipped, and entries without a + * `=` separator are ignored. Duplicate keys are reported instead of resolved, + * because picking either one would be a guess. + */ +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; + + // A key that shows up twice makes the whole lookup ambiguous. + if (match) return { status: 'duplicate' }; + + const version = parseConfigVersion( + segment.slice(separatorIndex + 1).trim(), + ); + match = + version === undefined + ? { status: 'invalid' } + : { status: 'found', version }; + } + + return match ?? { status: 'not-found' }; +} 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..59f8a47f 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,12 @@ export interface TrackReadOptions { mode?: 'poll' | 'stream' | 'build' | 'offline'; /** Revision of the config */ revision?: number; + /** + * Outcome of comparing the loaded config against the version this request + * was routed to, as decided during initialization. Omitted when no routed + * version applied. Low cardinality — never contains ids or header values. + */ + configRoutedInit?: RoutedInitOutcome; } export class FlagsConfigReadEvent implements UsageEvent { @@ -39,6 +46,7 @@ export class FlagsConfigReadEvent implements UsageEvent { mode?: 'poll' | 'stream' | 'build' | 'offline'; revision?: string; environment?: string; + configRoutedInit?: RoutedInitOutcome; }; constructor( @@ -81,6 +89,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 =