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, + ); } /** diff --git a/skills/flags-sdk/references/providers.md b/skills/flags-sdk/references/providers.md index b3dadc5ad..b88ee8cbc 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. ### `vercel flags` CLI