Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3e11646
feat(flags-core): preserve datafile fetch timestamps
luismeyer Sep 23, 2026
6649a6b
feat(flags-core): add header-driven Vercel mode on the shared cache
luismeyer Sep 23, 2026
9cedf47
test(flags-core): cover Vercel mode selection and refreshes
luismeyer Sep 23, 2026
0e5956d
test(flags-core): cover persisted freshness and header error recovery
luismeyer Sep 23, 2026
57c753d
feat(flags-core): accept stale-while-revalidate in seconds
luismeyer Sep 23, 2026
15938da
refactor(flags-core): drive cache reads with source callbacks
luismeyer Sep 23, 2026
cebdeb6
test(flags-core): cover cache callbacks and request isolation
luismeyer Sep 23, 2026
65138c7
refactor(flags-core): preserve bundled initialization flow
luismeyer Sep 23, 2026
c21c82b
refactor(flags-core): own freshness age in the cache
luismeyer Sep 23, 2026
8554c4a
feat(flags-core): assess stream and polling freshness by age
luismeyer Sep 23, 2026
c3349f9
test(flags-core): cover and document header freshness confirmations
luismeyer Sep 23, 2026
61e4caa
refactor(flags-core): simplify freshness policies while preserving re…
luismeyer Sep 23, 2026
4a4b658
test(flags-core): guard snapshot freshness and cold-cache recovery
luismeyer Sep 23, 2026
1b62893
test(flags-core): retain seconds-based stale-if-error coverage
luismeyer Sep 23, 2026
0270dae
docs(flags-core): explain cache serving and recovery checks
luismeyer Sep 23, 2026
0b64e80
docs(flags-core): clarify cache and source policy decisions
luismeyer Sep 23, 2026
122d819
feat(flags-core): fall back to stream or polling without version headers
luismeyer Sep 23, 2026
961f141
fix(flags-core): confirm cache recovery on stream pings
luismeyer Sep 23, 2026
20077f3
refactor(flags-core): share runtime fallback resolution
luismeyer Sep 23, 2026
05340cf
refactor(flags-core): keep header fallback on the existing path
luismeyer Sep 25, 2026
8395b82
fix(flags-core): track header authorization state
luismeyer Sep 30, 2026
66b71bf
refactor(flags-core): make cache freshness evidence explicit
luismeyer Sep 30, 2026
106f35f
refactor(flags-core): let header reads finish across source fallback
luismeyer Sep 30, 2026
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
8 changes: 8 additions & 0 deletions .changeset/embedded-flags-fetch-time.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@vercel/prepare-flags-definitions": patch
"@vercel/flags-core": patch
---

Record `fetchedAt` when a datafile fetch completes and preserve it in generated flag definitions. Loading the bundle retains the original timestamp so the Flags SDK can determine its age.

Expose optional `fetchedAt` metadata on datafiles. Record it for accepted live updates and preserve valid timestamps when loading provided or bundled definitions, without mutating the input.
11 changes: 11 additions & 0 deletions .changeset/header-driven-vercel-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@vercel/flags-core": minor
---

Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved.

An evaluation with a missing or empty version header permanently starts streaming when enabled, otherwise polling. Concurrent reads share startup, and pending header refreshes are cancelled without losing cached data or resetting stale-if-error. Initialization and snapshot reads do not trigger this switch.

The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age and clear stale-if-error failures because each connection sends `primed` or a datafile first. Source schedules stay unchanged.

Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse.
58 changes: 50 additions & 8 deletions packages/prepare-flags-definitions/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { readFile } from 'node:fs/promises';
import { describe, expect, it, vi } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { version as pkgVersion } from '../package.json';
import {
generateDefinitionsModule,
Expand All @@ -8,6 +11,16 @@
prepareFlagsDefinitions,
} from './index';

const FETCH_TIME = 1_700_000_000_000;

beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'], now: FETCH_TIME });
});

afterEach(() => {
vi.useRealTimers();
});

function createOidcToken(projectId: string): string {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString(
'base64url',
Expand Down Expand Up @@ -118,6 +131,35 @@
});

