From db0748f24788d818bef70b89903ab7d8a73e61be Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:18:11 +0000 Subject: [PATCH 1/3] feat(flags-core): skip init wait when routed config version is loaded EXP-3418 Reads the existing `x-vercel-edge-config-versions` request context header, selects the exact `flags_` entry derived from the loaded definitions, and resolves `initialize()` right away when the local `configUpdatedAt` is at or ahead of that version, while the stream or poll keeps updating in the background. No config id and no new header are involved. A missing request context, project or entry, a malformed/unsafe version, a duplicated entry, and local data without a usable `configUpdatedAt` all preserve the previous behavior of waiting up to `initTimeoutMs`. The controller stays in `initializing:*` until the source actually connects, so no connection is reported before it exists, and the existing `configUpdatedAt` guard still keeps background updates from replacing newer definitions with equal or older ones. The low cardinality outcome is attached to `FLAGS_CONFIG_READ` events as `configRoutedInit` and never carries ids or header values. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Luis Meyer --- .changeset/routed-config-version-init.md | 9 + packages/vercel-flags-core/CLAUDE.md | 35 +- .../vercel-flags-core/src/black-box.test.ts | 610 ++++++++++++++++++ .../vercel-flags-core/src/controller/index.ts | 80 +++ .../src/controller/routed-init.test.ts | 198 ++++++ .../src/controller/routed-init.ts | 116 ++++ .../src/utils/edge-config-versions.test.ts | 154 +++++ .../src/utils/edge-config-versions.ts | 91 +++ .../src/utils/usage/flags-config-read.ts | 11 + 9 files changed, 1303 insertions(+), 1 deletion(-) create mode 100644 .changeset/routed-config-version-init.md create mode 100644 packages/vercel-flags-core/src/controller/routed-init.test.ts create mode 100644 packages/vercel-flags-core/src/controller/routed-init.ts create mode 100644 packages/vercel-flags-core/src/utils/edge-config-versions.test.ts create mode 100644 packages/vercel-flags-core/src/utils/edge-config-versions.ts 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 = From e9028f1dd0665581744124b904afbe32feaff6b7 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Mon, 7 Sep 2026 11:58:58 +0200 Subject: [PATCH 2/3] fix(flags-core): abstract version headers and add fallback --- .changeset/routed-config-version-init.md | 6 +- packages/vercel-flags-core/CLAUDE.md | 45 ++++----- .../vercel-flags-core/src/black-box.test.ts | 62 ++++++++----- .../vercel-flags-core/src/controller/index.ts | 36 +------- .../src/controller/routed-init.test.ts | 53 ++++++++++- .../src/controller/routed-init.ts | 54 ++--------- .../src/utils/edge-config-versions.ts | 91 ------------------- .../src/utils/usage/flags-config-read.ts | 6 +- ...ersions.test.ts => version-header.test.ts} | 12 ++- .../src/utils/version-header.ts | 49 ++++++++++ 10 files changed, 182 insertions(+), 232 deletions(-) delete mode 100644 packages/vercel-flags-core/src/utils/edge-config-versions.ts rename packages/vercel-flags-core/src/utils/{edge-config-versions.test.ts => version-header.test.ts} (93%) create mode 100644 packages/vercel-flags-core/src/utils/version-header.ts diff --git a/.changeset/routed-config-version-init.md b/.changeset/routed-config-version-init.md index bc6c8ccb..af29fd8c 100644 --- a/.changeset/routed-config-version-init.md +++ b/.changeset/routed-config-version-init.md @@ -2,8 +2,6 @@ '@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. +Skip the stream or first-poll initialization wait when local flag definitions cover the routed config version, while updates continue in the background. -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. +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 exact `flags_` entry with local `configUpdatedAt`; missing, invalid, or duplicate entries preserve the existing wait behavior. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 1636523d..fa46542d 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -32,7 +32,7 @@ src/ │ ├── usage-tracker.ts │ ├── sdk-keys.ts │ ├── sleep.ts -│ ├── edge-config-versions.ts # x-vercel-edge-config-versions parser +│ ├── version-header.ts # Routed version header parser │ ├── request-context.ts # Vercel request context access │ └── read-bundled-definitions.ts └── lib/ @@ -191,7 +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`. +- **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: @@ -284,32 +284,21 @@ The Controller tags all data with its origin using `tagData(data, origin)` from ### 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 +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 exact `flags_${projectId}` entry. +- 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 entry; invalid or duplicate versions; and + unusable local timestamps preserve the existing initialization wait. +- `FLAGS_CONFIG_READ.configRoutedInit` records `immediate`, `behind`, `invalid`, + `duplicate`, or `unknown-local`, without ids or header values. It is omitted + when no routed version applies. ### configUpdatedAt Guard diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index acc121ac..48d8ba0c 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2935,18 +2935,19 @@ describe('Controller (black-box)', () => { }); // --------------------------------------------------------------------------- - // Routed config version (x-vercel-edge-config-versions) + // Routed config version // --------------------------------------------------------------------------- describe('routed config version', () => { - /** Request context carrying the routed config versions header. */ - function setRoutedVersions(value: string): () => void { + function setRoutedVersions( + value: string, + header = 'x-vercel-edge-config-versions', + ): () => void { return setRequestContext({ host: 'example.com', - 'x-vercel-edge-config-versions': value, + [header]: 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(); @@ -2959,7 +2960,6 @@ describe('Controller (black-box)', () => { }); } - /** 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( @@ -2967,7 +2967,6 @@ describe('Controller (black-box)', () => { ); } - /** Flag definition serving variant index `variant`. */ function servingVariant(variant: 0 | 1) { return { flagA: { @@ -2977,9 +2976,15 @@ describe('Controller (black-box)', () => { }; } - it('should initialize immediately when the loaded data covers the routed version', async () => { + 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'); + const cleanupCtx = setRoutedVersions( + 'ecfg_abc=9999;flags_prj_123=2000', + header, + ); const stream = createMockStream(); fetchMock.mockImplementation((input) => { @@ -3004,10 +3009,8 @@ describe('Controller (black-box)', () => { 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', @@ -3025,7 +3028,6 @@ describe('Controller (black-box)', () => { 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, @@ -3146,20 +3148,16 @@ describe('Controller (black-box)', () => { 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); @@ -3189,7 +3187,6 @@ describe('Controller (black-box)', () => { await client.initialize(); - // Equal configUpdatedAt — must not replace the loaded data. stream.push({ type: 'datafile', data: makeBundled({ @@ -3200,7 +3197,6 @@ describe('Controller (black-box)', () => { 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({ @@ -3211,7 +3207,6 @@ describe('Controller (black-box)', () => { await vi.advanceTimersByTimeAsync(0); expect((await client.evaluate('flagA')).value).toBe(true); - // Newer data is applied. stream.push({ type: 'datafile', data: makeBundled({ @@ -3239,9 +3234,33 @@ describe('Controller (black-box)', () => { ['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) => { + [ + '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 = setRoutedVersions(headerValue); + const cleanupCtx = setRequestContext({ + host: 'example.com', + 'x-vercel-edge-config-versions': headerValue, + ...(fallback === undefined ? {} : { 'edge-config-versions': fallback }), + }); serveSilentStream(); const client = createClient(sdkKey, { @@ -3443,7 +3462,6 @@ describe('Controller (black-box)', () => { }, ); - // 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'); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index a8ad68ff..7c813d99 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -121,8 +121,6 @@ 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) { @@ -272,10 +270,7 @@ 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. + // 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) { @@ -470,13 +465,6 @@ export class Controller implements ControllerInterface { // 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; @@ -489,18 +477,11 @@ export class Controller implements ControllerInterface { 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`. - */ + // Keep the initializing state 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. + // Source events handle connection state; suppress usage for invalid keys. if ( error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('401')) @@ -509,18 +490,11 @@ export class Controller implements ControllerInterface { } }); } 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. + // Local data covers this request even if starting the stream fails. } } - /** - * 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()`. - */ + // Start the interval first so stop() can abort the immediate poll. private startPollingInBackground(): void { this.pollingSource.startInterval(); void this.pollingSource.poll(); diff --git a/packages/vercel-flags-core/src/controller/routed-init.test.ts b/packages/vercel-flags-core/src/controller/routed-init.test.ts index 4f668192..78dfb8e6 100644 --- a/packages/vercel-flags-core/src/controller/routed-init.test.ts +++ b/packages/vercel-flags-core/src/controller/routed-init.test.ts @@ -4,7 +4,6 @@ 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', @@ -91,6 +90,58 @@ describe('decideRoutedInit', () => { }); }); + 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', + false, + 'duplicate', + ], + ['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'); diff --git a/packages/vercel-flags-core/src/controller/routed-init.ts b/packages/vercel-flags-core/src/controller/routed-init.ts index f1ef507f..f6d7c894 100644 --- a/packages/vercel-flags-core/src/controller/routed-init.ts +++ b/packages/vercel-flags-core/src/controller/routed-init.ts @@ -1,48 +1,23 @@ -/** - * 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 { getRequestContext } from '../utils/request-context'; import { - EDGE_CONFIG_VERSIONS_HEADER, + FALLBACK_VERSION_HEADER, flagsConfigVersionKey, parseConfigVersion, selectConfigVersion, -} from '../utils/edge-config-versions'; -import { getRequestContext } from '../utils/request-context'; + VERSION_HEADER, +} from '../utils/version-header'; -/** - * 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. - */ +/** Low-cardinality metric outcome; never includes ids or header values. */ 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. */ + /** Omitted when no routed version applies to this project. */ outcome: RoutedInitOutcome | undefined; }; @@ -51,11 +26,6 @@ const NO_DECISION: RoutedInitDecision = { 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; @@ -66,14 +36,7 @@ function parseLocalTimestamp(value: unknown): number | undefined { 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. - */ +/** Skips the init wait only when local definitions cover the routed version. */ export function decideRoutedInit(data: { projectId: unknown; configUpdatedAt: unknown; @@ -85,8 +48,9 @@ export function decideRoutedInit(data: { 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[EDGE_CONFIG_VERSIONS_HEADER], + headers[VERSION_HEADER] ?? headers[FALLBACK_VERSION_HEADER], flagsConfigVersionKey(projectId), ); diff --git a/packages/vercel-flags-core/src/utils/edge-config-versions.ts b/packages/vercel-flags-core/src/utils/edge-config-versions.ts deleted file mode 100644 index ecd5836b..00000000 --- a/packages/vercel-flags-core/src/utils/edge-config-versions.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * 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 59f8a47f..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 @@ -20,11 +20,7 @@ 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. - */ + /** Init comparison outcome; omitted when no routed version applies. */ configRoutedInit?: RoutedInitOutcome; } diff --git a/packages/vercel-flags-core/src/utils/edge-config-versions.test.ts b/packages/vercel-flags-core/src/utils/version-header.test.ts similarity index 93% rename from packages/vercel-flags-core/src/utils/edge-config-versions.test.ts rename to packages/vercel-flags-core/src/utils/version-header.test.ts index fbcb26ec..e96c058c 100644 --- a/packages/vercel-flags-core/src/utils/edge-config-versions.test.ts +++ b/packages/vercel-flags-core/src/utils/version-header.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from 'vitest'; import { - EDGE_CONFIG_VERSIONS_HEADER, + FALLBACK_VERSION_HEADER, flagsConfigVersionKey, parseConfigVersion, selectConfigVersion, -} from './edge-config-versions'; + VERSION_HEADER, +} from './version-header'; -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('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'); }); }); 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..1792aff5 --- /dev/null +++ b/packages/vercel-flags-core/src/utils/version-header.ts @@ -0,0 +1,49 @@ +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' } + | { status: 'duplicate' }; + +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 an exact key from a semicolon-separated map; duplicates are ambiguous. */ +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; + + 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' }; +} From b7eb8acdbf3538a5731fc4d270feef7f269b358e Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Mon, 7 Sep 2026 12:12:39 +0200 Subject: [PATCH 3/3] fix(flags-core): select first valid version header match --- .changeset/routed-config-version-init.md | 2 +- packages/vercel-flags-core/CLAUDE.md | 8 ++-- .../vercel-flags-core/src/black-box.test.ts | 20 ++++++++-- .../src/controller/routed-init.test.ts | 25 +++++++++---- .../src/controller/routed-init.ts | 3 -- .../src/utils/version-header.test.ts | 37 +++++++++++-------- .../src/utils/version-header.ts | 13 ++----- 7 files changed, 64 insertions(+), 44 deletions(-) diff --git a/.changeset/routed-config-version-init.md b/.changeset/routed-config-version-init.md index af29fd8c..06fc640e 100644 --- a/.changeset/routed-config-version-init.md +++ b/.changeset/routed-config-version-init.md @@ -4,4 +4,4 @@ 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 exact `flags_` entry with local `configUpdatedAt`; missing, invalid, or duplicate entries preserve the existing wait behavior. +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 fa46542d..20680dd0 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -290,14 +290,14 @@ 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 exact `flags_${projectId}` entry. +- `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 entry; invalid or duplicate versions; and - unusable local timestamps preserve the existing initialization wait. +- Missing context, project id, or valid entry, and unusable local timestamps + preserve the existing initialization wait. - `FLAGS_CONFIG_READ.configRoutedInit` records `immediate`, `behind`, `invalid`, - `duplicate`, or `unknown-local`, without ids or header values. It is omitted + or `unknown-local`, without ids or header values. It is omitted when no routed version applies. ### configUpdatedAt Guard diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 48d8ba0c..d7f13a13 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -3046,9 +3046,13 @@ describe('Controller (black-box)', () => { cleanupCtx(); }); - it('should initialize immediately when the loaded data equals the routed version', async () => { + 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('flags_prj_123=2000'); + const cleanupCtx = setRoutedVersions(header); serveSilentStream(); const client = createClient(sdkKey, { @@ -3233,7 +3237,14 @@ describe('Controller (black-box)', () => { ['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'], + [ + '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', '', @@ -3472,7 +3483,8 @@ describe('Controller (black-box)', () => { it.each([ ['behind', 'flags_prj_123=2001'], ['invalid', 'flags_prj_123=later'], - ['duplicate', 'flags_prj_123=2000;flags_prj_123=2000'], + ['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); diff --git a/packages/vercel-flags-core/src/controller/routed-init.test.ts b/packages/vercel-flags-core/src/controller/routed-init.test.ts index 78dfb8e6..51ca0f20 100644 --- a/packages/vercel-flags-core/src/controller/routed-init.test.ts +++ b/packages/vercel-flags-core/src/controller/routed-init.test.ts @@ -121,8 +121,8 @@ describe('decideRoutedInit', () => { [ 'flags_prj_123=1000;flags_prj_123=1000', 'flags_prj_123=1000', - false, - 'duplicate', + true, + 'immediate', ], ['flags_prj_999=1000', 'flags_prj_123=1000', false, undefined], ['flags_prj_123', 'flags_prj_123=1000', false, undefined], @@ -214,14 +214,25 @@ describe('decideRoutedInit', () => { cleanup(); }); - it('should wait when the routed entry is duplicated', () => { - const cleanup = setRoutedVersions( - 'flags_prj_123=1000;flags_prj_123=1000', - ); + 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: false, outcome: 'duplicate' }); + ).toEqual({ immediate, outcome }); cleanup(); }); diff --git a/packages/vercel-flags-core/src/controller/routed-init.ts b/packages/vercel-flags-core/src/controller/routed-init.ts index f6d7c894..a2497e92 100644 --- a/packages/vercel-flags-core/src/controller/routed-init.ts +++ b/packages/vercel-flags-core/src/controller/routed-init.ts @@ -12,7 +12,6 @@ export type RoutedInitOutcome = | 'immediate' | 'behind' | 'invalid' - | 'duplicate' | 'unknown-local'; export type RoutedInitDecision = { @@ -59,8 +58,6 @@ export function decideRoutedInit(data: { return NO_DECISION; case 'invalid': return { immediate: false, outcome: 'invalid' }; - case 'duplicate': - return { immediate: false, outcome: 'duplicate' }; case 'found': break; } diff --git a/packages/vercel-flags-core/src/utils/version-header.test.ts b/packages/vercel-flags-core/src/utils/version-header.test.ts index e96c058c..54c94bae 100644 --- a/packages/vercel-flags-core/src/utils/version-header.test.ts +++ b/packages/vercel-flags-core/src/utils/version-header.test.ts @@ -133,24 +133,29 @@ describe('selectConfigVersion', () => { }); }); - 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.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 duplicates even when one entry is malformed', () => { - expect( - selectConfigVersion('flags_prj_123=nope;flags_prj_123=2', key), - ).toEqual({ status: 'duplicate' }); + it('should report invalid when all matching entries are invalid', () => { expect( - selectConfigVersion('flags_prj_123=2;flags_prj_123=nope', key), - ).toEqual({ status: 'duplicate' }); + 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 index 1792aff5..2d52b896 100644 --- a/packages/vercel-flags-core/src/utils/version-header.ts +++ b/packages/vercel-flags-core/src/utils/version-header.ts @@ -4,8 +4,7 @@ export const FALLBACK_VERSION_HEADER = 'edge-config-versions'; export type ConfigVersionLookup = | { status: 'found'; version: number } | { status: 'not-found' } - | { status: 'invalid' } - | { status: 'duplicate' }; + | { status: 'invalid' }; const DIGITS = /^\d+$/; @@ -20,7 +19,7 @@ export function parseConfigVersion(value: string): number | undefined { return Number.isSafeInteger(parsed) ? parsed : undefined; } -/** Selects an exact key from a semicolon-separated map; duplicates are ambiguous. */ +/** Selects the first valid exact-key match from a semicolon-separated map. */ export function selectConfigVersion( headerValue: string | undefined, key: string, @@ -34,15 +33,11 @@ export function selectConfigVersion( if (separatorIndex === -1) continue; if (segment.slice(0, separatorIndex).trim() !== key) continue; - if (match) return { status: 'duplicate' }; - const version = parseConfigVersion( segment.slice(separatorIndex + 1).trim(), ); - match = - version === undefined - ? { status: 'invalid' } - : { status: 'found', version }; + if (version !== undefined) return { status: 'found', version }; + match = { status: 'invalid' }; } return match ?? { status: 'not-found' };