Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
a6dad71
feat(flags-core): preserve datafile fetch timestamps
luismeyer Sep 23, 2026
b077df0
feat(flags-core): add header-driven Vercel mode on the shared cache
luismeyer Sep 23, 2026
79721e7
test(flags-core): cover Vercel mode selection and refreshes
luismeyer Sep 23, 2026
e1ba342
test(flags-core): cover persisted freshness and header error recovery
luismeyer Sep 23, 2026
7bd71b1
feat(flags-core): accept stale-while-revalidate in seconds
luismeyer Sep 23, 2026
d3a0304
refactor(flags-core): drive cache reads with source callbacks
luismeyer Sep 23, 2026
2f3e549
test(flags-core): cover cache callbacks and request isolation
luismeyer Sep 23, 2026
14434f8
refactor(flags-core): preserve bundled initialization flow
luismeyer Sep 23, 2026
c07d584
refactor(flags-core): own freshness age in the cache
luismeyer Sep 23, 2026
9e2a8aa
feat(flags-core): assess stream and polling freshness by age
luismeyer Sep 23, 2026
cf40aa9
test(flags-core): cover and document header freshness confirmations
luismeyer Sep 23, 2026
599a2a6
refactor(flags-core): simplify freshness policies while preserving re…
luismeyer Sep 23, 2026
c6273d4
test(flags-core): guard snapshot freshness and cold-cache recovery
luismeyer Sep 23, 2026
a5af23c
test(flags-core): retain seconds-based stale-if-error coverage
luismeyer Sep 23, 2026
525a98b
docs(flags-core): explain cache serving and recovery checks
luismeyer Sep 23, 2026
3d6831e
docs(flags-core): clarify cache and source policy decisions
luismeyer Sep 23, 2026
edd1ac4
feat(flags-core): fall back to stream or polling without version headers
luismeyer Sep 23, 2026
a5132b7
fix(flags-core): confirm cache recovery on stream pings
luismeyer Sep 23, 2026
d3c9031
refactor(flags-core): share runtime fallback resolution
luismeyer Sep 23, 2026
6ab3115
refactor(flags-core): keep header fallback on the existing path
luismeyer Sep 25, 2026
edc0548
fix(flags-core): track header authorization state
luismeyer Sep 30, 2026
93a808f
refactor(flags-core): make cache freshness evidence explicit
luismeyer Sep 30, 2026
3718e29
refactor(flags-core): let header reads finish across source fallback
luismeyer Sep 30, 2026
e915986
refactor(flags-core): unify datafile refresh fallback
luismeyer Sep 30, 2026
5267673
style(flags-core): require braces for control flow
luismeyer Oct 1, 2026
ff27fd4
refactor(flags-core): rely on datafile fetch deadline
luismeyer Oct 1, 2026
88e4a2f
refactor(flags-core): reuse cache confirmation
luismeyer Oct 1, 2026
5c1e51e
refactor(flags-core): clarify runtime source resolution
luismeyer Oct 1, 2026
340b2c2
fix(flags-core): preserve source lifecycle and relax refresh windows
luismeyer Oct 1, 2026
14ad1db
refactor(flags-core): centralize cancellation in cache confirmation
luismeyer Oct 1, 2026
a27dc82
refactor(flags-core): keep polling initialization in its original sec…
luismeyer Oct 1, 2026
10a75bf
test(flags-core): await background refresh before stream failure
luismeyer Oct 1, 2026
c47f3b5
fix(flags-core): reconnect quietly after stream ping timeouts
luismeyer Oct 1, 2026
be8edb5
fix(flags-core): unify project-aware reads and immediate stream fallback
luismeyer Oct 1, 2026
015efa5
test(vercel-adapter): cover active discovery client initialization
luismeyer Oct 1, 2026
0b19bc1
refactor(flags-core): signal source fallback through cache assessment
luismeyer Oct 1, 2026
d11110c
fix(flags-core): unify polling startup and source assessment
luismeyer Oct 1, 2026
280a8f7
refactor runtime source fallback handling
luismeyer Oct 2, 2026
b0eef5b
fix(flags-core): keep stream and polling exclusive and treat any sour…
claude Oct 2, 2026
e323c1c
fix(flags-core): let reads refresh after the stream is exhausted with…
claude Oct 2, 2026
6f13d98
docs(flags-core): describe exclusive sources, age expiry, and snapsho…
claude Oct 2, 2026
5a539ba
fix(flags-core): fold stream exhaustion into state and apply SWR read…
luismeyer Oct 5, 2026
79c95a7
fix(flags-core): assess degraded reads from staleWhileRevalidate and …
luismeyer Oct 5, 2026
3cfcfe9
fix(flags-core): let the stream own refreshes instead of racing HTTP …
luismeyer Oct 6, 2026
5dc8062
feat(flags-core): prepare the cache when initialize() runs inside a r…
luismeyer Oct 7, 2026
18f8524
fix(flags-core): only wait for the stream after five minutes of silence
luismeyer Oct 8, 2026
1a66589
fix(flags-core): never make streaming reads wait for the stream
luismeyer Oct 9, 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.
13 changes: 13 additions & 0 deletions .changeset/header-driven-vercel-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@vercel/flags-core": minor
---

Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions. At module scope it starts no network activity, and an empty cache fetches on its first read. Inside a request, `initialize()` prepares the cache like the first evaluation would (confirm a matching version, fetch a newer one or an empty cache, or start streaming/polling for a missing entry), and repeated calls in later requests repeat that preparation. Request versions trigger refreshes. Explicit offline/build behavior is preserved.

