Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
bb72353
[flags] avoid re-imports
dferber90 Apr 1, 2026
ecfcc32
[flags] avoid iife microtask queue overhead
dferber90 Apr 1, 2026
df5281a
[flags] skip awaits where possible
dferber90 Apr 1, 2026
f693413
[flags] allow bulk eval
dferber90 Apr 1, 2026
fe9605a
wip
dferber90 May 13, 2026
099fda8
add bulk mode to playground
dferber90 May 13, 2026
aa0dfba
first try of bulk eval
dferber90 May 13, 2026
7cb1aeb
add bulk evaluation to adapters
dferber90 May 13, 2026
3af7129
Merge branch 'main' into bulk
dferber90 May 13, 2026
ea6d164
reuse
dferber90 May 14, 2026
cb04cde
simplify
dferber90 May 19, 2026
7fd2e5e
versions
dferber90 May 19, 2026
6321ab5
Merge branch 'main' into bulk
dferber90 May 19, 2026
e475198
rm outdated changeset
dferber90 May 19, 2026
2e1b113
rm outdated changeset
dferber90 May 19, 2026
930a9e0
revert playground
dferber90 May 19, 2026
35e518b
revert playground flags
dferber90 May 19, 2026
e076dd0
rm logs
dferber90 May 19, 2026
7a6c28d
reuse cache of resolved flags for bulk
dferber90 May 19, 2026
a576379
simplify
dferber90 May 19, 2026
5b2dc37
changesets
dferber90 May 19, 2026
380d725
support bulk([]) and bulk({})
dferber90 May 19, 2026
66b3a23
flagKey → key
dferber90 May 19, 2026
bf80336
allow any type in expectPermutations
dferber90 May 20, 2026
c65406b
avoid unnecessary mapping
dferber90 May 25, 2026
9575a4a
fix changeset
dferber90 May 25, 2026
254480e
validate package.json fields
dferber90 May 25, 2026
2225248
update package.json fields
dferber90 May 25, 2026
581a9c5
Merge branch 'main' into bulk
dferber90 May 25, 2026
fa48aba
keep covariant
dferber90 May 26, 2026
dde2adb
adjust adapter types
dferber90 May 26, 2026
29c4f6b
Merge bulk into evaluate (#392)
dferber90 May 29, 2026
0b54172
add test
dferber90 May 29, 2026
cafd951
changesets
dferber90 May 29, 2026
e6a6157
use type import
dferber90 May 29, 2026
8559877
fix import
dferber90 May 29, 2026
7944608
extract readOverrides
dferber90 May 29, 2026
7da325c
fix import of reportValue
dferber90 May 29, 2026
d39f8f3
tracing
dferber90 May 29, 2026
8e451a9
skip bulkDecide for overwritten flags
dferber90 Jun 3, 2026
574ea99
update changesets
dferber90 Jun 3, 2026
25f6fd7
[changesets] onlyUpdatePeerDependentsWhenOutOfRange
dferber90 Jun 3, 2026
14fcc20
add type guard
dferber90 Jun 3, 2026
7da30f5
Reapply "Add oidc support (#374)" (#389)
luismeyer May 28, 2026
6180dbf
bump oidc package
luismeyer May 28, 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/adapter-bulk-evaluation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@flags-sdk/vercel': minor
---

Reduces overhead when evaluating multiple flags via `evaluate()` or `precompute()` by using new bulk evaluation capabilities of `@vercel/flags-core`.
3 changes: 3 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
},
"ignore": [
"playground",
"shirt-shop",
Expand Down
20 changes: 20 additions & 0 deletions .changeset/core-bulk-evaluation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@vercel/flags-core': minor
---

Add `bulkEvaluate` method to `FlagsClient` for resolving multiple flags against shared entities in a single call.

```ts
const results = await client.bulkEvaluate(
[
{ key: 'a', defaultValue: false },
{ key: 'b', defaultValue: 'off' },
],
entities,
);

results.a; // EvaluationResult<boolean>
results.b; // EvaluationResult<string>
```

Avoids the per-flag overhead of separate `evaluate()` calls — the datafile is read once, entities are resolved once, and all flags share the same environment/segments lookup. Each entry in the returned record is a full `EvaluationResult` with `value`, `reason`, `outcomeType`, and `metrics`.
24 changes: 24 additions & 0 deletions .changeset/flags-evaluate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'flags': minor
---

Introduces a new bulk evaluation method for adapters, which is used when multiple flags are evaluated together to avoid making individual calls to each adapter.

When applications call `evaluate()` or `precompute()` function from `flags/next` it now defers bulk evaluation to the underlying adapters in case those support it, or otherwise falls back to evaluating each flag individually.

This speeds up evaluation for applications that need to evaluate multiple flags at once, as the runtime needs to handle fewer promises and more work is reused. In testing we have seen a 20x improvement when called with 100 flags.

```tsx
import { evaluate } from 'flags/next';
import { flagA, flagB } from '../flags';

// pass a list of flags
const [valueA, valueB] = await evaluate([flagA, flagB]);

// pass an object
const { a, b } = await evaluate({ a: flagA, b: flagB });
```

Adapters can opt into bulk evaluation by implementing a `bulkDecide` method and setting a stable `adapterId`. When both are present, flag evaluation groups flags that share the same `adapterId` and `identify` source and invokes `bulkDecide` once per group instead of calling `decide` per flag. Flags without a bulk-capable adapter still resolve through the normal per-flag path inside `evaluate()` and still benefit from now reusing the shared per-request headers, cookies, and overrides reads.

Tracing reflects this grouping. `evaluate()` (and therefore `precompute()`) now emits an `evaluate` span carrying a `flagCount` attribute. Within it, bulk-evaluated flags no longer emit an individual per-flag `run` span; instead each adapter group emits a single `batch` span (carrying the `adapterId`, the `keys` evaluated in the batch, and `cachedCount`/`overrideCount`/`decidedCount` attributes summarizing how the batch resolved) so per-flag instrumentation overhead is not reintroduced. Flags that fall back to the per-flag path continue to emit their own `flag` span as before.
13 changes: 13 additions & 0 deletions .changeset/twelve-shoes-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@flags-sdk/vercel": minor
"@vercel/prepare-flags-definitions": minor
"@vercel/flags-core": minor
---

Add OIDC authentication support for Vercel Flags clients and generated flag definitions.

`@vercel/flags-core` can now create clients without an SDK key and authenticate with a Vercel OIDC token, while still supporting SDK keys and connection strings. Bundled definitions can be looked up by SDK key hash or OIDC project ID.

`@vercel/prepare-flags-definitions` now collects both SDK keys and `VERCEL_OIDC_TOKEN`, fetches definitions for each auth entry, deduplicates identical definitions across SDK keys and OIDC project IDs, and writes generated maps keyed by SDK key hash or project ID.

`@flags-sdk/vercel` now supports provider data lookup for Vercel flag origins that do not include an SDK key, allowing OIDC-backed clients to resolve project metadata.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"shirt-shop": "pnpm dev -F shirt-shop",
"shirt-shop-api": "pnpm dev -F shirt-shop-api",
"snippets": "pnpm dev -F snippets",
"playground": "pnpm dev -F playground",
"svelte": "pnpm dev -F svelte-example",
"test": "turbo test",
"test:e2e": "turbo test:e2e",
Expand All @@ -43,8 +44,8 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"@changesets/changelog-github": "^0.6.0",
"@changesets/cli": "2.29.8",
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "2.31.0",
"@types/node": "22.9.0",
"gray-matter": "4.0.3",
"husky": "9.0.10",
Expand Down
13 changes: 8 additions & 5 deletions packages/adapter-posthog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function createPostHogAdapter({
trimKey(key),
parsedEntities.distinctId,
options,
)) ?? defaultValue;
)) ?? (defaultValue as boolean | undefined);
if (result === undefined) {
throw new Error(
`PostHog Adapter isFeatureEnabled returned undefined for ${trimKey(key)} and no default value was provided.`,
Expand All @@ -44,7 +44,7 @@ export function createPostHogAdapter({
);
if (flagValue === undefined) {
if (typeof defaultValue !== 'undefined') {
return defaultValue;
return defaultValue as string | boolean;
}
throw new Error(
`PostHog Adapter featureFlagValue found undefined for ${trimKey(key)} and no default value was provided.`,
Expand All @@ -54,7 +54,10 @@ export function createPostHogAdapter({
},
};
},
featureFlagPayload: (getValue, options) => {
featureFlagPayload: <T>(
getValue: (payload: JsonType) => T,
options?: { sendFeatureFlagEvents?: boolean },
) => {
return {
async decide({ key, entities, defaultValue }) {
const parsedEntities = parseEntities(entities);
Expand All @@ -66,13 +69,13 @@ export function createPostHogAdapter({
);
if (!payload) {
if (typeof defaultValue !== 'undefined') {
return defaultValue;
return defaultValue as T;
}
throw new Error(
`PostHog Adapter featureFlagPayload found undefined for ${trimKey(key)} and no default value was provided.`,
);
}
return getValue(payload);
return getValue(payload as JsonType);
},
};
},
Expand Down
66 changes: 66 additions & 0 deletions packages/adapter-vercel/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,72 @@ describe('createVercelAdapter', () => {
.toHaveProperty('entities')
.toEqualTypeOf<SampleEvaluationContext | undefined>();
});

describe('adapterId', () => {
it('shares one adapterId across all adapters from the same factory call', () => {
const adapter = createVercelAdapter(flagsClient);
const a = adapter();
const b = adapter();
expect(a).not.toBe(b);
expect(a.adapterId).toBeDefined();
expect(a.adapterId).toBe(b.adapterId);
});

it('uses different adapterIds across separate factory calls', () => {
const adapterA = createVercelAdapter('vf_client_key_a');
const adapterB = createVercelAdapter('vf_client_key_b');
expect(adapterA().adapterId).not.toBe(adapterB().adapterId);
});
});

describe('bulkDecide', () => {
it('forwards to flagsClient.bulkEvaluate with mapped flags and entities', async () => {
const bulkEvaluateMock = vi
.fn()
.mockResolvedValue({ a: { value: 'x' }, b: { value: 'y' } });
const fakeClient = {
origin: { provider: 'vercel', sdkKey: 'vf_x' },
bulkEvaluate: bulkEvaluateMock,
} as unknown as typeof flagsClient;

const adapter = createVercelAdapter(fakeClient)();
const result = await adapter.bulkDecide!({
flags: [{ key: 'a', defaultValue: 'da' }, { key: 'b' }],
entities: { user: { id: 'u1' } } as any,
headers: undefined as any,
cookies: undefined as any,
});

expect(bulkEvaluateMock).toHaveBeenCalledTimes(1);
expect(bulkEvaluateMock).toHaveBeenCalledWith(
[
{ key: 'a', defaultValue: 'da' },
{ key: 'b', defaultValue: undefined },
],
{ user: { id: 'u1' } },
);
expect(result).toEqual({ a: 'x', b: 'y' });
});

it('omits keys whose EvaluationResult.value is undefined', async () => {
const fakeClient = {
origin: { provider: 'vercel', sdkKey: 'vf_x' },
bulkEvaluate: vi.fn().mockResolvedValue({
a: { value: 'ok' },
b: { value: undefined, reason: 'error', errorMessage: 'nope' },
}),
} as unknown as typeof flagsClient;

const adapter = createVercelAdapter(fakeClient)();
const result = await adapter.bulkDecide!({
flags: [{ key: 'a' }, { key: 'b' }],
headers: undefined as any,
cookies: undefined as any,
});
expect(result).toEqual({ a: 'ok' });
expect('b' in result).toBe(false);
});
});
});

describe('when used with getProviderData', () => {
Expand Down
47 changes: 37 additions & 10 deletions packages/adapter-vercel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,25 @@ export type VercelAdapterDeclaration<ValueType, EntitiesType> = Omit<
*/
export function createVercelAdapter(
// usually a connection string, but can also be a pre-configured FlagsClient
sdkKeyOrFlagsClient: string | FlagsClient,
sdkKeyOrFlagsClient?: string | FlagsClient,
) {
const flagsClient =
typeof sdkKeyOrFlagsClient === 'string'
typeof sdkKeyOrFlagsClient === 'string' || sdkKeyOrFlagsClient === undefined
? createClient(sdkKeyOrFlagsClient)
: sdkKeyOrFlagsClient;

// Stable identity for this adapter's underlying flagsClient. Captured in
// the closure so every adapter object the factory below returns shares it,
// letting `evaluate()` group flags from multiple `vercelAdapter()` calls
// into a single `bulkDecide` invocation.
const adapterId = Symbol('vercelAdapter');

return function vercelAdapter<ValueType, EntitiesType>(): Adapter<
ValueType,
EntitiesType
> {
return {
adapterId,
origin: flagsClient.origin,
config: { reportValue: false },
async decide({ key, entities }): Promise<ValueType> {
Expand All @@ -57,6 +64,24 @@ export function createVercelAdapter(
// when there was an error but the defaultValue was set
return evaluationResult.value;
},
async bulkDecide({ flags, entities }) {
// `flags` is typed `{ key: string; defaultValue?: unknown }[]` on
// `Adapter.bulkDecide` (to keep `ValueType` covariant). The client
// here narrows it back to `ValueType`; `defaultValue` is shuttled
// through opaquely so the cast is safe.
const results = await flagsClient.bulkEvaluate<ValueType, EntitiesType>(
flags as { key: string; defaultValue?: ValueType }[],
entities,
);
const out: Record<string, ValueType> = {};
for (const key in results) {
const r = results[key]!;
// Omit undefined so the SDK applies the per-flag `defaultValue`
// fallback (matches single-decide semantics).
if (r.value !== undefined) out[key] = r.value;
}
return out;
Comment thread
dferber90 marked this conversation as resolved.
},
};
};
}
Expand Down Expand Up @@ -86,9 +111,13 @@ export function vercelAdapter<ValueType, EntitiesType>(): Adapter<
return defaultVercelAdapter<ValueType, EntitiesType>();
}

const flagsClients = new Map<string, FlagsClient>();
const flagsClients = new Map<string | undefined, FlagsClient>();

function getOrCreateClient(sdkKey: string): FlagsClient {
/**
* Ensures we only ever create a single client per SDK Key
* When undefined is passed, due to OIDC being used, then we return a single client too.
**/
function getOrCreateClient(sdkKey?: string): FlagsClient {
let client = flagsClients.get(sdkKey);
if (!client) {
client = createClient(sdkKey);
Expand All @@ -99,14 +128,12 @@ function getOrCreateClient(sdkKey: string): FlagsClient {

function isVercelOrigin(
origin: unknown,
): origin is { provider: 'vercel'; sdkKey: string } {
): origin is { provider: 'vercel'; sdkKey?: string } {
return (
typeof origin === 'object' &&
origin !== null &&
'provider' in origin &&
(origin as Record<string, unknown>).provider === 'vercel' &&
'sdkKey' in origin &&
typeof (origin as Record<string, unknown>).sdkKey === 'string'
(origin as Record<string, unknown>).provider === 'vercel'
);
}

Expand All @@ -122,14 +149,14 @@ export async function getProviderData(
.filter((i): i is KeyedFlagDefinitionType => !Array.isArray(i));

// Collect unique sdkKeys and resolve their projectIds
const sdkKeys = new Set<string>();
const sdkKeys = new Set<string | undefined>();
for (const d of flagDefs) {
if (isVercelOrigin(d.origin)) {
sdkKeys.add(d.origin.sdkKey);
}
}

const projectIdBySdkKey = new Map<string, string>();
const projectIdBySdkKey = new Map<string | undefined, string>();
await Promise.all(
Array.from(sdkKeys).map(async (sdkKey) => {
const client = getOrCreateClient(sdkKey);
Expand Down
Loading
Loading