Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/tidy-flags-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@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.
25 changes: 19 additions & 6 deletions packages/vercel-flags-core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ src/

```
createClient(sdkKey, options)
→ Controller (state machine, owns all data tagging and source coordination)
→ Controller (state machine, selects data origin and coordinates sources/cache)
→ StreamSource / PollingSource / BundledSource (emit raw DatafileInput)
→ create-raw-client (ID-based indirection for 'use cache' support)
→ controller-fns (lookup by ID, evaluate, report)
Expand All @@ -62,7 +62,7 @@ The Controller's `unauthorized` flag gates usage tracking. It is set on a 401 fr

### Design principles

- **Sources emit raw data** — StreamSource, PollingSource, and BundledSource return/emit raw `DatafileInput`. The Controller is solely responsible for tagging data with its origin (`tagData(data, 'stream')` etc.).
- **Sources emit raw data** — StreamSource, PollingSource, and BundledSource return/emit raw `DatafileInput`. The Controller selects the source origin. For live updates, `DatafileCache.updateFromSource()` checks version acceptance before tagging and storing data; the Controller tags initial/fallback snapshots passed to `seed()`.
- **BundledSource is a plain class** — unlike StreamSource and PollingSource which extend TypedEmitter, BundledSource has no event listeners. The Controller calls its methods directly and uses return values.
- **Tests are black-box** — all behavioral tests go through the public API (`createClient` from `./index.default`). Mock `readBundledDefinitions` and `internalReportValue` as observable I/O. Use `fetchMock` for network assertions.
- **ID-based indirection** — `controller-fns.ts` holds a `controllerInstanceMap` (Map<number, ControllerInstance>) so that `'use cache'` wrappers in Next.js can pass serializable IDs instead of function references.
Expand Down Expand Up @@ -99,6 +99,7 @@ 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
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 Down Expand Up @@ -242,7 +243,7 @@ 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`
- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `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`
- Reconnects with exponential backoff (base: 1s, max: 60s, max retries: 15)
Expand All @@ -266,7 +267,7 @@ When updating tests for new behavior, preserve the strength of existing assertio

### Data Origin Tagging

The Controller tags all data with its origin using `tagData(data, origin)` from `tagged-data.ts`. Origins map to public `metrics.source` values:
The Controller selects the origin. Initial/fallback snapshots are tagged before `cache.seed()`; live stream/poll updates are tagged inside `cache.updateFromSource()` only after acceptance. Both use `tagData(data, origin)` from `tagged-data.ts`. Origins map to public `metrics.source` values:
- `'stream'`, `'poll'`, `'provided'` → `'in-memory'`
- `'fetched'` → `'remote'`
- `'bundled'` → `'embedded'`
Expand Down Expand Up @@ -295,17 +296,29 @@ The Controller tags all data with its origin using `tagData(data, origin)` from

### 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`.
The DatafileCache 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`.

### Evaluation Reporting

- `internalReportValue` (defined in `lib/report-value.ts`, called from `controller-fns.ts`) reports flag evaluations to the Vercel request context
- Reports are sent for all evaluations where `datafile.projectId` exists, including error cases (e.g., FLAG_NOT_FOUND)

### Cache read policy

`DatafileCache.read()` is the only full-entry read. 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
are not recovery/failure evidence respectively.

### Evaluation Safety

- Regex comparators (`REGEX`, `NOT_REGEX`) limit input string length to 10,000 characters to prevent ReDoS
- `read()` and `getDatafile()` return new objects with spread (never mutate `this.data`)
- `read()` and `getDatafile()` apply the same cache read policy and each return new objects with spread (never mutate cached data)

### Debug Mode

Expand Down
40 changes: 40 additions & 0 deletions packages/vercel-flags-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,46 @@ export default app;

Outside Vercel, pass an SDK key explicitly: `createClient(process.env.FLAGS)`.

## Cached stream and polling reads

`staleIfError` controls how many seconds evaluations and `getDatafile()` may use
cached flag definitions after a stream/poll failure or stream disconnect:

```ts
const client = createClient(process.env.FLAGS!, {
staleIfError: 60,
});
```

The default is `Infinity`, preserving unlimited cached fallback. Use a finite
nonnegative number of seconds to bound fallback. Fractional seconds are supported
(for example, `0.5` allows 500 milliseconds). A positive window includes its exact
deadline; `0` disables cached fallback immediately after failure.
Negative values, `NaN`, and negative infinity throw when creating the client.

The allowance starts at the first consecutive failure. Repeated errors,
disconnects, and provided or bundled fallback data do not renew it. An accepted
source update, or a finite equal version for the same project and environment,
clears the outage. A stream `primed` message also clears it when its finite numeric
revision and identity match the cached entry. Opening a connection or receiving
a ping alone does not clear a failure. A later failure starts a new allowance.
Responses are observed in completion order, with existing version acceptance.

After expiry, `evaluate()` returns the caller's default with reason `error`, or
throws the first failure when no default is supplied. `bulkEvaluate()` returns
an error result for each requested flag, with its default value when provided.
`getDatafile()` follows the same allowance and throws after expiry. The entry is
retained for recovery, including its revision for stream reconnection. A clean
stream close or ping timeout records `stream: disconnected` if no earlier failure
exists. `getFallbackDatafile()` remains an independent bundled-data export.

There is no age-based expiry while the source is healthy, and reads do not trigger
an extra refresh after expiry. Build/offline behavior, source scheduling, retries,
timeouts, metrics categories, and logging are unchanged. An initialization timeout
alone does not start the allowance. Existing startup limitations remain: when
initial polling times out, no recurring interval is started, even if that in-flight
request later completes.

## Evaluation Metrics

To associate evaluation metrics with an environment, pass the
Expand Down
Loading
Loading