Skip to content

Commit 0da9c49

Browse files
authored
[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
1 parent b187fb3 commit 0da9c49

File tree

2 files changed

+78
-32
lines changed

2 files changed

+78
-32
lines changed

.changeset/dirty-olives-return.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@flags-sdk/growthbook": minor
3+
---
4+
5+
Auto-refresh flag definitions with a stale-while-revalidate strategy

packages/adapter-growthbook/src/index.ts

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type UserContext,
1212
} from '@growthbook/growthbook';
1313
import { createClient } from '@vercel/edge-config';
14+
import { AsyncLocalStorage } from 'async_hooks';
1415
import type { Adapter } from 'flags';
1516

1617
export { getProviderData } from './provider';
@@ -36,6 +37,7 @@ type EdgeConfig = {
3637
type AdapterResponse = {
3738
feature: <T>() => Adapter<T, Attributes>;
3839
initialize: () => Promise<GrowthBookClient>;
40+
refresh: () => Promise<void>;
3941
setTrackingCallback: (cb: TrackingCallback) => void;
4042
setStickyBucketService: (stickyBucketService: StickyBucketService) => void;
4143
stickyBucketService?: StickyBucketService;
@@ -72,49 +74,70 @@ export function createGrowthbookAdapter(options: {
7274
...(options.clientOptions || {}),
7375
});
7476

75-
let _initializePromise: Promise<void> | undefined;
77+
const edgeConfigClient = options.edgeConfig
78+
? createClient(options.edgeConfig.connectionString)
79+
: null;
80+
const edgeConfigKey = options.edgeConfig?.itemKey || options.clientKey;
7681

77-
const initializeGrowthBook = async (): Promise<void> => {
78-
let payload: FeatureApiResponse | string | undefined;
79-
if (options.edgeConfig) {
80-
try {
81-
const edgeConfigClient = createClient(
82-
options.edgeConfig.connectionString,
83-
);
84-
payload = await edgeConfigClient.get<FeatureApiResponse>(
85-
options.edgeConfig.itemKey || options.clientKey,
86-
);
82+
const store = new AsyncLocalStorage<WeakKey>();
83+
const cache = new WeakMap<WeakKey, Promise<FeatureApiResponse | null>>();
84+
85+
const getEdgePayload = async (): Promise<FeatureApiResponse | null> => {
86+
if (!edgeConfigClient) return null;
87+
88+
// Only do this once per request using AsyncLocalStorage
89+
const currentRequest = store.getStore();
90+
if (currentRequest) {
91+
const cached = cache.get(currentRequest);
92+
if (cached) {
93+
return cached;
94+
}
95+
}
96+
97+
// Fetch from Edge Config
98+
const payloadPromise = edgeConfigClient
99+
.get<FeatureApiResponse | string>(edgeConfigKey)
100+
.then((payload) => {
87101
if (!payload) {
88102
console.error('No payload found in edge config');
103+
return null;
104+
} else if (typeof payload === 'string') {
105+
// Older GrowthBook integrations use WebHooks directly to store
106+
// data in Edge Config, but they store the data as a string.
107+
//
108+
// We need to parse the string to JSON before passing it to GrowthBook.
109+
//
110+
// https://docs.growthbook.io/app/webhooks/sdk-webhooks#vercel-edge-config
111+
// https://github.com/vercel/flags/issues/209
112+
try {
113+
return JSON.parse(payload) as FeatureApiResponse;
114+
} catch {
115+
console.error('Invalid payload format');
116+
return null;
117+
}
118+
} else {
119+
return payload;
89120
}
90-
} catch (e) {
121+
})
122+
.catch((e) => {
91123
console.error('Error fetching edge config', e);
92-
}
93-
}
124+
return null;
125+
});
94126

95-
// Older GrowthBook integrations use WebHooks directly to store
96-
// data in Edge Config, but they store the data as a string.
97-
//
98-
// We need to parse the string to JSON before passing it to GrowthBook.
99-
//
100-
// https://docs.growthbook.io/app/webhooks/sdk-webhooks#vercel-edge-config
101-
// https://github.com/vercel/flags/issues/209
102-
if (typeof payload === 'string') {
103-
try {
104-
payload = JSON.parse(payload) as FeatureApiResponse;
105-
} catch {
106-
console.error('Invalid payload format');
107-
payload = undefined;
108-
}
109-
}
127+
if (currentRequest) cache.set(currentRequest, payloadPromise);
128+
return payloadPromise;
129+
};
110130

131+
const initializeGrowthBook = async (): Promise<void> => {
132+
const payload = await getEdgePayload();
111133
await growthbook.init({
112134
streaming: false,
113-
payload,
135+
payload: payload ?? undefined,
114136
...(options.initOptions || {}),
115137
});
116138
};
117139

140+
let _initializePromise: Promise<void> | undefined;
118141
/**
119142
* Initialize the GrowthBook SDK.
120143
*
@@ -131,6 +154,18 @@ export function createGrowthbookAdapter(options: {
131154
return growthbook;
132155
};
133156

157+
const refresh = async (): Promise<void> => {
158+
if (options.edgeConfig) {
159+
const payload = await getEdgePayload();
160+
if (payload && payload !== growthbook.getPayload()) {
161+
await growthbook.setPayload(payload);
162+
}
163+
} else {
164+
// Init does a refresh with a stale-while-revalidate strategy
165+
await growthbook.init();
166+
}
167+
};
168+
134169
function origin(prefix: string) {
135170
return (key: string) => {
136171
const appOrigin = options.appOrigin || 'https://app.growthbook.io';
@@ -151,8 +186,12 @@ export function createGrowthbookAdapter(options: {
151186
): Adapter<T, Attributes> {
152187
return {
153188
origin: origin('features'),
154-
decide: async ({ key, entities, defaultValue }) => {
155-
await initialize();
189+
decide: async ({ key, entities, defaultValue, headers }) => {
190+
await store.run(headers, async () => {
191+
await initialize();
192+
await refresh();
193+
});
194+
156195
const userContext: UserContext = {
157196
attributes: entities as Attributes,
158197
trackingCallback: opts.exposureLogging ? trackingCallback : undefined,
@@ -186,6 +225,7 @@ export function createGrowthbookAdapter(options: {
186225
return {
187226
feature,
188227
initialize,
228+
refresh,
189229
setTrackingCallback,
190230
setStickyBucketService,
191231
stickyBucketService,
@@ -269,6 +309,7 @@ export function getOrCreateDefaultGrowthbookAdapter(): AdapterResponse {
269309
export const growthbookAdapter: AdapterResponse = {
270310
feature: (...args) => getOrCreateDefaultGrowthbookAdapter().feature(...args),
271311
initialize: () => getOrCreateDefaultGrowthbookAdapter().initialize(),
312+
refresh: () => getOrCreateDefaultGrowthbookAdapter().refresh(),
272313
setTrackingCallback: (...args) =>
273314
getOrCreateDefaultGrowthbookAdapter().setTrackingCallback(...args),
274315
setStickyBucketService: (...args) =>

0 commit comments

Comments
 (0)