From 0da9c49fcdfd3d84371d8eb4f37365e654813eaf Mon Sep 17 00:00:00 2001 From: Jeremy Dorn Date: Fri, 7 Nov 2025 06:49:04 -0600 Subject: [PATCH] [GrowthBook provider] Auto refresh flags with a stale-while-revalidate strategy (#148) * Auto refresh features with a stale-while-revalidate strategy * Update default cache TTL for a better developer experience * Reduce latency for edge config * Add changeset entry * Treat edge config client as a singleton * Use AsyncLocalStorage to only read from edgeConfig once per request --- .changeset/dirty-olives-return.md | 5 ++ packages/adapter-growthbook/src/index.ts | 105 ++++++++++++++++------- 2 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 .changeset/dirty-olives-return.md diff --git a/.changeset/dirty-olives-return.md b/.changeset/dirty-olives-return.md new file mode 100644 index 00000000..134dd92b --- /dev/null +++ b/.changeset/dirty-olives-return.md @@ -0,0 +1,5 @@ +--- +"@flags-sdk/growthbook": minor +--- + +Auto-refresh flag definitions with a stale-while-revalidate strategy diff --git a/packages/adapter-growthbook/src/index.ts b/packages/adapter-growthbook/src/index.ts index 9819c0d2..ba78f1a9 100644 --- a/packages/adapter-growthbook/src/index.ts +++ b/packages/adapter-growthbook/src/index.ts @@ -11,6 +11,7 @@ import { type UserContext, } from '@growthbook/growthbook'; import { createClient } from '@vercel/edge-config'; +import { AsyncLocalStorage } from 'async_hooks'; import type { Adapter } from 'flags'; export { getProviderData } from './provider'; @@ -36,6 +37,7 @@ type EdgeConfig = { type AdapterResponse = { feature: () => Adapter; initialize: () => Promise; + refresh: () => Promise; setTrackingCallback: (cb: TrackingCallback) => void; setStickyBucketService: (stickyBucketService: StickyBucketService) => void; stickyBucketService?: StickyBucketService; @@ -72,49 +74,70 @@ export function createGrowthbookAdapter(options: { ...(options.clientOptions || {}), }); - let _initializePromise: Promise | undefined; + const edgeConfigClient = options.edgeConfig + ? createClient(options.edgeConfig.connectionString) + : null; + const edgeConfigKey = options.edgeConfig?.itemKey || options.clientKey; - const initializeGrowthBook = async (): Promise => { - let payload: FeatureApiResponse | string | undefined; - if (options.edgeConfig) { - try { - const edgeConfigClient = createClient( - options.edgeConfig.connectionString, - ); - payload = await edgeConfigClient.get( - options.edgeConfig.itemKey || options.clientKey, - ); + const store = new AsyncLocalStorage(); + const cache = new WeakMap>(); + + const getEdgePayload = async (): Promise => { + if (!edgeConfigClient) return null; + + // Only do this once per request using AsyncLocalStorage + const currentRequest = store.getStore(); + if (currentRequest) { + const cached = cache.get(currentRequest); + if (cached) { + return cached; + } + } + + // Fetch from Edge Config + const payloadPromise = edgeConfigClient + .get(edgeConfigKey) + .then((payload) => { if (!payload) { console.error('No payload found in edge config'); + return null; + } else if (typeof payload === 'string') { + // Older GrowthBook integrations use WebHooks directly to store + // data in Edge Config, but they store the data as a string. + // + // We need to parse the string to JSON before passing it to GrowthBook. + // + // https://docs.growthbook.io/app/webhooks/sdk-webhooks#vercel-edge-config + // https://github.com/vercel/flags/issues/209 + try { + return JSON.parse(payload) as FeatureApiResponse; + } catch { + console.error('Invalid payload format'); + return null; + } + } else { + return payload; } - } catch (e) { + }) + .catch((e) => { console.error('Error fetching edge config', e); - } - } + return null; + }); - // Older GrowthBook integrations use WebHooks directly to store - // data in Edge Config, but they store the data as a string. - // - // We need to parse the string to JSON before passing it to GrowthBook. - // - // https://docs.growthbook.io/app/webhooks/sdk-webhooks#vercel-edge-config - // https://github.com/vercel/flags/issues/209 - if (typeof payload === 'string') { - try { - payload = JSON.parse(payload) as FeatureApiResponse; - } catch { - console.error('Invalid payload format'); - payload = undefined; - } - } + if (currentRequest) cache.set(currentRequest, payloadPromise); + return payloadPromise; + }; + const initializeGrowthBook = async (): Promise => { + const payload = await getEdgePayload(); await growthbook.init({ streaming: false, - payload, + payload: payload ?? undefined, ...(options.initOptions || {}), }); }; + let _initializePromise: Promise | undefined; /** * Initialize the GrowthBook SDK. * @@ -131,6 +154,18 @@ export function createGrowthbookAdapter(options: { return growthbook; }; + const refresh = async (): Promise => { + if (options.edgeConfig) { + const payload = await getEdgePayload(); + if (payload && payload !== growthbook.getPayload()) { + await growthbook.setPayload(payload); + } + } else { + // Init does a refresh with a stale-while-revalidate strategy + await growthbook.init(); + } + }; + function origin(prefix: string) { return (key: string) => { const appOrigin = options.appOrigin || 'https://app.growthbook.io'; @@ -151,8 +186,12 @@ export function createGrowthbookAdapter(options: { ): Adapter { return { origin: origin('features'), - decide: async ({ key, entities, defaultValue }) => { - await initialize(); + decide: async ({ key, entities, defaultValue, headers }) => { + await store.run(headers, async () => { + await initialize(); + await refresh(); + }); + const userContext: UserContext = { attributes: entities as Attributes, trackingCallback: opts.exposureLogging ? trackingCallback : undefined, @@ -186,6 +225,7 @@ export function createGrowthbookAdapter(options: { return { feature, initialize, + refresh, setTrackingCallback, setStickyBucketService, stickyBucketService, @@ -269,6 +309,7 @@ export function getOrCreateDefaultGrowthbookAdapter(): AdapterResponse { export const growthbookAdapter: AdapterResponse = { feature: (...args) => getOrCreateDefaultGrowthbookAdapter().feature(...args), initialize: () => getOrCreateDefaultGrowthbookAdapter().initialize(), + refresh: () => getOrCreateDefaultGrowthbookAdapter().refresh(), setTrackingCallback: (...args) => getOrCreateDefaultGrowthbookAdapter().setTrackingCallback(...args), setStickyBucketService: (...args) =>