Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/routed-config-version-init.md
Original file line number Diff line number Diff line change
@@ -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_<projectId>` 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.
35 changes: 34 additions & 1 deletion packages/vercel-flags-core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ 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
├── utils/ # Utilities
│ ├── 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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`.
Expand Down
Loading