describe('prepareFlagsDefinitions', () => {
it('embeds fetch completion time and preserves it when loaded later', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'flags-fetched-at-'));
try {
await prepareFlagsDefinitions({
cwd,
env: { FLAGS: 'vf_server_timestamp' },
fetch: vi.fn().mockResolvedValue({
ok: true,
json: async () => {
vi.setSystemTime(FETCH_TIME + 5_000);
return { configUpdatedAt: 123, fetchedAt: 456, definitions: {} };
},
}),
});
vi.setSystemTime(FETCH_TIME + 365 * 24 * 60 * 60 * 1_000);
const url = pathToFileURL(
join(cwd, 'node_modules/@vercel/flags-definitions/index.js'),
).href;
const bundle = await import(/* @vite-ignore */ url);
expect(bundle.get(hashSdkKey('vf_server_timestamp'))).toEqual({
configUpdatedAt: 123,
fetchedAt: FETCH_TIME + 5_000,
definitions: {},
});
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

it('returns { created: false, reason: "no-flags-entries" } when no flags auth is in env', async () => {
const result = await prepareFlagsDefinitions({
cwd: '/tmp/test',
Expand Down Expand Up @@ -148,7 +190,7 @@
expect(definitionsJs).toMatchInlineSnapshot(`
"const memo = (fn) => { let cached; return () => (cached ??= fn()); };

const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}"));
const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}"));

const map = {
"faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0,
Expand Down Expand Up @@ -242,7 +284,7 @@
expect(definitionsJs).toMatchInlineSnapshot(`
"const memo = (fn) => { let cached; return () => (cached ??= fn()); };

const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}"));
const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}"));

const map = {
"3790790d2dc9b23c4539a9f3c49eb5820e4216daebdd7eeee9136f3ceccc31a3": _d0,
Expand Down Expand Up @@ -310,7 +352,7 @@
`${cwd}/node_modules/@vercel/flags-definitions/index.js`,
'utf8',
);
expect(definitionsJs).toMatchInlineSnapshot(`

Check failure on line 355 in packages/prepare-flags-definitions/src/index.test.ts

View workflow job for this annotation

GitHub Actions / Test (24.x)

src/index.test.ts > prepareFlagsDefinitions > fetches connected projects with the OIDC token and the source project header

Error: Snapshot `prepareFlagsDefinitions > fetches connected projects with the OIDC token and the source project header 1` mismatched - Expected + Received @@ -1,9 +1,9 @@ "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\"flag_a\":{\"value\":true}}")); + const _d0 = memo(() => JSON.parse("{\"flag_a\":{\"value\":true},\"fetchedAt\":1700000000000}")); - const _d1 = memo(() => JSON.parse("{\"flag_b\":{\"value\":\"from-source\"}}")); + const _d1 = memo(() => JSON.parse("{\"flag_b\":{\"value\":\"from-source\"},\"fetchedAt\":1700000000000}")); const map = { "prj_consumer": _d0, "prj_source": _d1, }; ❯ src/index.test.ts:355:27

Check failure on line 355 in packages/prepare-flags-definitions/src/index.test.ts

View workflow job for this annotation

GitHub Actions / Test (24.x)

src/index.test.ts > prepareFlagsDefinitions > fetches connected projects with the OIDC token and the source project header

Error: Snapshot `prepareFlagsDefinitions > fetches connected projects with the OIDC token and the source project header 1` mismatched - Expected + Received @@ -1,9 +1,9 @@ "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\"flag_a\":{\"value\":true}}")); + const _d0 = memo(() => JSON.parse("{\"flag_a\":{\"value\":true},\"fetchedAt\":1700000000000}")); - const _d1 = memo(() => JSON.parse("{\"flag_b\":{\"value\":\"from-source\"}}")); + const _d1 = memo(() => JSON.parse("{\"flag_b\":{\"value\":\"from-source\"},\"fetchedAt\":1700000000000}")); const map = { "prj_consumer": _d0, "prj_source": _d1, }; ❯ src/index.test.ts:355:27

Check failure on line 355 in packages/prepare-flags-definitions/src/index.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.x)

src/index.test.ts > prepareFlagsDefinitions > fetches connected projects with the OIDC token and the source project header

Error: Snapshot `prepareFlagsDefinitions > fetches connected projects with the OIDC token and the source project header 1` mismatched - Expected + Received @@ -1,9 +1,9 @@ "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\"flag_a\":{\"value\":true}}")); + const _d0 = memo(() => JSON.parse("{\"flag_a\":{\"value\":true},\"fetchedAt\":1700000000000}")); - const _d1 = memo(() => JSON.parse("{\"flag_b\":{\"value\":\"from-source\"}}")); + const _d1 = memo(() => JSON.parse("{\"flag_b\":{\"value\":\"from-source\"},\"fetchedAt\":1700000000000}")); const map = { "prj_consumer": _d0, "prj_source": _d1, }; ❯ src/index.test.ts:355:27

Check failure on line 355 in packages/prepare-flags-definitions/src/index.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.x)

src/index.test.ts > prepareFlagsDefinitions > fetches connected projects with the OIDC token and the source project header

Error: Snapshot `prepareFlagsDefinitions > fetches connected projects with the OIDC token and the source project header 1` mismatched - Expected + Received @@ -1,9 +1,9 @@ "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\"flag_a\":{\"value\":true}}")); + const _d0 = memo(() => JSON.parse("{\"flag_a\":{\"value\":true},\"fetchedAt\":1700000000000}")); - const _d1 = memo(() => JSON.parse("{\"flag_b\":{\"value\":\"from-source\"}}")); + const _d1 = memo(() => JSON.parse("{\"flag_b\":{\"value\":\"from-source\"},\"fetchedAt\":1700000000000}")); const map = { "prj_consumer": _d0, "prj_source": _d1, }; ❯ src/index.test.ts:355:27
"const memo = (fn) => { let cached; return () => (cached ??= fn()); };

const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}"));
Expand Down Expand Up @@ -437,7 +479,7 @@
expect(definitionsJs).toMatchInlineSnapshot(`
"const memo = (fn) => { let cached; return () => (cached ??= fn()); };

const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}"));
const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}"));

const map = {
"prj_oidc_test": _d0,
Expand Down Expand Up @@ -477,7 +519,7 @@
expect(definitionsJs).toMatchInlineSnapshot(`
"const memo = (fn) => { let cached; return () => (cached ??= fn()); };

const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}"));
const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}"));

const map = {
"faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0,
Expand Down Expand Up @@ -579,8 +621,8 @@
expect(definitionsJs).toMatchInlineSnapshot(`
"const memo = (fn) => { let cached; return () => (cached ??= fn()); };

const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}"));
const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":true}}"));
const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}"));
const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}"));

const map = {
"faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0,
Expand Down
4 changes: 3 additions & 1 deletion packages/prepare-flags-definitions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@ async function fetchDatafile(
}

if (res.ok) {
return res.json() as Promise<BundledDefinitions>;
const definitions = (await res.json()) as BundledDefinitions;
// Preserve fetch time so loading the bundle does not make old data fresh.
return { ...definitions, fetchedAt: Date.now() };
}

if (res.status === 404) {
Expand Down
61 changes: 53 additions & 8 deletions packages/vercel-flags-core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ src/
├── controller/ # Controller (state machine) and I/O sources
│ ├── index.ts # Controller class
│ ├── stream-source.ts # StreamSource (wraps stream-connection)
│ ├── header-source.ts # Request version checks and on-demand refresh
│ ├── polling-source.ts # PollingSource (wraps fetch-datafile)
│ ├── bundled-source.ts # BundledSource (wraps read-bundled-definitions)
│ ├── stream-connection.ts # Low-level NDJSON stream connection
Expand All @@ -43,7 +44,7 @@ src/
```
createClient(sdkKey, options)
→ Controller (state machine, selects data origin and coordinates sources/cache)
→ StreamSource / PollingSource / BundledSource (emit raw DatafileInput)
→ StreamSource / PollingSource / HeaderSource / BundledSource (raw DatafileInput)
→ create-raw-client (ID-based indirection for 'use cache' support)
→ controller-fns (lookup by ID, evaluate, report)
→ FlagsClient (public API)
Expand Down Expand Up @@ -98,7 +99,9 @@ type ControllerOptions = {
datafile?: Datafile; // Initial datafile for immediate reads
stream?: boolean | { initTimeoutMs: number }; // default: true (3000ms)
polling?: boolean | { intervalMs: number; initTimeoutMs: number }; // default: true (30s interval, 3s timeout)
staleIfError?: number; // Seconds of fallback after stream/poll failure; default: Infinity
vercel?: boolean; // default: process.env.VERCEL === '1'; replaces stream/poll at runtime
staleWhileRevalidate?: number; // Seconds of header refresh grace; default: 10
staleIfError?: number; // Seconds of fallback after update failure; default: Infinity
buildStep?: boolean; // Override build step auto-detection
metricEnvironment?: string; // Environment attached to ingested evaluation metrics
waitUntil?: (promise: Promise<unknown>) => void; // default: @vercel/functions waitUntil
Expand All @@ -122,7 +125,29 @@ Behavior differs based on environment:

Build-step reads are deduplicated: data is loaded once via a shared promise (`buildDataPromise`) and all concurrent `evaluate()` calls share the result. The entire build counts as a single tracked read event (`buildReadTracked` flag in Controller).

**Runtime** (default, or `buildStep: false`):
**Vercel runtime** (`vercel: true`, default when `VERCEL=1`):
- Load provided or bundled definitions during initialization, then select Vercel mode.
- Do not start stream/poll; the first read fetches if the cache is empty.
- HeaderSource parses the request's project version and owns `highestObserved`. The cache owns freshness age.
- A matching header confirms freshness only when no newer version has been observed.
- The controller passes `assess` and `fetch` callbacks to `cache.resolve()`.
The cache selects cached/background/blocking behavior and shares refresh work.
- A newer header permits background refresh within `staleWhileRevalidate` seconds of the
latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh.
- Every returned entry passes through `DatafileCache.read()`. Refresh errors use its
`staleIfError` allowance; expiry forces blocking recovery on the next newer-header read.
- Evaluations without a version header (including an empty header) permanently
start streaming if enabled, otherwise polling, using the existing startup timeouts.
Concurrent new reads share source startup. Pending header reads finish independently;
successful responses still pass the cache version guard, while errors from the retired
header source do not change cache failure or authorization state.
`resolveData()` checks header availability and uses `resolveDataWithFallbacks()`
to start the configured source. Handover retains cached data before considering seeds.
- Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error.
- `getDatafile()` remains a snapshot read: it enforces the same failure policy but does
not inspect request headers. Disabling both stream and polling selects offline mode.

**Other runtime** (default outside Vercel, or `vercel: false`):
1. **Stream** - Real-time updates via NDJSON streaming, wait up to `initTimeoutMs`
2. **Polling** - Interval-based HTTP requests, wait up to `initTimeoutMs`
3. **Provided datafile** - Use `options.datafile` if provided
Expand Down Expand Up @@ -242,9 +267,9 @@ When updating tests for new behavior, preserve the strength of existing assertio
### Stream Connection

- Uses fetch with streaming body (NDJSON format)
- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onDisconnect`, and `onError` (failure evidence for cache policy)
- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onPing` (resets age and clears failure), `onDisconnect`, and `onError` (failure evidence for cache policy)
- Sends `X-Revision` header with the current revision number on every connection (including reconnects), allowing the server to respond with a lightweight `primed` message instead of a full datafile when the revision is current
- The `primed` message confirms the client's data is up-to-date; it resolves the init promise (like `datafile`) but does not update data — only transitions state to `streaming`
- The `primed` message confirms the client's data is up-to-date; it resolves the init promise (like `datafile`) but does not update data — resets cache age and clears a failure when revision/identity match, then transitions state to `streaming`
- Reconnects with exponential backoff (base: 1s, max: 60s, max retries: 15)
- Retries on transient errors both before and after initial data is received. Before initial data, retries continue until max retries are exhausted or the abort controller is aborted (e.g., by the Controller's init timeout). The init promise rejects when the loop exits without data.
- Default `initTimeoutMs`: 3000ms
Expand All @@ -271,7 +296,11 @@ The Controller selects the origin. Initial/fallback snapshots are tagged before
- `'fetched'` → `'remote'`
- `'bundled'` → `'embedded'`

`tagData` mutates the input object in-place via `Object.assign` (callers always pass freshly-created data).
`tagData` returns a shallow copy. Accepted fetched/stream/poll data is stamped with
`fetchedAt`; provided and bundled data preserves valid finite nonnegative timestamps.
The cache seeds its own freshness age from that timestamp; missing/invalid timestamps
mean unknown age. Accepted updates and valid confirmations reset cache age without
rewriting the stored `fetchedAt`. Equal/older responses do not replace or retag data.

### Usage Tracking

Expand Down Expand Up @@ -304,16 +333,32 @@ The DatafileCache rejects incoming data (from stream or poll) if its `configUpda

### Cache read policy

`DatafileCache.read()` is the only full-entry read. The cache is configured once
Every served entry passes through `DatafileCache.read()`, including `resolve(policy)` results. The cache is configured once
with the internal `staleIfErrorMs`, normalized from the public `staleIfError`
option in seconds. Evaluations and `getDatafile()` share the same serving
boundary. `hasData` and `revision` expose coordination metadata even after expiry,
so retained data is not replaced by fallback and stream reconnects can still send
`X-Revision`. `seed()` never clears failure. Accepted source updates or valid
version/revision confirmations clear it; repeated errors/disconnects do not renew
the first-error deadline. Stream opening/pings and initialization timeout alone
the first-error deadline. Stream opening and initialization timeout alone
are not recovery/failure evidence respectively.

`cache.resolve(policy)` receives a mode-specific `assess` callback returning
`{ status, confirmed? }`, where status is `fresh`, `stale`, `expired`, or `unknown`,
and an optional `fetch` callback.
It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation
on clear. HeaderSource supplies small version/age checks and a fetch callback; it does
not read the cache. Header assessments return confirmation evidence explicitly; the
cache applies it before enforcing stale-if-error, without a controller event round trip.
Source data/error events update the cache before the fetch promise settles; rejecting
the promise does not record a second failure. Stream/poll modes omit on-read revalidation
and retain their existing schedules. New public time windows use seconds; internal
normalized durations, cache age, and `fetchedAt` use milliseconds. Polling is fresh
through its interval; streaming through 30 seconds. Accepted updates, valid confirmations,
and stream pings reset cache age. Pings also clear failures: each connection sends
`primed` or a datafile before pings, so they confirm recovery without rewriting `fetchedAt`.
Polling errors use the shared source-error handler without logging each failed poll.

### Evaluation Safety

- Regex comparators (`REGEX`, `NOT_REGEX`) limit input string length to 10,000 characters to prevent ReDoS
Expand Down
Loading
Loading