Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
ace5b10
refactor
dferber90 Feb 14, 2026
069041e
remove retries
dferber90 Feb 14, 2026
909e327
simplify
dferber90 Feb 14, 2026
cccbb8c
simplify options
dferber90 Feb 14, 2026
db00431
before
dferber90 Feb 14, 2026
991a98d
rename DataSource to Controller
dferber90 Feb 14, 2026
280ab20
refactor and better tests
dferber90 Feb 14, 2026
94724f9
avoid double polling
dferber90 Feb 14, 2026
ce621a2
rename
dferber90 Feb 14, 2026
f7ba29b
fix
dferber90 Feb 14, 2026
16901de
simplify test setup
dferber90 Feb 14, 2026
bcb8a8d
wip
dferber90 Feb 17, 2026
777a35f
unify
dferber90 Feb 19, 2026
e2bc416
wip
dferber90 Feb 20, 2026
ef623bb
only track 1 read per build
dferber90 Feb 20, 2026
a8c21bc
make BundledSource lazy
dferber90 Feb 20, 2026
f714662
clean up fallback behavior
dferber90 Feb 20, 2026
43fa287
add mode
dferber90 Feb 20, 2026
fa36888
mutually exclusive streaming & polling
dferber90 Feb 20, 2026
00def3d
rely more on black box testing
dferber90 Feb 20, 2026
5a8d298
simplify
dferber90 Feb 20, 2026
976b864
Update CLAUDE.md
dferber90 Feb 20, 2026
218c876
various fixes
dferber90 Feb 20, 2026
bc390a9
add tests
dferber90 Feb 20, 2026
b7cffa2
more fixes
dferber90 Feb 20, 2026
fd0f7f1
Update CLAUDE.md
dferber90 Feb 20, 2026
fb2614a
more fixes
dferber90 Feb 20, 2026
d0b327a
resolve more issues
dferber90 Feb 20, 2026
519b266
unite black box tests
dferber90 Feb 20, 2026
9d7d6d4
tests
dferber90 Feb 20, 2026
35ce0f7
progress
dferber90 Feb 20, 2026
2f57032
adjust
dferber90 Feb 20, 2026
0d73afa
don't report on 401s; use Response.json
dferber90 Feb 20, 2026
e2fd698
enforce min polling interval
dferber90 Feb 20, 2026
161c3bf
progress
dferber90 Feb 20, 2026
c21e5fc
types
dferber90 Feb 20, 2026
b9b9a3b
capture all logs
dferber90 Feb 20, 2026
54ca358
throw with prefix
dferber90 Feb 20, 2026
f598a3d
added a minimum gap of `BASE_DELAY_MS` (1 second) between connection
dferber90 Feb 20, 2026
a79013e
step
dferber90 Feb 20, 2026
2ce930a
progress
dferber90 Feb 20, 2026
1494706
use request context in tests
dferber90 Feb 20, 2026
df14ca8
add separate tests depending on context
dferber90 Feb 20, 2026
b5b9ea8
tests
dferber90 Feb 20, 2026
997c0e6
rm unused option
dferber90 Feb 20, 2026
8b628c4
add state machine chart
dferber90 Feb 21, 2026
3433e96
rm sources
dferber90 Feb 21, 2026
dbc54e5
use fake timers for more tests
dferber90 Feb 21, 2026
36d4636
swap remaining tests from msw to mocked fetch
dferber90 Feb 21, 2026
224792c
update comments
dferber90 Feb 21, 2026
144feab
Update CLAUDE.md
dferber90 Feb 21, 2026
0d5af6c
update comment
dferber90 Feb 21, 2026
99d47b0
fix comment
dferber90 Feb 21, 2026
f56eda5
fix types
dferber90 Feb 21, 2026
e60cad0
rm peek
dferber90 Feb 21, 2026
0f2b228
adds ping checks and sending x-revision header (#282)
dferber90 Feb 24, 2026
b1d1d23
fixes
dferber90 Feb 24, 2026
667f810
rm state machine
dferber90 Feb 24, 2026
8942906
await first poll before resoliving init
dferber90 Feb 24, 2026
261b9a1
rm outdated comment
dferber90 Feb 24, 2026
c4e76a5
avoid leaking _origin
dferber90 Feb 24, 2026
81ccad3
changeset
luismeyer Feb 24, 2026
20600cb
avoid log on reconnect due to missed pings
dferber90 Feb 26, 2026
5c710ed
don't warn on missed pings
dferber90 Feb 26, 2026
dcfb39e
Update event reporting for control rewrite (#289)
AndyBitz Feb 26, 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
5 changes: 5 additions & 0 deletions .changeset/ripe-signs-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vercel/flags-core": minor
---

Refactor client
178 changes: 154 additions & 24 deletions packages/vercel-flags-core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,49 @@ src/
├── types.ts # Type definitions
├── errors.ts # Error classes
├── evaluate.ts # Core evaluation logic
├── data-source/ # Data source implementations
│ ├── flag-network-data-source.ts
│ ├── in-memory-data-source.ts
│ └── stream-connection.ts
├── controller-fns.ts # Controller function wrappers + instance map
├── create-raw-client.ts # Raw client factory (ID-based indirection for 'use cache')
├── controller/ # Controller (state machine) and I/O sources
│ ├── index.ts # Controller class
│ ├── stream-source.ts # StreamSource (wraps stream-connection)
│ ├── polling-source.ts # PollingSource (wraps fetch-datafile)
│ ├── bundled-source.ts # BundledSource (wraps read-bundled-definitions)
│ ├── stream-connection.ts # Low-level NDJSON stream connection
│ ├── fetch-datafile.ts # HTTP datafile fetch
│ ├── tagged-data.ts # Data origin tagging types/helpers
│ ├── normalized-options.ts # Option normalization
│ └── 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
│ └── read-bundled-definitions.ts
└── lib/ # Internal libraries
└── lib/
└── report-value.ts # Flag evaluation reporting to Vercel request context
```

## Architecture

### Data flow

```
createClient(sdkKey, options)
→ Controller (state machine, owns all data tagging and source coordination)
→ StreamSource / PollingSource / BundledSource (emit raw DatafileInput)
→ create-raw-client (ID-based indirection for 'use cache' support)
→ controller-fns (lookup by ID, evaluate, report)
→ FlagsClient (public API)
```

### 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.).
- **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.

## Key Concepts

### FlagsClient
Expand All @@ -36,6 +67,7 @@ type FlagsClient = {
initialize(): Promise<void>;
shutdown(): Promise<void>;
getDatafile(): Promise<Datafile>;
getFallbackDatafile(): Promise<BundledDefinitions>;
evaluate<T, E>(flagKey, defaultValue?, entities?): Promise<EvaluationResult<T>>;
}
```
Expand All @@ -48,15 +80,16 @@ type FlagsClient = {
4. Evaluate segment-based rules against entity context
5. Return fallthrough default if no match

### FlagNetworkDataSource Options
### Controller Options

```typescript
type FlagNetworkDataSourceOptions = {
type ControllerOptions = {
sdkKey: string;
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)
buildStep?: boolean; // Override build step auto-detection
sources?: { stream?: StreamSource; polling?: PollingSource; bundled?: BundledSource }; // DI for testing
};
```

Expand All @@ -67,18 +100,22 @@ Behavior differs based on environment:
**Build step** (CI=1, NEXT_PHASE=phase-production-build, or `buildStep: true`):
1. **Provided datafile** - Use `options.datafile` if provided
2. **Bundled definitions** - Use `@vercel/flags-definitions`
3. **Fetch** - Last resort network fetch
3. **One-time fetch** - Fallback network request
4. **Throw** - If all above fail

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`):
1. **Stream** - Real-time updates via SSE, wait up to `initTimeoutMs`
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
4. **Bundled definitions** - Use `@vercel/flags-definitions`
5. **One-time fetch** - Last resort (only when stream and polling are both disabled)

Key behaviors:
- Bundled definitions are always loaded as ultimate fallback
- All mechanisms write to in-memory state
- If in-memory state exists, serve immediately while background updates happen
- 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
- For offline mode with existing data, `initialize()` returns immediately
- **Never stream AND poll simultaneously**
- If stream reconnects while polling → stop polling
- If stream disconnects → start polling (if enabled)
Expand All @@ -97,8 +134,9 @@ Key behaviors:

Internal compact format for flag definitions:
- Variants stored as indices
- Conditions use enum values
- Entities accessed via arrays (e.g., `['user', 'id']`)
- Conditions use tuples: `[LHS, Comparator, RHS]` (e.g., `[['user', 'id'], Comparator.EQ, 'user-123']`)
- Targets shorthand: `{ user: { id: ['user-123'] } }`
- Entities accessed via path arrays (e.g., `['user', 'id']`)

## Entry Points

Expand All @@ -110,55 +148,147 @@ The package has conditional exports based on environment:

## Commands

All commands must be run from the package directory (`packages/vercel-flags-core`):

```bash
# Build
pnpm build

# Test
# Run all tests
pnpm test

# Run a single test file
pnpm vitest --run src/black-box.test.ts

# Run a single test file in watch mode
pnpm vitest src/black-box.test.ts

# Type check
pnpm check

# Integration tests (requires INTEGRATION_TEST_CONNECTION_STRING)
pnpm test:integration
```

## Test Guidelines (black-box.test.ts)

### Critical rules

- **All tests must use fake timers** unless there is a specific reason to use `vi.useRealTimers()`. The `beforeEach` sets up fake timers; only opt out when testing real async timing.
- **No stderr leaks**: every `console.warn` and `console.error` the implementation emits must be captured by a spy (`vi.spyOn(console, 'warn').mockImplementation(() => {})`) and asserted. A test that produces stderr output is broken.
- **Tests should complete in milliseconds**, not seconds. If a test takes ~3s, it's hitting a real timeout instead of advancing fake timers.

### initialize() blocks on stream/poll confirmation

`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`

This means:

- **With fake timers**: call `client.initialize()` (or `client.evaluate()` which triggers lazy init), then `await vi.advanceTimersByTimeAsync(initTimeoutMs)` to trigger the timeout fallback.
- **With real timers (`vi.useRealTimers()`)**: for streaming, you MUST push a stream message before awaiting `initialize()`, otherwise it blocks for the real 3s timeout:
```typescript
const initPromise = client.initialize();
await new Promise((r) => setTimeout(r, 0)); // let stream connect
stream.push({ type: 'primed', revision: 42, projectId: 'prj_123', environment: 'production' });
await initPromise; // resolves immediately
```
For polling, `initialize()` will await the first poll (which resolves immediately if `fetchMock` responds synchronously).

### Prefer evaluate-driven tests over explicit initialize()

Many tests on the `control` branch test that `evaluate()` triggers lazy initialization. Prefer this pattern to test the full public API path:
```typescript
const evalPromise = client.evaluate('flagA');
await vi.advanceTimersByTimeAsync(3_000);
const result = await evalPromise;
```
Only call `initialize()` explicitly when the test specifically needs to verify initialization behavior (e.g., deduplication, timing, init promise resolution).

### Assert console output from the implementation

The implementation logs warnings/errors for specific conditions. Tests must assert these:
- Stream timeout: `console.warn('@vercel/flags-core: Stream initialization timeout, falling back')`
- Stream error (e.g., 502): `console.error('@vercel/flags-core: Stream error', expect.any(Error))`
- 401 fast-fail: `console.error` with auth error (no retry, no timeout wait)

### Do not weaken assertions when adapting tests

When updating tests for new behavior, preserve the strength of existing assertions:
- Keep exact call count checks (e.g., `expect(streamCalls).toHaveLength(2)`) rather than weakening to `.toBeGreaterThanOrEqual(1)`
- Keep specific header assertions (e.g., `X-Retry-Attempt` values) rather than removing them
- Keep `errorSpy`/`warnSpy` assertions rather than dropping them

## Important Implementation Details

### Stream Connection

- Uses fetch with streaming body (NDJSON format)
- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onDisconnect`
- 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)
- 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
- 401 errors abort immediately (invalid SDK key)
- On disconnect: falls back to polling if enabled
- 401 errors abort immediately (invalid SDK key) and reject the init promise, so fallback kicks in without waiting for the stream timeout
- On disconnect: state transitions to `'degraded'`, falls back to polling if enabled
- On reconnect: Controller listens for `'connected'` event and transitions back to `'streaming'`
- Background stream promises (from init timeout) are `.catch`-ed by the Controller to prevent unhandled rejections when the stream is aborted before receiving data

### Polling

- Interval-based HTTP requests to `/v1/datafile`
- Default `intervalMs`: 30000ms (30s)
- Default `initTimeoutMs`: 10000ms (10s)
- Retries with exponential backoff (base: 500ms, max 3 retries)
- Default `initTimeoutMs`: 3000ms (3s)
- No retries — on fetch failure, emits an error event and waits for the next interval
- Stops automatically when stream reconnects
- `PollingSource` passes its abort signal to `fetchDatafile`, so calling `stop()` aborts in-flight HTTP requests
- `fetchDatafile` accepts an optional `signal` parameter; when provided, it aborts the internal fetch controller when the external signal fires

### 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:
- `'stream'`, `'poll'`, `'provided'` → `'in-memory'`
- `'fetched'` → `'remote'`
- `'bundled'` → `'embedded'`

`tagData` mutates the input object in-place via `Object.assign` (callers always pass freshly-created data).

### Usage Tracking

- Batches flag read events (max 50 events, max 5s wait)
- Sends to `flags.vercel.com/v1/ingest`
- Deduplicates by request context
- Uses `waitUntil()` from `@vercel/functions`
- At runtime: deduplicates by request context (per-instance WeakSet in UsageTracker)
- During builds: deduplicates all reads to a single event (buildReadTracked flag in Controller), since there is no request context available
- Uses `waitUntil()` from `@vercel/functions` (wrapped in try/catch for resilience)
- On flush failure, events are re-queued for retry with a max queue size of 500 events (oldest events are dropped when exceeded)
- `flush()` directly flushes queued events even when no scheduled flush is pending, ensuring events are not lost during `shutdown()`

### Client Management

- Each client gets unique incrementing ID
- Stored in `clientMap` for function lookups
- Stored in `controllerInstanceMap` in `controller-fns.ts`
- Supports multiple simultaneous clients
- Necessary as we can't pass function to `'use cache'` client-fns
- Necessary as we can't pass functions to `'use cache'` wrappers

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

### 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)

### 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`)

### Debug Mode

Enable debug logging with `DEBUG=1` environment variable.
Enable debug logging with `DEBUG=@vercel/flags-core` environment variable.

## Dependencies

Expand Down
2 changes: 2 additions & 0 deletions packages/vercel-flags-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { createClient } from '@vercel/flags-core';

const client = createClient(process.env.FLAGS!);

await client.initialize();

const result = await client.evaluate<boolean>('show-new-feature', false, {
user: { id: 'user-123' },
});
Expand Down
5 changes: 2 additions & 3 deletions packages/vercel-flags-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,16 @@
"@arethetypeswrong/cli": "0.18.2",
"@types/node": "20.11.17",
"flags": "workspace:*",
"msw": "2.6.4",
"next": "16.1.6",
"tsup": "8.5.1",
"typescript": "5.6.3",
"vite": "6.4.1",
"vitest": "2.1.9"
},
"peerDependencies": {
"next": "*",
"@openfeature/server-sdk": "1.18.0",
"flags": "*"
"flags": "*",
"next": "*"
},
"peerDependenciesMeta": {
"@openfeature/server-sdk": {
Expand Down
Loading