[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
This commit is contained in:
Jeremy Dorn
2025-11-07 06:49:04 -06:00
committed by GitHub
parent b187fb3d37
commit 0da9c49fcd
2 changed files with 78 additions and 32 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@flags-sdk/growthbook": minor
---
Auto-refresh flag definitions with a stale-while-revalidate strategy
+73 -32
View File
@@ -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: <T>() => Adapter<T, Attributes>;
initialize: () => Promise<GrowthBookClient>;
refresh: () => Promise<void>;
setTrackingCallback: (cb: TrackingCallback) => void;
setStickyBucketService: (stickyBucketService: StickyBucketService) => void;
stickyBucketService?: StickyBucketService;
@@ -72,49 +74,70 @@ export function createGrowthbookAdapter(options: {
...(options.clientOptions || {}),
});
let _initializePromise: Promise<void> | undefined;
const edgeConfigClient = options.edgeConfig
? createClient(options.edgeConfig.connectionString)
: null;
const edgeConfigKey = options.edgeConfig?.itemKey || options.clientKey;
const initializeGrowthBook = async (): Promise<void> => {
let payload: FeatureApiResponse | string | undefined;
if (options.edgeConfig) {
try {
const edgeConfigClient = createClient(
options.edgeConfig.connectionString,
);
payload = await edgeConfigClient.get<FeatureApiResponse>(
options.edgeConfig.itemKey || options.clientKey,
);
const store = new AsyncLocalStorage<WeakKey>();
const cache = new WeakMap<WeakKey, Promise<FeatureApiResponse | null>>();
const getEdgePayload = async (): Promise<FeatureApiResponse | null> => {
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<FeatureApiResponse | string>(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<void> => {
const payload = await getEdgePayload();
await growthbook.init({
streaming: false,
payload,
payload: payload ?? undefined,
...(options.initOptions || {}),
});
};
let _initializePromise: Promise<void> | undefined;
/**
* Initialize the GrowthBook SDK.
*
@@ -131,6 +154,18 @@ export function createGrowthbookAdapter(options: {
return growthbook;
};
const refresh = async (): Promise<void> => {
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<T, Attributes> {
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) =>