mirror of
https://github.com/vercel/flags.git
synced 2026-09-19 03:49:23 +08:00
Add GrowthBook Adapter (#133)
* initial wip adapter * default adapter * provider * provider test * tsc issues * tsc issues * tsc issues * tsc * feature type safety * lock * rm .js / .d.ts / .map * fix test * readme * edge adapter * readme typo * update gb sdk ver * lock * lock * fix changelog, package.json * tighten feature() return type, remove userId from callback * fix defaultValue handling * fix decide typo * Support EXPERIMENTATION_CONFIG Co-authored-by: Romain Beauxis <romain@growthbook.io> * sticky bucket support, readme, cleanup * changeset * update readme * support client-side tracking, export types --------- Co-authored-by: Romain Beauxis <romain@growthbook.io>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@flags-sdk/growthbook': minor
|
||||
---
|
||||
|
||||
initialize
|
||||
@@ -10,6 +10,7 @@ npm-debug.log
|
||||
.turbo
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
.vercel
|
||||
.env*.local
|
||||
.svelte-kit
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# @flags-sdk/growthbook
|
||||
@@ -0,0 +1,69 @@
|
||||
# Flags SDK — GrowthBook Provider
|
||||
|
||||
The [GrowthBook provider](https://flags-sdk.dev/docs/api-reference/adapters/growthbook) for the [Flags SDK](https://flags-sdk.dev/) contains support for GrowthBook's Feature Flags and Experiments.
|
||||
|
||||
## Setup
|
||||
|
||||
The GrowthBook provider is available in the `@flags-sdk/growthbook` module. You can install it with
|
||||
|
||||
```bash
|
||||
pnpm i @flags-sdk/growthbook
|
||||
```
|
||||
|
||||
## Provider Instance
|
||||
|
||||
You can import the default adapter instance `growthbookAdapter` from `@flags-sdk/growthbook`:
|
||||
|
||||
```ts
|
||||
import { growthBookAdapter } from '@flags-sdk/growthbook';
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
import { flag } from 'flags/next';
|
||||
import { growthBookAdapter } from '@flags-sdk/growthbook';
|
||||
|
||||
export const summerBannerFlag = feature<boolean>({
|
||||
key: 'summer-banner',
|
||||
adapter: growthBookAdapter.feature(),
|
||||
});
|
||||
```
|
||||
|
||||
## Experimentation (A/B Testing)
|
||||
|
||||
In order to run GrowthBook experiments, you must define a back-end tracking callback function. This is called every time a user is put into an experiment and can be used to track the exposure event in your analytics system. We recommend defining this callback in your flag definition file (e.g. `flags.ts`).
|
||||
|
||||
```ts
|
||||
import { after } from 'next/server';
|
||||
|
||||
growthbookAdapter.setTrackingCallback((experiment, result) => {
|
||||
// Safely fire and forget async calls (Next.js)
|
||||
after(async () => {
|
||||
console.log('Viewed Experiment', {
|
||||
experimentId: experiment.key,
|
||||
variationId: result.key,
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
In some situations, you may prefer to use a front-end tracking callback. This requires a bit of implementation glue. See the Vercel [Next.js + GrowthBook example](https://github.com/vercel/examples/tree/main/flags-sdk/growthbook) for details.
|
||||
|
||||
### Sticky Bucketing
|
||||
|
||||
To implement sticky bucketing (required for Bandits), you may create any `StickyBucketService` instance and apply it to the adapter (recommended in `flags.ts`). We recommend using our Redis service for ease of implementation.
|
||||
|
||||
```ts
|
||||
import { RedisStickyBucketService } from '@growthbook/growthbook';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redis = new Redis(process.env.REDIS_CONNECTION_URL);
|
||||
const redisStickyBucketService = new RedisStickyBucketService({ redis });
|
||||
|
||||
growthbookAdapter.setStickyBucketService(redisStickyBucketService);
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Please check out the [GrowthBook provider documentation](https://flags-sdk.dev/docs/api-reference/adapters/growthbook) for more information.
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@flags-sdk/growthbook",
|
||||
"version": "0.0.1",
|
||||
"description": "GrowthBook provider for the Flags SDK",
|
||||
"homepage": "https://flags-sdk.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vercel/flags/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel/flags.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./provider": {
|
||||
"types": "./dist/provider/index.d.ts",
|
||||
"import": "./dist/provider/index.js",
|
||||
"require": "./dist/provider/index.cjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
".": [
|
||||
"./dist/index.d.ts",
|
||||
"./dist/index.d.cts"
|
||||
],
|
||||
"provider": [
|
||||
"./dist/provider/index.d.ts",
|
||||
"./dist/provider/index.d.cts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsup",
|
||||
"dev": "tsup --watch --clean=false",
|
||||
"eslint": "eslint-runner",
|
||||
"eslint:fix": "eslint-runner --fix",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@growthbook/growthbook": "^1.5.1",
|
||||
"@vercel/edge-config": "^1.2.0",
|
||||
"@vercel/functions": "^1.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.11.17",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"flags": "workspace:*",
|
||||
"msw": "2.6.4",
|
||||
"rimraf": "6.0.1",
|
||||
"tsconfig": "workspace:*",
|
||||
"tsup": "8.0.1",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.1.1",
|
||||
"vitest": "1.4.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { Adapter } from 'flags';
|
||||
import { createClient } from '@vercel/edge-config';
|
||||
import {
|
||||
GrowthBookClient,
|
||||
type Attributes,
|
||||
type ClientOptions,
|
||||
type InitOptions,
|
||||
type UserContext,
|
||||
type TrackingCallback,
|
||||
type TrackingCallbackWithUser,
|
||||
type FeatureApiResponse,
|
||||
type StickyBucketService,
|
||||
type StickyAssignmentsDocument,
|
||||
} from '@growthbook/growthbook';
|
||||
|
||||
export { getProviderData } from './provider';
|
||||
export {
|
||||
GrowthBookClient,
|
||||
type Attributes,
|
||||
type ClientOptions,
|
||||
type InitOptions,
|
||||
type UserContext,
|
||||
type TrackingCallback,
|
||||
type TrackingCallbackWithUser,
|
||||
type FeatureApiResponse,
|
||||
type StickyBucketService,
|
||||
type StickyAssignmentsDocument,
|
||||
};
|
||||
|
||||
type EdgeConfig = {
|
||||
connectionString: string;
|
||||
/** Defaults to `options.clientKey` **/
|
||||
itemKey?: string;
|
||||
};
|
||||
|
||||
type AdapterResponse = {
|
||||
feature: <T>() => Adapter<T, Attributes>;
|
||||
initialize: () => Promise<GrowthBookClient>;
|
||||
setTrackingCallback: (cb: TrackingCallback) => void;
|
||||
setStickyBucketService: (stickyBucketService: StickyBucketService) => void;
|
||||
stickyBucketService?: StickyBucketService;
|
||||
growthbook: GrowthBookClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a GrowthBook adapter for use with the Flags SDK.
|
||||
*/
|
||||
export function createGrowthbookAdapter(options: {
|
||||
/** GrowthBook SDK key **/
|
||||
clientKey: string;
|
||||
/** Callback to log experiment exposures **/
|
||||
trackingCallback?: TrackingCallback;
|
||||
/** Override the features API endpoint for self-hosted users **/
|
||||
apiHost?: string;
|
||||
/** Override the application URL for self-hosted users **/
|
||||
appOrigin?: string;
|
||||
/** Optional GrowthBook SDK constructor options **/
|
||||
clientOptions?: ClientOptions;
|
||||
/** Optional GrowthBook SDK init() options **/
|
||||
initOptions?: InitOptions;
|
||||
/** Optional StickyBucketService (reduces variation hopping, required for Bandits) **/
|
||||
stickyBucketService?: StickyBucketService;
|
||||
/** Provide Edge Config details to use the optional Edge Config adapter */
|
||||
edgeConfig?: EdgeConfig;
|
||||
}): AdapterResponse {
|
||||
let trackingCallback = options.trackingCallback;
|
||||
let stickyBucketService = options.stickyBucketService;
|
||||
|
||||
const growthbook = new GrowthBookClient({
|
||||
clientKey: options.clientKey,
|
||||
apiHost: options.apiHost || 'https://cdn.growthbook.io',
|
||||
...(options.clientOptions || {}),
|
||||
});
|
||||
|
||||
let _initializePromise: Promise<void> | undefined;
|
||||
|
||||
const initializeGrowthBook = async (): Promise<void> => {
|
||||
let payload: FeatureApiResponse | undefined;
|
||||
if (options.edgeConfig) {
|
||||
try {
|
||||
const edgeConfigClient = createClient(
|
||||
options.edgeConfig.connectionString,
|
||||
);
|
||||
payload = await edgeConfigClient.get<FeatureApiResponse>(
|
||||
options.edgeConfig.itemKey || options.clientKey,
|
||||
);
|
||||
if (!payload) {
|
||||
console.error('No payload found in edge config');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching edge config', e);
|
||||
}
|
||||
}
|
||||
|
||||
await growthbook.init({
|
||||
streaming: false,
|
||||
payload,
|
||||
...(options.initOptions || {}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the GrowthBook SDK.
|
||||
*
|
||||
* This must be called before checking feature flags or experiments.
|
||||
* It is deduplicated to prevent multiple calls from being made.
|
||||
* You can pre-initialize the SDK by calling `adapter.initialize()`,
|
||||
* otherwise it will be initialized lazily when needed.
|
||||
*/
|
||||
const initialize = async (): Promise<GrowthBookClient> => {
|
||||
if (!_initializePromise) {
|
||||
_initializePromise = initializeGrowthBook();
|
||||
}
|
||||
await _initializePromise;
|
||||
return growthbook;
|
||||
};
|
||||
|
||||
function origin(prefix: string) {
|
||||
return (key: string) => {
|
||||
const appOrigin = options.appOrigin || 'https://app.growthbook.io';
|
||||
return `${appOrigin}/${prefix}/${key}`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a feature flag.
|
||||
*
|
||||
* Implements `decide` to resolve the feature with `GrowthBook.evalFeature`
|
||||
* Implements `origin` to link to the flag in the GrowthBook app
|
||||
*/
|
||||
function feature<T>(
|
||||
opts: {
|
||||
exposureLogging?: boolean;
|
||||
} = {
|
||||
exposureLogging: true,
|
||||
},
|
||||
): Adapter<T, Attributes> {
|
||||
return {
|
||||
origin: origin('features'),
|
||||
decide: async ({ key, entities, defaultValue }) => {
|
||||
await initialize();
|
||||
const userContext: UserContext = {
|
||||
attributes: entities as Attributes,
|
||||
trackingCallback: opts.exposureLogging ? trackingCallback : undefined,
|
||||
};
|
||||
if (stickyBucketService && opts.exposureLogging) {
|
||||
const { stickyBucketAssignmentDocs, saveStickyBucketAssignmentDoc } =
|
||||
await growthbook.applyStickyBuckets(
|
||||
userContext,
|
||||
stickyBucketService,
|
||||
);
|
||||
return (growthbook.evalFeature<T>(key, {
|
||||
...userContext,
|
||||
stickyBucketAssignmentDocs,
|
||||
saveStickyBucketAssignmentDoc,
|
||||
}).value ?? defaultValue) as T;
|
||||
}
|
||||
return (growthbook.evalFeature<T>(key, userContext).value ??
|
||||
defaultValue) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setTrackingCallback(cb: TrackingCallback) {
|
||||
trackingCallback = cb;
|
||||
}
|
||||
|
||||
function setStickyBucketService(sbs: StickyBucketService) {
|
||||
stickyBucketService = sbs;
|
||||
}
|
||||
|
||||
return {
|
||||
feature,
|
||||
initialize,
|
||||
setTrackingCallback,
|
||||
setStickyBucketService,
|
||||
stickyBucketService,
|
||||
growthbook,
|
||||
};
|
||||
}
|
||||
|
||||
let defaultGrowthbookAdapter: AdapterResponse | undefined;
|
||||
|
||||
export function resetDefaultGrowthbookAdapter() {
|
||||
defaultGrowthbookAdapter = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equivalent to `createGrowthbookAdapter` but with default environment variable names.
|
||||
*
|
||||
* Required:
|
||||
* - `GROWTHBOOK_CLIENT_KEY` - GrowthBook SDK key
|
||||
*
|
||||
* Optional:
|
||||
* - `GROWTHBOOK_API_HOST` - Override the SDK API endpoint for self-hosted users
|
||||
* - `GROWTHBOOK_APP_ORIGIN` - Override the application URL for self-hosted users
|
||||
* - `GROWTHBOOK_EDGE_CONNECTION_STRING` - Edge Config connection string
|
||||
* - `EXPERIMENTATION_CONFIG` - fallback for GROWTHBOOK_EDGE_CONNECTION_STRING
|
||||
* - `GROWTHBOOK_EDGE_CONFIG_ITEM_KEY` - Override the item key for Edge Config (defaults to GROWTHBOOK_CLIENT_KEY)
|
||||
*/
|
||||
export function getOrCreateDefaultGrowthbookAdapter(): AdapterResponse {
|
||||
if (defaultGrowthbookAdapter) {
|
||||
return defaultGrowthbookAdapter;
|
||||
}
|
||||
const clientKey = process.env.GROWTHBOOK_CLIENT_KEY as string;
|
||||
if (!clientKey) {
|
||||
throw new Error('Missing GROWTHBOOK_CLIENT_KEY env var');
|
||||
}
|
||||
const apiHost = process.env.GROWTHBOOK_API_HOST;
|
||||
const appOrigin = process.env.GROWTHBOOK_APP_ORIGIN;
|
||||
const connectionString =
|
||||
process.env.GROWTHBOOK_EDGE_CONNECTION_STRING ||
|
||||
process.env.EXPERIMENTATION_CONFIG;
|
||||
const itemKey = process.env.GROWTHBOOK_EDGE_CONFIG_ITEM_KEY;
|
||||
|
||||
let edgeConfig: EdgeConfig | undefined;
|
||||
if (connectionString) {
|
||||
edgeConfig = {
|
||||
connectionString,
|
||||
itemKey,
|
||||
};
|
||||
}
|
||||
|
||||
defaultGrowthbookAdapter = createGrowthbookAdapter({
|
||||
clientKey,
|
||||
apiHost,
|
||||
appOrigin,
|
||||
edgeConfig,
|
||||
});
|
||||
|
||||
return defaultGrowthbookAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default GrowthBook adapter.
|
||||
*
|
||||
* This is a convenience object that pre-initializes the GrowthBook SDK, provides
|
||||
* an adapter function for features, and provides a hook to set the experiment exposure
|
||||
* tracking callback.
|
||||
*
|
||||
* This is the recommended way to use the GrowthBook adapter.
|
||||
*
|
||||
* ```ts
|
||||
* // flags.ts
|
||||
* import { flag } from 'flags/next';
|
||||
* import { growthbookAdapter } from '@flags-sdk/growthbook';
|
||||
*
|
||||
* const flag = flag({
|
||||
* key: 'my-flag',
|
||||
* defaultValue: false,
|
||||
* adapter: growthbookAdapter.feature(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const growthbookAdapter: AdapterResponse = {
|
||||
feature: (...args) => getOrCreateDefaultGrowthbookAdapter().feature(...args),
|
||||
initialize: () => getOrCreateDefaultGrowthbookAdapter().initialize(),
|
||||
setTrackingCallback: (...args) =>
|
||||
getOrCreateDefaultGrowthbookAdapter().setTrackingCallback(...args),
|
||||
setStickyBucketService: (...args) =>
|
||||
getOrCreateDefaultGrowthbookAdapter().setStickyBucketService(...args),
|
||||
growthbook: getOrCreateDefaultGrowthbookAdapter().growthbook,
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { getProviderData } from '..';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
const restHandlers = [
|
||||
http.get('https://api.growthbook.io/api/v1/features', () => {
|
||||
return HttpResponse.json({
|
||||
features: [
|
||||
{
|
||||
id: 'showdemo',
|
||||
defaultValue: 'true',
|
||||
environments: {
|
||||
production: {
|
||||
enabled: true,
|
||||
defaultValue: 'true',
|
||||
rules: [
|
||||
{
|
||||
id: 'fr_abc',
|
||||
type: 'force',
|
||||
enabled: true,
|
||||
value: 'false',
|
||||
condition: '{"id": "1"}',
|
||||
coverage: 1,
|
||||
scheduleRules: [],
|
||||
savedGroupTargeting: [],
|
||||
prerequisites: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
description: 'Show demo banner',
|
||||
valueType: 'boolean',
|
||||
dateCreated: '2023-01-01T00:00:00.000Z',
|
||||
dateUpdated: '2023-01-02T00:00:00.000Z',
|
||||
origin: 'https://app.growthbook.io/features/showdemo',
|
||||
},
|
||||
],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
count: 1,
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
nextOffset: 0,
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
const responseFixture = {
|
||||
hints: [],
|
||||
definitions: {
|
||||
showdemo: {
|
||||
description: 'Show demo banner',
|
||||
origin: 'https://app.growthbook.io/features/showdemo',
|
||||
createdAt: 1672531200000,
|
||||
updatedAt: 1672617600000,
|
||||
options: [
|
||||
{ label: 'On', value: true },
|
||||
{ label: 'Off', value: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const server = setupServer(...restHandlers);
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterAll(() => server.close());
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
describe('getProviderData', () => {
|
||||
describe('when called with valid params', () => {
|
||||
it('should fetch and return', async () => {
|
||||
await expect(
|
||||
getProviderData({
|
||||
apiKey: 'secret_user_a1b2c3d4e5',
|
||||
}),
|
||||
).resolves.toEqual(responseFixture);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with invalid params', () => {
|
||||
it('should return hints if missing API key', async () => {
|
||||
await expect(
|
||||
getProviderData({
|
||||
apiKey: '',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
definitions: {},
|
||||
hints: [
|
||||
{
|
||||
key: 'growthbook/missing-api-key',
|
||||
text: 'Missing GrowthBook API Key',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { JsonValue, ProviderData } from 'flags';
|
||||
|
||||
interface GrowthbookFeature {
|
||||
id: string;
|
||||
dateCreated: string;
|
||||
dateUpdated: string;
|
||||
archived: boolean;
|
||||
description: string;
|
||||
owner: string;
|
||||
project: string;
|
||||
valueType: 'string' | 'number' | 'boolean' | 'json';
|
||||
defaultValue: string;
|
||||
tags: string[];
|
||||
environments: Record<
|
||||
string,
|
||||
{
|
||||
enabled: boolean;
|
||||
defaultValue: string;
|
||||
rules: {}[];
|
||||
}
|
||||
>;
|
||||
prerequisites: [];
|
||||
revision: {};
|
||||
}
|
||||
|
||||
interface GrowthbookFeaturesResponse {
|
||||
features: GrowthbookFeature[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
count: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
nextOffset: number;
|
||||
}
|
||||
|
||||
export async function getProviderData(options: {
|
||||
/** GrowthBook API Key or Personal Access Token **/
|
||||
apiKey: string;
|
||||
/** Override the application API host for self-hosted users **/
|
||||
appApiHost?: string;
|
||||
/** Override the application URL for self-hosted users **/
|
||||
appOrigin?: string;
|
||||
/** Provide your GrowthBook SDK key to filter flag definitions **/
|
||||
clientKey?: string;
|
||||
}): Promise<ProviderData> {
|
||||
const apiKey = options.apiKey;
|
||||
const appApiHost = options.appApiHost || 'https://api.growthbook.io';
|
||||
const appOrigin = options.appOrigin || 'https://app.growthbook.io';
|
||||
const clientKey = options.clientKey;
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
definitions: {},
|
||||
hints: [
|
||||
{
|
||||
key: 'growthbook/missing-api-key',
|
||||
text: 'Missing GrowthBook API Key',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const hints: ProviderData['hints'] = [];
|
||||
|
||||
const features = await getFeatures({ apiKey, appApiHost, clientKey });
|
||||
|
||||
if (features instanceof Error) {
|
||||
return {
|
||||
definitions: {},
|
||||
hints: [
|
||||
{
|
||||
key: 'growthbook/fetch-failed',
|
||||
text: features.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const definitions: ProviderData['definitions'] = {};
|
||||
for (const feature of features) {
|
||||
if (feature.archived) continue;
|
||||
|
||||
let options: { label: string; value: JsonValue }[] = [];
|
||||
let invalidType: never;
|
||||
|
||||
switch (feature.valueType) {
|
||||
case 'boolean':
|
||||
options = [
|
||||
{ label: 'On', value: true },
|
||||
{ label: 'Off', value: false },
|
||||
];
|
||||
break;
|
||||
case 'string':
|
||||
options = [
|
||||
{ label: `"${feature.defaultValue}"`, value: feature.defaultValue },
|
||||
];
|
||||
break;
|
||||
case 'number':
|
||||
options = [
|
||||
{
|
||||
label: String(feature.defaultValue),
|
||||
value: Number(feature.defaultValue),
|
||||
},
|
||||
];
|
||||
break;
|
||||
case 'json':
|
||||
options = [
|
||||
{
|
||||
label: 'JSON',
|
||||
value: tryParseJSON(feature.defaultValue),
|
||||
},
|
||||
];
|
||||
break;
|
||||
default:
|
||||
invalidType = feature.valueType;
|
||||
hints.push({
|
||||
key: 'growthbook/invalid-feature-type',
|
||||
text: `Invalid feature type: ${feature.valueType}`,
|
||||
});
|
||||
}
|
||||
const typeLabel =
|
||||
feature.valueType === 'json'
|
||||
? 'JSON'
|
||||
: feature.valueType.charAt(0).toUpperCase() +
|
||||
feature.valueType.slice(1);
|
||||
|
||||
definitions[feature.id] = {
|
||||
description: `[${typeLabel}] ${feature.description}`,
|
||||
origin: `${appOrigin}/features/${feature.id}`,
|
||||
options,
|
||||
createdAt: new Date(feature.dateCreated).getTime(),
|
||||
updatedAt: new Date(feature.dateUpdated).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
return { definitions, hints };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all Feature Flags.
|
||||
*/
|
||||
async function getFeatures(options: {
|
||||
apiKey: string;
|
||||
appApiHost: string;
|
||||
clientKey?: string;
|
||||
}): Promise<GrowthbookFeature[] | Error> {
|
||||
try {
|
||||
const features: GrowthbookFeaturesResponse['features'] = [];
|
||||
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const params = new URLSearchParams();
|
||||
if (offset) params.append('offset', String(offset));
|
||||
if (options.clientKey) params.append('clientKey', options.clientKey);
|
||||
const qs = params.toString() ? `?${params.toString()}` : '';
|
||||
const url = `${options.appApiHost}/api/v1/features${qs}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.apiKey}`,
|
||||
},
|
||||
// @ts-expect-error some Next.js versions need this
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
await response.arrayBuffer(); // ensure stream is drained
|
||||
return new Error(
|
||||
`Failed to fetch GrowthBook (Received ${response.status} response)`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as GrowthbookFeaturesResponse;
|
||||
features.push(...body.features);
|
||||
hasMore = body.hasMore;
|
||||
offset = body.nextOffset;
|
||||
}
|
||||
|
||||
return features;
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e : new Error(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJSON(value: string): JsonValue {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "tsconfig/base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"lib": ["esnext"],
|
||||
"resolveJsonModule": true,
|
||||
"target": "ES2020"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- [@vercel/style-guide@5 migration]
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts', 'src/provider/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
splitting: true,
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
clean: false,
|
||||
skipNodeModulesBundle: true,
|
||||
dts: true,
|
||||
external: ['node_modules'],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [],
|
||||
test: { environment: 'node' },
|
||||
});
|
||||
Generated
+68
-13
@@ -387,6 +387,49 @@ importers:
|
||||
specifier: 1.4.0
|
||||
version: 1.4.0(@types/node@20.11.17)
|
||||
|
||||
packages/adapter-growthbook:
|
||||
dependencies:
|
||||
'@growthbook/growthbook':
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
'@vercel/edge-config':
|
||||
specifier: ^1.2.0
|
||||
version: 1.4.0
|
||||
'@vercel/functions':
|
||||
specifier: ^1.5.2
|
||||
version: 1.6.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 20.11.17
|
||||
version: 20.11.17
|
||||
eslint-config-custom:
|
||||
specifier: workspace:*
|
||||
version: link:../../tooling/eslint-config-custom
|
||||
flags:
|
||||
specifier: workspace:*
|
||||
version: link:../flags
|
||||
msw:
|
||||
specifier: 2.6.4
|
||||
version: 2.6.4(@types/node@20.11.17)(typescript@5.6.3)
|
||||
rimraf:
|
||||
specifier: 6.0.1
|
||||
version: 6.0.1
|
||||
tsconfig:
|
||||
specifier: workspace:*
|
||||
version: link:../../tooling/tsconfig
|
||||
tsup:
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1(typescript@5.6.3)
|
||||
typescript:
|
||||
specifier: 5.6.3
|
||||
version: 5.6.3
|
||||
vite:
|
||||
specifier: 5.1.1
|
||||
version: 5.1.1(@types/node@20.11.17)
|
||||
vitest:
|
||||
specifier: 1.4.0
|
||||
version: 1.4.0(@types/node@20.11.17)
|
||||
|
||||
packages/adapter-happykit:
|
||||
dependencies:
|
||||
'@happykit/flags':
|
||||
@@ -711,7 +754,7 @@ importers:
|
||||
version: 5.10.0
|
||||
react-dom:
|
||||
specifier: '*'
|
||||
version: 19.0.0(react@19.2.0-canary-4448b187-20250515)
|
||||
version: 19.0.0(react@19.2.0-canary-526dd340-20250602)
|
||||
devDependencies:
|
||||
'@arethetypeswrong/cli':
|
||||
specifier: 0.17.3
|
||||
@@ -733,10 +776,10 @@ importers:
|
||||
version: 2.6.4(@types/node@20.11.17)(typescript@5.6.3)
|
||||
next:
|
||||
specifier: 15.1.4
|
||||
version: 15.1.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.0.0)(react@19.2.0-canary-4448b187-20250515)
|
||||
version: 15.1.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.0.0)(react@19.2.0-canary-526dd340-20250602)
|
||||
react:
|
||||
specifier: canary
|
||||
version: 19.2.0-canary-4448b187-20250515
|
||||
version: 19.2.0-canary-526dd340-20250602
|
||||
tsconfig:
|
||||
specifier: workspace:*
|
||||
version: link:../../tooling/tsconfig
|
||||
@@ -2534,6 +2577,13 @@ packages:
|
||||
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
|
||||
dev: false
|
||||
|
||||
/@growthbook/growthbook@1.5.1:
|
||||
resolution: {integrity: sha512-t0cj+3i2YtZ4Y/v1xl2fThogLMvjjUy3fx7ywGoLPgKZdhYy2HSXUGY1lLM/I0FMTEd2L2lkJHEzeUKpW6Ew3g==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
dom-mutator: 0.6.0
|
||||
dev: false
|
||||
|
||||
/@happykit/flags@3.3.0(next@15.2.0)(react@19.0.0):
|
||||
resolution: {integrity: sha512-QyywkKYctdo0bnNf+bBd+3B/3Rsnjdaz+jtVA+jSqLxm0/lw51Z1+q2sEfc62MyNwQnY6w7eqajdwhuvH3VWAw==}
|
||||
peerDependencies:
|
||||
@@ -7471,6 +7521,11 @@ packages:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
/dom-mutator@0.6.0:
|
||||
resolution: {integrity: sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/dotenv@16.0.3:
|
||||
resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -11265,7 +11320,7 @@ packages:
|
||||
- babel-plugin-macros
|
||||
dev: false
|
||||
|
||||
/next@15.1.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.0.0)(react@19.2.0-canary-4448b187-20250515):
|
||||
/next@15.1.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.0.0)(react@19.2.0-canary-526dd340-20250602):
|
||||
resolution: {integrity: sha512-mTaq9dwaSuwwOrcu3ebjDYObekkxRnXpuVL21zotM8qE2W0HBOdVIdg2Li9QjMEZrj73LN96LcWcz62V19FjAg==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
hasBin: true
|
||||
@@ -11293,9 +11348,9 @@ packages:
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: 1.0.30001704
|
||||
postcss: 8.4.31
|
||||
react: 19.2.0-canary-4448b187-20250515
|
||||
react-dom: 19.0.0(react@19.2.0-canary-4448b187-20250515)
|
||||
styled-jsx: 5.1.6(@babel/core@7.26.10)(react@19.2.0-canary-4448b187-20250515)
|
||||
react: 19.2.0-canary-526dd340-20250602
|
||||
react-dom: 19.0.0(react@19.2.0-canary-526dd340-20250602)
|
||||
styled-jsx: 5.1.6(@babel/core@7.26.10)(react@19.2.0-canary-526dd340-20250602)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.1.4
|
||||
'@next/swc-darwin-x64': 15.1.4
|
||||
@@ -12150,12 +12205,12 @@ packages:
|
||||
scheduler: 0.25.0
|
||||
dev: false
|
||||
|
||||
/react-dom@19.0.0(react@19.2.0-canary-4448b187-20250515):
|
||||
/react-dom@19.0.0(react@19.2.0-canary-526dd340-20250602):
|
||||
resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}
|
||||
peerDependencies:
|
||||
react: ^19.0.0
|
||||
dependencies:
|
||||
react: 19.2.0-canary-4448b187-20250515
|
||||
react: 19.2.0-canary-526dd340-20250602
|
||||
scheduler: 0.25.0
|
||||
|
||||
/react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028):
|
||||
@@ -12260,8 +12315,8 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/react@19.2.0-canary-4448b187-20250515:
|
||||
resolution: {integrity: sha512-XU13V8AnU5+2/PeqZela97gmLo1nxDOJJHvuDogNdrodwLF9vu3Fjxf/Y8uBfeZzhP3qm+psotuvqZa+fMIbdQ==}
|
||||
/react@19.2.0-canary-526dd340-20250602:
|
||||
resolution: {integrity: sha512-Ebiwh5tDLCZx7z5KYQQ3IPezOKbgN2ZPGXLh/akSEd3ve6NhMj/Zy+x7lnMVweFil29iC16ujVeLCGU5wf+R5w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
/read-cache@1.0.0:
|
||||
@@ -13151,7 +13206,7 @@ packages:
|
||||
react: 19.0.0-rc.1
|
||||
dev: false
|
||||
|
||||
/styled-jsx@5.1.6(@babel/core@7.26.10)(react@19.2.0-canary-4448b187-20250515):
|
||||
/styled-jsx@5.1.6(@babel/core@7.26.10)(react@19.2.0-canary-526dd340-20250602):
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
peerDependencies:
|
||||
@@ -13166,7 +13221,7 @@ packages:
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
client-only: 0.0.1
|
||||
react: 19.2.0-canary-4448b187-20250515
|
||||
react: 19.2.0-canary-526dd340-20250602
|
||||
dev: true
|
||||
|
||||
/sucrase@3.35.0:
|
||||
|
||||
Reference in New Issue
Block a user