Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
52e0a09
feat(flags-core): preserve datafile fetch timestamps
luismeyer Sep 23, 2026
2634fe4
feat(flags-core): add header-driven Vercel mode on the shared cache
luismeyer Sep 23, 2026
11a58f7
test(flags-core): cover Vercel mode selection and refreshes
luismeyer Sep 23, 2026
db9057b
test(flags-core): cover persisted freshness and header error recovery
luismeyer Sep 23, 2026
1dce10f
feat(flags-core): accept stale-while-revalidate in seconds
luismeyer Sep 23, 2026
844a720
refactor(flags-core): drive cache reads with source callbacks
luismeyer Sep 23, 2026
059a040
test(flags-core): cover cache callbacks and request isolation
luismeyer Sep 23, 2026
7dd4902
refactor(flags-core): preserve bundled initialization flow
luismeyer Sep 23, 2026
252ac82
refactor(flags-core): own freshness age in the cache
luismeyer Sep 23, 2026
e61b4f5
feat(flags-core): assess stream and polling freshness by age
luismeyer Sep 23, 2026
c3b7214
test(flags-core): cover and document header freshness confirmations
luismeyer Sep 23, 2026
2d2e859
refactor(flags-core): simplify freshness policies while preserving re…
luismeyer Sep 23, 2026
3a78ae7
test(flags-core): guard snapshot freshness and cold-cache recovery
luismeyer Sep 23, 2026
3ad3477
test(flags-core): retain seconds-based stale-if-error coverage
luismeyer Sep 23, 2026
046f549
docs(flags-core): explain cache serving and recovery checks
luismeyer Sep 23, 2026
d1f07c7
docs(flags-core): clarify cache and source policy decisions
luismeyer Sep 23, 2026
6e43b44
feat(flags-core): fall back to stream or polling without version headers
luismeyer Sep 23, 2026
c809d4b
fix(flags-core): confirm cache recovery on stream pings
luismeyer Sep 23, 2026
824796e
refactor(flags-core): share runtime fallback resolution
luismeyer Sep 23, 2026
f5b96c3
refactor(flags-core): keep header fallback on the existing path
luismeyer Sep 25, 2026
c23ca63
fix(flags-core): track header authorization state
luismeyer Sep 30, 2026
9d65c5d
refactor(flags-core): make cache freshness evidence explicit
luismeyer Sep 30, 2026
bddfd60
refactor(flags-core): let header reads finish across source fallback
luismeyer Sep 30, 2026
309f9bf
refactor(flags-core): unify datafile refresh fallback
luismeyer Sep 30, 2026
388cc70
style(flags-core): require braces for control flow
luismeyer Oct 1, 2026
759884b
refactor(flags-core): rely on datafile fetch deadline
luismeyer Oct 1, 2026
9b798ad
refactor(flags-core): reuse cache confirmation
luismeyer Oct 1, 2026
e24c1d1
refactor(flags-core): clarify runtime source resolution
luismeyer Oct 1, 2026
403c93c
fix(flags-core): preserve source lifecycle and relax refresh windows
luismeyer Oct 1, 2026
227b09e
refactor(flags-core): centralize cancellation in cache confirmation
luismeyer Oct 1, 2026
f6eb2af
refactor(flags-core): keep polling initialization in its original sec…
luismeyer Oct 1, 2026
6006432
test(flags-core): await background refresh before stream failure
luismeyer Oct 1, 2026
d13b317
fix(flags-core): reconnect quietly after stream ping timeouts
luismeyer Oct 1, 2026
19e5f69
fix(flags-core): unify project-aware reads and immediate stream fallback
luismeyer Oct 1, 2026
2f69bcc
test(vercel-adapter): cover active discovery client initialization
luismeyer Oct 1, 2026
3c9c638
refactor(flags-core): signal source fallback through cache assessment
luismeyer Oct 1, 2026
b298baa
fix(flags-core): unify polling startup and source assessment
luismeyer Oct 1, 2026
e9076c3
refactor runtime source fallback handling
luismeyer Oct 2, 2026
a248357
fix(flags-core): keep stream and polling exclusive and treat any sour…
claude Oct 2, 2026
1da3009
fix(flags-core): let reads refresh after the stream is exhausted with…
claude Oct 2, 2026
13f91a0
docs(flags-core): describe exclusive sources, age expiry, and snapsho…
claude Oct 2, 2026
976a4a4
fix(flags-core): fold stream exhaustion into state and apply SWR read…
luismeyer Oct 5, 2026
1367cfc
fix(flags-core): assess degraded reads from staleWhileRevalidate and …
luismeyer Oct 5, 2026
e05c8f1
fix(flags-core): let the stream own refreshes instead of racing HTTP …
luismeyer Oct 6, 2026
1d1668f
feat(flags-core): prepare the cache when initialize() runs inside a r…
luismeyer Oct 7, 2026
174c1b5
fix(flags-core): only wait for the stream after five minutes of silence
luismeyer Oct 8, 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 expire by 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, no HTTP request competes with the connection: data stays stale until five minutes after the last message (or the fresh window plus `staleWhileRevalidate`, if longer), so resuming from an ordinary suspension serves the cache while the stream reconnects in the background. Only older data makes reads drop the silent connection and wait up to ten seconds for the replacement stream to confirm or replace the cache. 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