From 833b085f46aa44c4cbb1b0d3fb84cf3786738ce1 Mon Sep 17 00:00:00 2001 From: feugy Date: Thu, 10 Sep 2026 18:12:24 +0200 Subject: [PATCH 1/7] Add custom waitUntil to flags client --- .changeset/quiet-flags-wait.md | 8 + packages/vercel-flags-core/CLAUDE.md | 5 +- packages/vercel-flags-core/README.md | 12 -- .../vercel-flags-core/src/black-box.test.ts | 57 ++++++- .../src/controller/normalized-options.ts | 11 ++ .../src/create-raw-client.ts | 11 +- .../vercel-flags-core/src/index.common.ts | 1 + .../vercel-flags-core/src/index.default.ts | 3 +- .../vercel-flags-core/src/index.make.test.ts | 142 +++++++++++++++--- packages/vercel-flags-core/src/index.make.ts | 15 +- .../vercel-flags-core/src/index.next-js.ts | 2 + packages/vercel-flags-core/src/types.ts | 3 + .../vercel-flags-core/src/utils/scheduler.ts | 4 +- .../src/utils/usage-tracker.test.ts | 7 + .../src/utils/usage-tracker.ts | 8 +- 15 files changed, 250 insertions(+), 39 deletions(-) create mode 100644 .changeset/quiet-flags-wait.md diff --git a/.changeset/quiet-flags-wait.md b/.changeset/quiet-flags-wait.md new file mode 100644 index 000000000..14baf1f46 --- /dev/null +++ b/.changeset/quiet-flags-wait.md @@ -0,0 +1,8 @@ +--- +'@vercel/flags-core': patch +--- + +Allow passing a custom `waitUntil` function to `createClient` for background +usage and exposure reporting. Pending exposure reports are drained by +`client.shutdown()`. The Next.js conditional export uses `after` from +`next/server` by default. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index edb770f60..d8724b232 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -90,6 +90,7 @@ type ControllerOptions = { polling?: boolean | { intervalMs: number; initTimeoutMs: number }; // default: true (30s interval, 3s timeout) buildStep?: boolean; // Override build step auto-detection metricEnvironment?: string; // Environment attached to ingested evaluation metrics + waitUntil?: (promise: Promise) => void; // default: @vercel/functions waitUntil sources?: { stream?: StreamSource; polling?: PollingSource; bundled?: BundledSource }; // DI for testing }; ``` @@ -267,7 +268,9 @@ The Controller tags all data with its origin using `tagData(data, origin)` from - Sends to `flags.vercel.com/v1/ingest` - 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) +- Uses a custom `waitUntil()` passed to `createClient`, or defaults to `@vercel/functions` (wrapped in try/catch for resilience) +- The Next.js conditional export defaults to `after()` from `next/server`; an explicit `waitUntil` option always takes precedence +- Exposure reporting does not block evaluation; `shutdown()` drains pending exposure reports for graceful shutdown in long-lived processes - 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()` diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index aa5189ef6..3cad2db8d 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -33,18 +33,6 @@ export default app; Outside Vercel, pass an SDK key explicitly: `createClient(process.env.FLAGS)`. -### Initialization and request-scoped OIDC - -On Vercel, the OIDC token can be supplied through the current request context (`x-vercel-oidc-token`). It is not guaranteed to be available while modules are loading. Creating the client at module scope is safe, but starting `client.initialize()` there can attempt authentication before a request exists. - -Do not store a module-scope initialization promise and await it later in a handler. Calling `initialize()` starts the work immediately; awaiting the promise inside a request does not move that work into the request context. If the promise rejects, every handler awaiting that same promise will reject before reaching evaluation and its fallback handling. - -This also applies when definitions are embedded at build time: with OIDC authentication, the client uses the token's `project_id` claim to select the embedded definitions. An importable `@vercel/flags-definitions` module alone is not enough. - -For local development, `vercel env pull` writes an OIDC token to `.env.local`. If your local runner loads that file, the environment-variable fallback can make module-scope initialization appear to work, even though request-scoped authentication on a deployment requires deferring it. - -Explicit `await client.initialize()` is optional and can be useful when you want to wait for initialization and handle its errors yourself. Only call it once authentication is available: inside a request handler for request-scoped OIDC, or during startup when using an SDK key or an already-available environment token. For normal flag evaluation, prefer calling `evaluate()` or `bulkEvaluate()` directly in the handler. - ## Evaluation Metrics To associate evaluation metrics with an environment, pass the diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 3ade19248..7b24e2340 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -3836,7 +3836,7 @@ describe('Controller (black-box)', () => { await client.shutdown(); }); - it('does not block evaluation while reporting an exposure', async () => { + it('does not block evaluation and drains exposure reporting on shutdown', async () => { let finishReporting: () => void = () => {}; const reporting = new Promise((resolve) => { finishReporting = resolve; @@ -3856,8 +3856,41 @@ describe('Controller (black-box)', () => { ).resolves.toMatchObject({ value: 'treatment-a' }); expect(reportExposures).toHaveBeenCalledOnce(); + let shutdownComplete = false; + const shutdown = Promise.resolve(client.shutdown()).then(() => { + shutdownComplete = true; + }); + await Promise.resolve(); + expect(shutdownComplete).toBe(false); + + finishReporting(); + await shutdown; + expect(shutdownComplete).toBe(true); + }); + + it('registers exposure reporting with a custom waitUntil', async () => { + let finishReporting: () => void = () => {}; + const reporting = new Promise((resolve) => { + finishReporting = resolve; + }); + const waitUntil = vi.fn<(promise: Promise) => void>(); + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + buildStep: true, + datafile: makeBundled({ definitions }), + experimental_reportExposures: () => reporting, + waitUntil, + }); + + await client.evaluate('flagA', undefined, entity); + + expect(waitUntil).toHaveBeenCalledTimes(2); + expect(waitUntil).toHaveBeenNthCalledWith(1, expect.any(Promise)); + expect(waitUntil).toHaveBeenNthCalledWith(2, expect.any(Promise)); + finishReporting(); - await reporting; await client.shutdown(); }); @@ -4060,6 +4093,26 @@ describe('Controller (black-box)', () => { // Usage tracking // --------------------------------------------------------------------------- describe('usage tracking', () => { + it('should use a custom waitUntil function for usage tracking', async () => { + const cleanupCtx = setRequestContext({ host: 'example.com' }); + const waitUntil = vi.fn<(promise: Promise) => void>(); + const client = createClient(sdkKey, { + datafile: makeBundled(), + fetch: fetchMock, + polling: false, + stream: false, + waitUntil, + }); + + await client.evaluate('flagA'); + + expect(waitUntil).toHaveBeenCalledOnce(); + expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise)); + + await client.shutdown(); + cleanupCtx(); + }); + it('should report counted FLAG_EVALUATION events', async () => { const cleanupCtx = setRequestContext({ host: 'example.com' }); fetchMock.mockImplementation((input) => { diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 9cb30632d..a8320316a 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -1,8 +1,10 @@ +import { waitUntil as defaultWaitUntil } from '@vercel/functions'; import type { DatafileInput, MetricEnvironment, PollingOptions, StreamOptions, + WaitUntil, } from '../types'; import type { Auth } from './auth'; @@ -58,6 +60,13 @@ export type ControllerOptions = { */ fetch?: typeof globalThis.fetch; + /** + * Custom function for keeping background work alive after a response has + * been sent. + * @default waitUntil from `@vercel/functions` + */ + waitUntil?: WaitUntil; + /** * Environment included with evaluation metrics sent to the ingest endpoint. * Falls back to the `VERCEL_ENV` environment variable when not set. @@ -84,6 +93,7 @@ export type NormalizedOptions = { polling: { enabled: boolean; intervalMs: number; initTimeoutMs: number }; buildStep: boolean; fetch: typeof globalThis.fetch; + waitUntil: WaitUntil; host: string; metricEnvironment: MetricEnvironment | undefined; clientName: string | undefined; @@ -136,6 +146,7 @@ export function normalizeOptions( polling, buildStep, fetch: options.fetch ?? globalThis.fetch, + waitUntil: options.waitUntil ?? defaultWaitUntil, host: 'https://flags.vercel.com', metricEnvironment: options.metricEnvironment, clientName: options.clientName, diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index d2915c942..9094154bc 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -1,4 +1,4 @@ -import { waitUntil } from '@vercel/functions'; +import { waitUntil as defaultWaitUntil } from '@vercel/functions'; import { dequal } from 'dequal/lite'; import type { bulkEvaluate, @@ -23,6 +23,7 @@ import type { FlagsClient, Packed, Value, + WaitUntil, } from './types'; let idCount = 0; @@ -53,12 +54,15 @@ export function createCreateRawClient(fns: { controller, origin, experimental_reportExposures, + waitUntil = defaultWaitUntil, }: { controller: ControllerInterface; origin?: { provider: string; sdkKey?: string }; experimental_reportExposures?: experimental_ReportExposures; + waitUntil?: WaitUntil; }): FlagsClient { const id = idCount++; + const pendingExposureReports = new Set>(); controllerInstanceMap.set(id, { controller, initialized: false, @@ -81,6 +85,8 @@ export function createCreateRawClient(fns: { ); } })(); + pendingExposureReports.add(pending); + void pending.finally(() => pendingExposureReports.delete(pending)); try { waitUntil(pending); @@ -131,6 +137,9 @@ export function createCreateRawClient(fns: { }, shutdown: async () => { await fns.shutdown(id); + while (pendingExposureReports.size > 0) { + await Promise.all(pendingExposureReports); + } controllerInstanceMap.delete(id); }, getDatafile: async () => { diff --git a/packages/vercel-flags-core/src/index.common.ts b/packages/vercel-flags-core/src/index.common.ts index 1364b89bc..a2fd55dd6 100644 --- a/packages/vercel-flags-core/src/index.common.ts +++ b/packages/vercel-flags-core/src/index.common.ts @@ -28,4 +28,5 @@ export { ResolutionReason as Reason, type StreamOptions, type Value, + type WaitUntil, } from './types'; diff --git a/packages/vercel-flags-core/src/index.default.ts b/packages/vercel-flags-core/src/index.default.ts index b565a5449..cbc0cf249 100644 --- a/packages/vercel-flags-core/src/index.default.ts +++ b/packages/vercel-flags-core/src/index.default.ts @@ -10,6 +10,7 @@ * We do not need to repeat the JSDoc on the next-js export. */ +import { waitUntil } from '@vercel/functions'; import * as fns from './controller-fns'; import { createCreateRawClient } from './create-raw-client'; import { make } from './index.make'; @@ -32,4 +33,4 @@ export const { * Create a flags client using an SDK key, connection string, or Vercel OIDC. */ createClient, -} = make(createCreateRawClient(fns)); +} = make(createCreateRawClient(fns), { waitUntil }); diff --git a/packages/vercel-flags-core/src/index.make.test.ts b/packages/vercel-flags-core/src/index.make.test.ts index 429be61f8..1f72bca30 100644 --- a/packages/vercel-flags-core/src/index.make.test.ts +++ b/packages/vercel-flags-core/src/index.make.test.ts @@ -6,6 +6,7 @@ import { make } from './index.make'; vi.mock('./controller', () => ({ // Controller is instantiated with `new`, so the implementation must be a // function rather than an arrow — Vitest 4 throws "is not a constructor". + // biome-ignore lint/complexity/useArrowFunction: the mock must be constructible Controller: vi.fn().mockImplementation(function ({ auth }) { return { auth, @@ -23,6 +24,8 @@ vi.mock('./controller', () => ({ import { Controller } from './controller'; +const defaultWaitUntil = vi.fn(); + function createMockCreateRawClient(): ReturnType { return vi.fn().mockImplementation(({ controller }) => ({ initialize: vi.fn().mockResolvedValue(undefined), @@ -62,12 +65,15 @@ describe('make', () => { describe('createClient', () => { it('should create a client with a valid SDK key', () => { const createRawClient = createMockCreateRawClient(); - const { createClient } = make(createRawClient); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); const client = createClient('vf_server_test_key'); expect(Controller).toHaveBeenCalledWith({ auth: expect.objectContaining({ sdkKey: 'vf_server_test_key' }), + waitUntil: defaultWaitUntil, }); expect(createRawClient).toHaveBeenCalled(); expect(client).toBeDefined(); @@ -75,7 +81,9 @@ describe('make', () => { it('should create a client from a connection string', () => { const createRawClient = createMockCreateRawClient(); - const { createClient } = make(createRawClient); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); const connectionString = 'flags:edgeConfigId=ecfg_123&edgeConfigToken=token&sdkKey=vf_client_conn_key'; @@ -83,13 +91,16 @@ describe('make', () => { expect(Controller).toHaveBeenCalledWith({ auth: expect.objectContaining({ sdkKey: 'vf_client_conn_key' }), + waitUntil: defaultWaitUntil, }); expect(client).toBeDefined(); }); it('should create an OIDC-authenticated client with options as the first argument', () => { const createRawClient = createMockCreateRawClient(); - const { createClient } = make(createRawClient); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); const client = createClient({ stream: false, polling: false }); @@ -97,17 +108,21 @@ describe('make', () => { auth: expect.objectContaining({ sdkKey: undefined }), stream: false, polling: false, + waitUntil: defaultWaitUntil, }); expect(createRawClient).toHaveBeenCalledWith({ controller: expect.any(Object), origin: { provider: 'vercel', sdkKey: undefined }, + waitUntil: defaultWaitUntil, }); expect(client).toBeDefined(); }); it('should pass clientName to the controller', () => { const createRawClient = createMockCreateRawClient(); - const { createClient } = make(createRawClient); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); const client = createClient('vf_server_test_key', { clientName: 'checkout', @@ -116,13 +131,75 @@ describe('make', () => { expect(Controller).toHaveBeenCalledWith({ auth: expect.objectContaining({ sdkKey: 'vf_server_test_key' }), clientName: 'checkout', + waitUntil: defaultWaitUntil, }); expect(client).toBeDefined(); }); + it('should pass a custom waitUntil to the controller and raw client', () => { + const createRawClient = createMockCreateRawClient(); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); + const waitUntil = vi.fn(); + + createClient('vf_server_test_key', { waitUntil }); + + expect(Controller).toHaveBeenCalledWith({ + auth: expect.objectContaining({ sdkKey: 'vf_server_test_key' }), + waitUntil, + }); + expect(createRawClient).toHaveBeenCalledWith({ + controller: expect.any(Object), + origin: { provider: 'vercel', sdkKey: 'vf_server_test_key' }, + waitUntil, + }); + }); + + it('should use the default waitUntil for the controller and raw client', () => { + const createRawClient = createMockCreateRawClient(); + const waitUntil = vi.fn(); + const { createClient } = make(createRawClient, { waitUntil }); + + createClient('vf_server_test_key'); + + expect(Controller).toHaveBeenCalledWith({ + auth: expect.objectContaining({ sdkKey: 'vf_server_test_key' }), + waitUntil, + }); + expect(createRawClient).toHaveBeenCalledWith({ + controller: expect.any(Object), + origin: { provider: 'vercel', sdkKey: 'vf_server_test_key' }, + waitUntil, + }); + }); + + it('should prefer a custom waitUntil over the default', () => { + const createRawClient = createMockCreateRawClient(); + const defaultWaitUntil = vi.fn(); + const customWaitUntil = vi.fn(); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); + + createClient('vf_server_test_key', { waitUntil: customWaitUntil }); + + expect(Controller).toHaveBeenCalledWith({ + auth: expect.objectContaining({ sdkKey: 'vf_server_test_key' }), + waitUntil: customWaitUntil, + }); + expect(createRawClient).toHaveBeenCalledWith({ + controller: expect.any(Object), + origin: { provider: 'vercel', sdkKey: 'vf_server_test_key' }, + waitUntil: customWaitUntil, + }); + }); + it('should pass experimental_reportExposures to the raw client, not the controller', () => { const createRawClient = createMockCreateRawClient(); - const { createClient } = make(createRawClient); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); const reportExposures = vi.fn(); createClient('vf_server_test_key', { @@ -133,17 +210,21 @@ describe('make', () => { expect(Controller).toHaveBeenCalledWith({ auth: expect.objectContaining({ sdkKey: 'vf_server_test_key' }), stream: false, + waitUntil: defaultWaitUntil, }); expect(createRawClient).toHaveBeenCalledWith({ controller: expect.any(Object), origin: { provider: 'vercel', sdkKey: 'vf_server_test_key' }, experimental_reportExposures: reportExposures, + waitUntil: defaultWaitUntil, }); }); it('should throw for empty SDK key', () => { const createRawClient = createMockCreateRawClient(); - const { createClient } = make(createRawClient); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); expect(() => createClient('')).toThrow( '@vercel/flags-core: Missing sdkKey', @@ -152,7 +233,9 @@ describe('make', () => { it('should throw for invalid connection string', () => { const createRawClient = createMockCreateRawClient(); - const { createClient } = make(createRawClient); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); expect(() => createClient('invalid_string')).toThrow( '@vercel/flags-core: Missing sdkKey', @@ -161,7 +244,9 @@ describe('make', () => { it('should throw for connection string without sdkKey param', () => { const createRawClient = createMockCreateRawClient(); - const { createClient } = make(createRawClient); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); expect(() => createClient('flags:edgeConfigId=ecfg_123&edgeConfigToken=token'), @@ -174,7 +259,9 @@ describe('make', () => { const createRawClient = createMockCreateRawClient(); process.env.FLAGS = 'vf_server_test_key'; - const { flagsClient } = make(createRawClient); + const { flagsClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); // Just getting flagsClient shouldn't create anything expect(createRawClient).not.toHaveBeenCalled(); @@ -189,11 +276,14 @@ describe('make', () => { const createRawClient = createMockCreateRawClient(); delete process.env.FLAGS; - const { flagsClient } = make(createRawClient); + const { flagsClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ auth: expect.objectContaining({ sdkKey: undefined }), + waitUntil: defaultWaitUntil, }); }); @@ -201,7 +291,9 @@ describe('make', () => { const createRawClient = createMockCreateRawClient(); process.env.FLAGS = 'invalid_value'; - const { flagsClient } = make(createRawClient); + const { flagsClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); expect(() => flagsClient.evaluate).toThrow( '@vercel/flags-core: Missing sdkKey', @@ -212,7 +304,9 @@ describe('make', () => { const createRawClient = createMockCreateRawClient(); process.env.FLAGS = 'vf_server_test_key'; - const { flagsClient } = make(createRawClient); + const { flagsClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); // Access multiple properties const _ = flagsClient.evaluate; @@ -227,11 +321,14 @@ describe('make', () => { const createRawClient = createMockCreateRawClient(); process.env.FLAGS = 'vf_server_env_key'; - const { flagsClient } = make(createRawClient); + const { flagsClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ auth: expect.objectContaining({ sdkKey: 'vf_server_env_key' }), + waitUntil: defaultWaitUntil, }); }); @@ -240,11 +337,14 @@ describe('make', () => { process.env.FLAGS = 'flags:edgeConfigId=ecfg_123&edgeConfigToken=token&sdkKey=vf_client_flags_key'; - const { flagsClient } = make(createRawClient); + const { flagsClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ auth: expect.objectContaining({ sdkKey: 'vf_client_flags_key' }), + waitUntil: defaultWaitUntil, }); }); }); @@ -254,7 +354,9 @@ describe('make', () => { const createRawClient = createMockCreateRawClient(); process.env.FLAGS = 'vf_server_test_key'; - const { flagsClient, resetDefaultFlagsClient } = make(createRawClient); + const { flagsClient, resetDefaultFlagsClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); // Access to create client const _ = flagsClient.evaluate; @@ -272,12 +374,15 @@ describe('make', () => { const createRawClient = createMockCreateRawClient(); process.env.FLAGS = 'vf_server_first_key'; - const { flagsClient, resetDefaultFlagsClient } = make(createRawClient); + const { flagsClient, resetDefaultFlagsClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); // Access with first key const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ auth: expect.objectContaining({ sdkKey: 'vf_server_first_key' }), + waitUntil: defaultWaitUntil, }); // Reset and change env @@ -288,6 +393,7 @@ describe('make', () => { const __ = flagsClient.initialize; expect(Controller).toHaveBeenCalledWith({ auth: expect.objectContaining({ sdkKey: 'vf_client_second_key' }), + waitUntil: defaultWaitUntil, }); }); }); @@ -295,7 +401,9 @@ describe('make', () => { describe('integration', () => { it('should return a working client that can call methods', async () => { const createRawClient = createMockCreateRawClient(); - const { createClient } = make(createRawClient); + const { createClient } = make(createRawClient, { + waitUntil: defaultWaitUntil, + }); const client = createClient('vf_server_test_key'); diff --git a/packages/vercel-flags-core/src/index.make.ts b/packages/vercel-flags-core/src/index.make.ts index 9e83ea1c3..9c4970b63 100644 --- a/packages/vercel-flags-core/src/index.make.ts +++ b/packages/vercel-flags-core/src/index.make.ts @@ -5,7 +5,11 @@ import { Controller, type ControllerOptions } from './controller'; import { Authentication } from './controller/auth'; import type { createCreateRawClient } from './create-raw-client'; -import type { experimental_ReportExposures, FlagsClient } from './types'; +import type { + experimental_ReportExposures, + FlagsClient, + WaitUntil, +} from './types'; /** * Options for createClient @@ -35,6 +39,7 @@ type CreateClient = { export function make( createRawClient: ReturnType, + defaults: { waitUntil: WaitUntil }, ): { flagsClient: FlagsClient; resetDefaultFlagsClient: () => void; @@ -69,12 +74,18 @@ export function make( const { experimental_reportExposures, ...controllerOptions } = createClientOptions ?? {}; const auth = new Authentication(sdkKeyOrConnectionString); + const waitUntil = controllerOptions.waitUntil ?? defaults.waitUntil; // sdk key contains the environment - const controller = new Controller({ auth, ...controllerOptions }); + const controller = new Controller({ + auth, + ...controllerOptions, + waitUntil, + }); return createRawClient({ controller, origin: { provider: 'vercel', sdkKey: auth.sdkKey }, + waitUntil, ...(experimental_reportExposures ? { experimental_reportExposures } : {}), }); } diff --git a/packages/vercel-flags-core/src/index.next-js.ts b/packages/vercel-flags-core/src/index.next-js.ts index 00054f7d8..28fba54ac 100644 --- a/packages/vercel-flags-core/src/index.next-js.ts +++ b/packages/vercel-flags-core/src/index.next-js.ts @@ -11,6 +11,7 @@ */ import { cacheLife } from 'next/cache'; +import { after } from 'next/server'; import * as fns from './controller-fns'; import { createCreateRawClient } from './create-raw-client'; import { make } from './index.make'; @@ -70,4 +71,5 @@ export * from './index.common'; // no JSDoc needed here since editors will use the one if index.default.ts export const { flagsClient, resetDefaultFlagsClient, createClient } = make( createCreateRawClient(cachedFns), + { waitUntil: after }, ); diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 2d6dc0be2..6b67468db 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -18,6 +18,9 @@ export type PollingOptions = { initTimeoutMs: number; }; +/** Keeps an asynchronous task alive after a response has been sent */ +export type WaitUntil = (promise: Promise) => void; + /** Input type for creating a datafile (without metrics) */ export type DatafileInput = Packed.Data & { /** diff --git a/packages/vercel-flags-core/src/utils/scheduler.ts b/packages/vercel-flags-core/src/utils/scheduler.ts index c7ce75941..2cf4bb4aa 100644 --- a/packages/vercel-flags-core/src/utils/scheduler.ts +++ b/packages/vercel-flags-core/src/utils/scheduler.ts @@ -1,4 +1,5 @@ import { waitUntil } from '@vercel/functions'; +import type { WaitUntil } from '../types'; import { getJitteredWaitMs } from './backoff'; const IDLE_FLUSH_WAIT_MS = 5000; @@ -27,6 +28,7 @@ export class Scheduler { constructor( private readonly onFlush: (reason: FlushReason) => void | Promise, + private readonly scheduleTask: WaitUntil = waitUntil, ) {} scheduleFlush(): void { @@ -45,7 +47,7 @@ export class Scheduler { })(); try { - waitUntil(this.pending); + this.scheduleTask(this.pending); } catch { // waitUntil is best-effort; falling through leaves a floating promise } diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index ca269d066..564e248bb 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -115,6 +115,7 @@ function createTracker(sdkKey = 'test-key', options?: Partial) { auth: createAuth(sdkKey), host: 'https://example.com', fetch: fetchMock, + waitUntil, ...options, }); } @@ -228,6 +229,7 @@ describe('UsageTracker', () => { auth: createAuth('my-secret-key'), host: 'https://example.com', fetch: fetchMock, + waitUntil, }); tracker.trackRead(); @@ -274,6 +276,7 @@ describe('UsageTracker', () => { }, host: 'https://example.com', fetch: fetchMock, + waitUntil, }); tracker.trackRead(); @@ -378,6 +381,7 @@ describe('UsageTracker', () => { auth: createAuth('test-key'), host: 'https://example.com', fetch: fetchMock, + waitUntil, }); tracker.trackRead(); @@ -416,6 +420,7 @@ describe('UsageTracker', () => { auth: createAuth('test-key'), host: 'https://example.com', fetch: fetchMock, + waitUntil, }); tracker.trackRead(); @@ -906,12 +911,14 @@ describe('UsageTracker', () => { auth: createAuth('key-1'), host: 'https://example.com', fetch: fetchMock, + waitUntil, }); const tracker2 = new UsageTracker({ auth: createAuth('key-2'), host: 'https://example.com', fetch: fetchMock, + waitUntil, }); // Both trackers track with the same request context diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index ab0e78a06..b391fbacf 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -1,3 +1,4 @@ +import type { WaitUntil } from '../types'; import { type IngestOptions, sendIngestEvents } from './ingest'; import { getRequestContext } from './request-context'; import { type FlushReason, Scheduler } from './scheduler'; @@ -26,9 +27,12 @@ export class UsageTracker { private readEvents: FlagsConfigReadEvent[] = []; private evaluationEvents = new Map(); - constructor(options: IngestOptions) { + constructor(options: IngestOptions & { waitUntil: WaitUntil }) { this.options = options; - this.scheduler = new Scheduler((reason) => this.flushEvents(reason)); + this.scheduler = new Scheduler( + (reason) => this.flushEvents(reason), + options.waitUntil, + ); } /** From c1de9b0cc5c6b7b1037a7d38ffa955ec1a8e10a4 Mon Sep 17 00:00:00 2001 From: feugy Date: Mon, 14 Sep 2026 17:26:40 +0200 Subject: [PATCH 2/7] docs(flags-core): document OIDC and waitUntil --- apps/docs/content/docs/providers/meta.json | 1 + .../docs/providers/vercel/core-client.mdx | 161 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 apps/docs/content/docs/providers/vercel/core-client.mdx diff --git a/apps/docs/content/docs/providers/meta.json b/apps/docs/content/docs/providers/meta.json index 15d506d7e..d7344b567 100644 --- a/apps/docs/content/docs/providers/meta.json +++ b/apps/docs/content/docs/providers/meta.json @@ -8,6 +8,7 @@ "------", "---Featured---", "vercel", + "vercel/core-client", "statsig", "hypertune", "growthbook", diff --git a/apps/docs/content/docs/providers/vercel/core-client.mdx b/apps/docs/content/docs/providers/vercel/core-client.mdx new file mode 100644 index 000000000..20fd290de --- /dev/null +++ b/apps/docs/content/docs/providers/vercel/core-client.mdx @@ -0,0 +1,161 @@ +--- +title: '@vercel/flags-core' +description: 'Use the low-level Vercel Flags client with request-scoped OIDC authentication and custom background task scheduling.' +--- + +`@vercel/flags-core` is the low-level evaluation client for Vercel Flags. Use it when your framework does not have a Flags SDK integration, when you need direct control over the client lifecycle, or when you use the OpenFeature standard. + +For Next.js and SvelteKit applications, prefer the Flags SDK with the [Vercel adapter](/providers/vercel). It integrates Vercel Flags with flag declarations, overrides, and provider metadata. + +## Installation + +Install the core client: + +```bash +pnpm i @vercel/flags-core +``` + +## Create a client + +Create one shared client at module scope. On Vercel, calling `createClient()` without an SDK key uses the deployment's OpenID Connect (OIDC) token: + +```ts title="src/flags.ts" +import { createClient } from '@vercel/flags-core'; + +export const flagsClient = createClient(); +``` + +Evaluate flags inside a request handler. `evaluate()` and `bulkEvaluate()` initialize the client automatically on first use: + +```ts title="src/server.ts" +import express from 'express'; +import { flagsClient } from './flags'; + +const app = express(); + +app.get('/api/feature', async (_request, response) => { + const result = await flagsClient.evaluate( + 'show-new-feature', + false, + ); + + response.json({ enabled: result.value }); +}); + +export default app; +``` + +Outside Vercel, pass an SDK key explicitly: + +```ts +const flagsClient = createClient(process.env.FLAGS); +``` + +## Use request-scoped OIDC + +On Vercel, the OIDC token can come from the current request context through the `x-vercel-oidc-token` header. The token is not guaranteed to exist while modules load. + +Creating a client at module scope is safe because the client initializes lazily. Do not call `initialize()` at module scope when you use OIDC: + +```ts +import { createClient } from '@vercel/flags-core'; + +const flagsClient = createClient(); + +// Do not start initialization here. A request-scoped OIDC token might not +// be available yet. +const initialization = flagsClient.initialize(); +``` + +Awaiting a module-scoped initialization promise inside a request handler does not move the initialization into that request's context. If initialization fails, each handler that awaits the same promise also fails before evaluation can use its fallback behavior. + +Instead, call `evaluate()` or `bulkEvaluate()` in the request handler. If you need to handle initialization errors yourself, call `initialize()` in the handler before evaluation: + +```ts +export async function handleRequest(): Promise { + await flagsClient.initialize(); + + const result = await flagsClient.evaluate( + 'show-new-feature', + false, + ); + + return Response.json({ enabled: result.value }); +} +``` + +This requirement also applies when your build embeds flag definitions. An OIDC-authenticated client uses the token's `project_id` claim to select the correct embedded definitions. Importing `@vercel/flags-definitions` does not remove the need to resolve the token at request time. + +For local development, link the project and pull its environment variables: + +```bash +vercel link +vercel env pull +``` + +The pull command writes a Vercel OIDC token to `.env.local`. A local runner that loads this file can make module-scope initialization appear to work. A deployment can still require request-scoped authentication, so keep evaluation and explicit initialization inside the request handler. + +## Configure background work + +The client reports usage and experiment exposures in the background. It uses a `waitUntil` function to keep this work alive after the response is sent. + +The default depends on your runtime: + +| Runtime | Default | +| --- | --- | +| Next.js | [`after`](https://nextjs.org/docs/app/api-reference/functions/after) from `next/server` | +| Other runtimes | `waitUntil` from `@vercel/functions` | + +You do not need to configure `waitUntil` for these runtimes. For another platform or runtime, pass a compatible function to `createClient`: + +```ts +import { createClient } from '@vercel/flags-core'; + +const flagsClient = createClient(process.env.FLAGS, { + waitUntil(promise) { + backgroundTasks.register(promise); + }, +}); +``` + +The function must accept a `Promise` and keep the process or request alive until that promise settles. An explicit `waitUntil` option takes precedence over the runtime default. + +Background reporting does not block flag evaluation. In a long-lived process, call `shutdown()` during graceful shutdown. It flushes usage data, closes active connections, and waits for pending exposure reports: + +```ts +process.on('SIGTERM', async () => { + await flagsClient.shutdown(); + process.exit(0); +}); +``` + +## Configure evaluation metrics + +Use `metricEnvironment` to attach an environment to evaluation metrics: + +```ts +const flagsClient = createClient(process.env.FLAGS, { + metricEnvironment: 'preview', +}); +``` + +This option affects only the metrics sent to the ingestion endpoint. It does not select the environment used for flag evaluation. + +## Use OpenFeature + +The package includes an OpenFeature-compatible provider at `@vercel/flags-core/openfeature`: + +```ts +import { OpenFeature } from '@openfeature/server-sdk'; +import { VercelProvider } from '@vercel/flags-core/openfeature'; + +await OpenFeature.setProviderAndWait(new VercelProvider()); + +const flagsClient = OpenFeature.getClient(); +``` + +## Next steps + +- [Configure the Vercel adapter](/providers/vercel) +- [Learn about evaluation context](/principles/evaluation-context) +- [Review server-side and client-side evaluation](/principles/server-side-vs-client-side) From 612788f35314e475d895f19fbb2c002e5b0284c6 Mon Sep 17 00:00:00 2001 From: feugy Date: Mon, 14 Sep 2026 17:32:29 +0200 Subject: [PATCH 3/7] docs(flags-core): correct lifecycle guidance --- .../docs/providers/vercel/core-client.mdx | 26 +++++++++---------- skills/flags-sdk/references/providers.md | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/docs/content/docs/providers/vercel/core-client.mdx b/apps/docs/content/docs/providers/vercel/core-client.mdx index 20fd290de..ca81eb54a 100644 --- a/apps/docs/content/docs/providers/vercel/core-client.mdx +++ b/apps/docs/content/docs/providers/vercel/core-client.mdx @@ -48,7 +48,7 @@ export default app; Outside Vercel, pass an SDK key explicitly: ```ts -const flagsClient = createClient(process.env.FLAGS); +const flagsClient = createClient(process.env.FLAGS!); ``` ## Use request-scoped OIDC @@ -97,7 +97,7 @@ The pull command writes a Vercel OIDC token to `.env.local`. A local runner that ## Configure background work -The client reports usage and experiment exposures in the background. It uses a `waitUntil` function to keep this work alive after the response is sent. +The client reports usage in the background. If you configure `experimental_reportExposures`, it also reports experiment exposures in the background. The client uses a `waitUntil` function to keep this work alive after the response is sent. The default depends on your runtime: @@ -109,18 +109,16 @@ The default depends on your runtime: You do not need to configure `waitUntil` for these runtimes. For another platform or runtime, pass a compatible function to `createClient`: ```ts -import { createClient } from '@vercel/flags-core'; +import { createClient, type WaitUntil } from '@vercel/flags-core'; -const flagsClient = createClient(process.env.FLAGS, { - waitUntil(promise) { - backgroundTasks.register(promise); - }, -}); +export function createFlagsClient(waitUntil: WaitUntil) { + return createClient(process.env.FLAGS!, { waitUntil }); +} ``` -The function must accept a `Promise` and keep the process or request alive until that promise settles. An explicit `waitUntil` option takes precedence over the runtime default. +Pass the platform's lifecycle function when you create the client. The function must accept a `Promise` and keep the process or request alive until that promise settles. An explicit `waitUntil` option takes precedence over the runtime default. -Background reporting does not block flag evaluation. In a long-lived process, call `shutdown()` during graceful shutdown. It flushes usage data, closes active connections, and waits for pending exposure reports: +Background reporting does not block flag evaluation. In a long-lived process, call `shutdown()` during graceful shutdown. It flushes usage data and closes active connections. If you configure `experimental_reportExposures`, it also waits for pending exposure reports: ```ts process.on('SIGTERM', async () => { @@ -134,7 +132,7 @@ process.on('SIGTERM', async () => { Use `metricEnvironment` to attach an environment to evaluation metrics: ```ts -const flagsClient = createClient(process.env.FLAGS, { +const flagsClient = createClient(process.env.FLAGS!, { metricEnvironment: 'preview', }); ``` @@ -143,13 +141,15 @@ This option affects only the metrics sent to the ingestion endpoint. It does not ## Use OpenFeature -The package includes an OpenFeature-compatible provider at `@vercel/flags-core/openfeature`: +The package includes an OpenFeature-compatible provider at `@vercel/flags-core/openfeature`. When you initialize the provider during application startup, pass an SDK key because a request-scoped OIDC token is not available yet: ```ts import { OpenFeature } from '@openfeature/server-sdk'; import { VercelProvider } from '@vercel/flags-core/openfeature'; -await OpenFeature.setProviderAndWait(new VercelProvider()); +await OpenFeature.setProviderAndWait( + new VercelProvider(process.env.FLAGS!), +); const flagsClient = OpenFeature.getClient(); ``` diff --git a/skills/flags-sdk/references/providers.md b/skills/flags-sdk/references/providers.md index a35756dea..1ead4bec7 100644 --- a/skills/flags-sdk/references/providers.md +++ b/skills/flags-sdk/references/providers.md @@ -131,7 +131,7 @@ export async function getVersion(): Promise { } ``` -With Vercel OIDC, the token can come from request context and may not exist during module loading. Even embedded definitions require OIDC to select the entry by the token's `project_id`. Local `.env.local` credentials can hide this timing problem. Explicit initialization is optional and must wait until authentication is available; awaiting an already-started initialization promise later does not move it into request context. See the [core client README](https://github.com/vercel/flags/tree/main/packages/vercel-flags-core#initialization-and-request-scoped-oidc). +With Vercel OIDC, the token can come from request context and may not exist during module loading. Even embedded definitions require OIDC to select the entry by the token's `project_id`. Local `.env.local` credentials can hide this timing problem. Explicit initialization is optional and must wait until authentication is available; awaiting an already-started initialization promise later does not move it into request context. See the [`@vercel/flags-core` guide](https://flags-sdk.dev/providers/vercel/core-client#use-request-scoped-oidc). ### `vercel flags` CLI From 38cc0b8985edaaded5528fb35efbf84cddb58ed7 Mon Sep 17 00:00:00 2001 From: feugy Date: Mon, 14 Sep 2026 17:34:45 +0200 Subject: [PATCH 4/7] docs(flags-core): hide exposure reporting --- apps/docs/content/docs/providers/vercel/core-client.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/providers/vercel/core-client.mdx b/apps/docs/content/docs/providers/vercel/core-client.mdx index ca81eb54a..b83d173b0 100644 --- a/apps/docs/content/docs/providers/vercel/core-client.mdx +++ b/apps/docs/content/docs/providers/vercel/core-client.mdx @@ -97,7 +97,7 @@ The pull command writes a Vercel OIDC token to `.env.local`. A local runner that ## Configure background work -The client reports usage in the background. If you configure `experimental_reportExposures`, it also reports experiment exposures in the background. The client uses a `waitUntil` function to keep this work alive after the response is sent. +The client reports usage in the background. It uses a `waitUntil` function to keep this work alive after the response is sent. The default depends on your runtime: @@ -118,7 +118,7 @@ export function createFlagsClient(waitUntil: WaitUntil) { Pass the platform's lifecycle function when you create the client. The function must accept a `Promise` and keep the process or request alive until that promise settles. An explicit `waitUntil` option takes precedence over the runtime default. -Background reporting does not block flag evaluation. In a long-lived process, call `shutdown()` during graceful shutdown. It flushes usage data and closes active connections. If you configure `experimental_reportExposures`, it also waits for pending exposure reports: +Background reporting does not block flag evaluation. In a long-lived process, call `shutdown()` during graceful shutdown. It flushes usage data and closes active connections: ```ts process.on('SIGTERM', async () => { From a5228ee064c7d88b2bf3a64ad650d4ea137c913e Mon Sep 17 00:00:00 2001 From: feugy Date: Tue, 15 Sep 2026 11:45:46 +0200 Subject: [PATCH 5/7] docs: move core client out of providers navigation --- apps/docs/content/docs/providers/meta.json | 1 - apps/docs/content/docs/providers/vercel.mdx | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/docs/providers/meta.json b/apps/docs/content/docs/providers/meta.json index d7344b567..15d506d7e 100644 --- a/apps/docs/content/docs/providers/meta.json +++ b/apps/docs/content/docs/providers/meta.json @@ -8,7 +8,6 @@ "------", "---Featured---", "vercel", - "vercel/core-client", "statsig", "hypertune", "growthbook", diff --git a/apps/docs/content/docs/providers/vercel.mdx b/apps/docs/content/docs/providers/vercel.mdx index a8de3155e..b6c0ef7f4 100644 --- a/apps/docs/content/docs/providers/vercel.mdx +++ b/apps/docs/content/docs/providers/vercel.mdx @@ -163,4 +163,5 @@ export const exampleFlag = flag({ ## Additional resources - [Vercel Flags](https://vercel.com/docs/flags/vercel-flags) +- [Use the `@vercel/flags-core` client](/providers/vercel/core-client) - [@flags-sdk/vercel](https://github.com/vercel/flags/tree/main/packages/adapter-vercel) From 8adf0f4ac2315e00e7629fb68b5dffbfb21842cc Mon Sep 17 00:00:00 2001 From: feugy Date: Tue, 15 Sep 2026 14:37:49 +0200 Subject: [PATCH 6/7] docs(flags-core): remove standalone client guide --- apps/docs/content/docs/providers/vercel.mdx | 1 - .../docs/providers/vercel/core-client.mdx | 161 ------------------ 2 files changed, 162 deletions(-) delete mode 100644 apps/docs/content/docs/providers/vercel/core-client.mdx diff --git a/apps/docs/content/docs/providers/vercel.mdx b/apps/docs/content/docs/providers/vercel.mdx index b6c0ef7f4..a8de3155e 100644 --- a/apps/docs/content/docs/providers/vercel.mdx +++ b/apps/docs/content/docs/providers/vercel.mdx @@ -163,5 +163,4 @@ export const exampleFlag = flag({ ## Additional resources - [Vercel Flags](https://vercel.com/docs/flags/vercel-flags) -- [Use the `@vercel/flags-core` client](/providers/vercel/core-client) - [@flags-sdk/vercel](https://github.com/vercel/flags/tree/main/packages/adapter-vercel) diff --git a/apps/docs/content/docs/providers/vercel/core-client.mdx b/apps/docs/content/docs/providers/vercel/core-client.mdx deleted file mode 100644 index b83d173b0..000000000 --- a/apps/docs/content/docs/providers/vercel/core-client.mdx +++ /dev/null @@ -1,161 +0,0 @@ ---- -title: '@vercel/flags-core' -description: 'Use the low-level Vercel Flags client with request-scoped OIDC authentication and custom background task scheduling.' ---- - -`@vercel/flags-core` is the low-level evaluation client for Vercel Flags. Use it when your framework does not have a Flags SDK integration, when you need direct control over the client lifecycle, or when you use the OpenFeature standard. - -For Next.js and SvelteKit applications, prefer the Flags SDK with the [Vercel adapter](/providers/vercel). It integrates Vercel Flags with flag declarations, overrides, and provider metadata. - -## Installation - -Install the core client: - -```bash -pnpm i @vercel/flags-core -``` - -## Create a client - -Create one shared client at module scope. On Vercel, calling `createClient()` without an SDK key uses the deployment's OpenID Connect (OIDC) token: - -```ts title="src/flags.ts" -import { createClient } from '@vercel/flags-core'; - -export const flagsClient = createClient(); -``` - -Evaluate flags inside a request handler. `evaluate()` and `bulkEvaluate()` initialize the client automatically on first use: - -```ts title="src/server.ts" -import express from 'express'; -import { flagsClient } from './flags'; - -const app = express(); - -app.get('/api/feature', async (_request, response) => { - const result = await flagsClient.evaluate( - 'show-new-feature', - false, - ); - - response.json({ enabled: result.value }); -}); - -export default app; -``` - -Outside Vercel, pass an SDK key explicitly: - -```ts -const flagsClient = createClient(process.env.FLAGS!); -``` - -## Use request-scoped OIDC - -On Vercel, the OIDC token can come from the current request context through the `x-vercel-oidc-token` header. The token is not guaranteed to exist while modules load. - -Creating a client at module scope is safe because the client initializes lazily. Do not call `initialize()` at module scope when you use OIDC: - -```ts -import { createClient } from '@vercel/flags-core'; - -const flagsClient = createClient(); - -// Do not start initialization here. A request-scoped OIDC token might not -// be available yet. -const initialization = flagsClient.initialize(); -``` - -Awaiting a module-scoped initialization promise inside a request handler does not move the initialization into that request's context. If initialization fails, each handler that awaits the same promise also fails before evaluation can use its fallback behavior. - -Instead, call `evaluate()` or `bulkEvaluate()` in the request handler. If you need to handle initialization errors yourself, call `initialize()` in the handler before evaluation: - -```ts -export async function handleRequest(): Promise { - await flagsClient.initialize(); - - const result = await flagsClient.evaluate( - 'show-new-feature', - false, - ); - - return Response.json({ enabled: result.value }); -} -``` - -This requirement also applies when your build embeds flag definitions. An OIDC-authenticated client uses the token's `project_id` claim to select the correct embedded definitions. Importing `@vercel/flags-definitions` does not remove the need to resolve the token at request time. - -For local development, link the project and pull its environment variables: - -```bash -vercel link -vercel env pull -``` - -The pull command writes a Vercel OIDC token to `.env.local`. A local runner that loads this file can make module-scope initialization appear to work. A deployment can still require request-scoped authentication, so keep evaluation and explicit initialization inside the request handler. - -## Configure background work - -The client reports usage in the background. It uses a `waitUntil` function to keep this work alive after the response is sent. - -The default depends on your runtime: - -| Runtime | Default | -| --- | --- | -| Next.js | [`after`](https://nextjs.org/docs/app/api-reference/functions/after) from `next/server` | -| Other runtimes | `waitUntil` from `@vercel/functions` | - -You do not need to configure `waitUntil` for these runtimes. For another platform or runtime, pass a compatible function to `createClient`: - -```ts -import { createClient, type WaitUntil } from '@vercel/flags-core'; - -export function createFlagsClient(waitUntil: WaitUntil) { - return createClient(process.env.FLAGS!, { waitUntil }); -} -``` - -Pass the platform's lifecycle function when you create the client. The function must accept a `Promise` and keep the process or request alive until that promise settles. An explicit `waitUntil` option takes precedence over the runtime default. - -Background reporting does not block flag evaluation. In a long-lived process, call `shutdown()` during graceful shutdown. It flushes usage data and closes active connections: - -```ts -process.on('SIGTERM', async () => { - await flagsClient.shutdown(); - process.exit(0); -}); -``` - -## Configure evaluation metrics - -Use `metricEnvironment` to attach an environment to evaluation metrics: - -```ts -const flagsClient = createClient(process.env.FLAGS!, { - metricEnvironment: 'preview', -}); -``` - -This option affects only the metrics sent to the ingestion endpoint. It does not select the environment used for flag evaluation. - -## Use OpenFeature - -The package includes an OpenFeature-compatible provider at `@vercel/flags-core/openfeature`. When you initialize the provider during application startup, pass an SDK key because a request-scoped OIDC token is not available yet: - -```ts -import { OpenFeature } from '@openfeature/server-sdk'; -import { VercelProvider } from '@vercel/flags-core/openfeature'; - -await OpenFeature.setProviderAndWait( - new VercelProvider(process.env.FLAGS!), -); - -const flagsClient = OpenFeature.getClient(); -``` - -## Next steps - -- [Configure the Vercel adapter](/providers/vercel) -- [Learn about evaluation context](/principles/evaluation-context) -- [Review server-side and client-side evaluation](/principles/server-side-vs-client-side) From 921c10fd2f66e281804e285573e6f139e1fa5210 Mon Sep 17 00:00:00 2001 From: feugy Date: Tue, 15 Sep 2026 14:59:16 +0200 Subject: [PATCH 7/7] docs(flags-core): remove stale guide link --- skills/flags-sdk/references/providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/flags-sdk/references/providers.md b/skills/flags-sdk/references/providers.md index 1ead4bec7..882d75101 100644 --- a/skills/flags-sdk/references/providers.md +++ b/skills/flags-sdk/references/providers.md @@ -131,7 +131,7 @@ export async function getVersion(): Promise { } ``` -With Vercel OIDC, the token can come from request context and may not exist during module loading. Even embedded definitions require OIDC to select the entry by the token's `project_id`. Local `.env.local` credentials can hide this timing problem. Explicit initialization is optional and must wait until authentication is available; awaiting an already-started initialization promise later does not move it into request context. See the [`@vercel/flags-core` guide](https://flags-sdk.dev/providers/vercel/core-client#use-request-scoped-oidc). +With Vercel OIDC, the token can come from request context and may not exist during module loading. Even embedded definitions require OIDC to select the entry by the token's `project_id`. Local `.env.local` credentials can hide this timing problem. Explicit initialization is optional and must wait until authentication is available; awaiting an already-started initialization promise later does not move it into request context. ### `vercel flags` CLI