Evaluations require a valid positive version header for their own project. Missing, empty, malformed, or unrelated project entries permanently start streaming when enabled, otherwise polling; multiple clients select their sources independently. Concurrent reads share startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded refreshes; waiting reads use the confirmed cache, and late responses cannot change cache or authorization state. A cold shared fetch discovers project identity before accepting header evidence; a failed cold fetch rejects without switching sources and is retried by the next read. `getDatafile()` remains a snapshot that never starts streaming or polling: it serves cached definitions through the same header checks, stale-while-revalidate, blocking refresh, and stale-if-error, and loads bundled definitions or performs a one-time fetch when the cache is empty.

**Cached definitions now age in streaming and polling mode.** Streaming data is fresh for 60 seconds after the last message; polling data is fresh for its interval plus the 10-second fetch deadline (40 seconds by default). Polling data then stays stale for `staleWhileRevalidate` seconds (default 10) while reads refresh over HTTP in the background, and expired reads wait for the shared fetch. While streaming, reads never wait and no HTTP request competes with the connection: older data is served immediately and reported as stale while the stream's ping watchdog reconnects in the background, including right after a suspended runtime resumes. Data without a known age is served until its source first confirms it. Accepted updates, source responses, valid confirmations, and stream pings reset age without rewriting `fetchedAt`.

Streaming and polling never run at the same time. A stream startup timeout or disconnect keeps the stream reconnecting in the background while reads serve the cache; polling starts only once the stream gives up for good (retries exhausted, 401, or token failure), waiting for its first poll up to `polling.initTimeoutMs`. Ping timeouts reconnect the stream internally, allowing suspended runtimes to resume. Polling gives up on a 401 too. Whenever no live source is active, because the stream is reconnecting or the stream or polling gave up, reads apply plain stale-while-revalidate over HTTP: fresh within `staleWhileRevalidate`, served while refreshing in the background beyond that, and waiting for the refresh once older than `staleWhileRevalidate` plus `staleIfError`; data of unknown age refreshes in the background. Shutting down and reinitializing a client rewires its sources and starts with a clean cache and failure deadline.

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.
2 changes: 1 addition & 1 deletion .changeset/tidy-flags-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@vercel/flags-core": minor
---

Add `staleIfError` in seconds to bound cached runtime reads after the first consecutive stream/poll failure or stream disconnect. The default `Infinity` preserves unlimited fallback; finite nonnegative durations (including fractional seconds) use existing evaluation defaults and errors after expiry, and `getDatafile()` follows the same allowance. Accepted updates, valid equal-version responses, or matching stream primed revisions reset the allowance. Storing fallback data does not confirm freshness or renew the failure clock. Build/offline behavior and source scheduling remain unchanged.
Add `staleIfError` in seconds to bound cached runtime reads after the first consecutive stream/poll failure or stream disconnect. The default `Infinity` preserves unlimited fallback; finite nonnegative durations (including fractional seconds) use existing evaluation defaults and errors after expiry, and `getDatafile()` follows the same allowance. Any successful source response resets the allowance, including one the version guard rejects as older or for a different project, as does a matching stream primed revision. Storing provided or bundled fallback data does not confirm freshness or renew the failure clock. Build/offline behavior and source scheduling remain unchanged.
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Coding Style

- Always use curly braces for `if`, `else`, loop, and other control-flow bodies in newly added code, including single-statement bodies. Do not refactor existing code solely to add braces.
- Avoid nested ternary expressions. Prefer explicit conditionals or small helper functions so each branch is easy to follow.
62 changes: 52 additions & 10 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 @@ import {
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 @@ -144,6 +157,35 @@ describe('generateDefinitionsModule', () => {
});

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 @@ -174,7 +216,7 @@ describe('prepareFlagsDefinitions', () => {
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 @@ -268,7 +310,7 @@ describe('prepareFlagsDefinitions', () => {
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 @@ -355,8 +397,8 @@ describe('prepareFlagsDefinitions', () => {
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\\":\\"from-source\\"}}"));
const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}"));
const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":\\"from-source\\"},\\"fetchedAt\\":1700000000000}"));

const map = {
"prj_consumer": _d0,
Expand Down Expand Up @@ -416,7 +458,7 @@ describe('prepareFlagsDefinitions', () => {
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 @@ -453,7 +495,7 @@ describe('prepareFlagsDefinitions', () => {
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 @@ -559,8 +601,8 @@ describe('prepareFlagsDefinitions', () => {
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 @@ -199,7 +199,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
Loading
Loading