Skip to content

Commit 79b25f6

Browse files
dferber90roncohenAAorris
authored
@flags-sdk/bucket adapter (#96)
* init * [@flags-sdk/bucket] getProviderData (#113) * [@flags-sdk/bucket] various improvements (#121) * Update framer-motion to motion for CI --------- Co-authored-by: Ron Cohen <cohen1@gmail.com> Co-authored-by: Aaron Morris <aaron@vercel.com>
1 parent 5b40b1c commit 79b25f6

File tree

14 files changed

+1348
-821
lines changed

14 files changed

+1348
-821
lines changed

.changeset/wicked-camels-suffer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@flags-sdk/bucket': minor
3+
---
4+
5+
Introduce Bucket adapter

examples/shirt-shop/components/product-detail-page/add-to-cart-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { motion, AnimatePresence } from 'framer-motion';
3+
import { motion, AnimatePresence } from 'motion/react';
44

55
function Spinner() {
66
return (

examples/shirt-shop/components/shopping-cart/shopping-cart-item.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { CartItem } from '@/components/utils/cart-types';
33
import { ShoppingBagIcon } from '@heroicons/react/24/outline';
44
import { colorToImage } from '@/components/utils/images';
5-
import { motion } from 'framer-motion';
5+
import { motion } from 'motion/react';
66
import Image from 'next/image';
77
import { ShoppingCartRemoveButton } from './shopping-cart-remove-button';
88
import Link from 'next/link';

examples/shirt-shop/components/shopping-cart/shopping-cart-list.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { AnimatePresence } from 'framer-motion';
3+
import { AnimatePresence } from 'motion/react';
44

55
export function ShoppingCartList({ children }: { children: React.ReactNode }) {
66
return (

examples/shirt-shop/components/shopping-cart/shopping-cart-remove-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { removeFromCart } from '@/lib/actions';
4-
import { motion, AnimatePresence } from 'framer-motion';
4+
import { motion, AnimatePresence } from 'motion/react';
55
import { useState } from 'react';
66
import { CartItem } from '@/components/utils/cart-types';
77

examples/shirt-shop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
"@vercel/toolbar": "0.1.36",
2323
"clsx": "2.1.1",
2424
"flags": "workspace:*",
25-
"framer-motion": "12.4.7",
2625
"js-xxhash": "4.0.0",
26+
"motion": "12.12.1",
2727
"nanoid": "5.1.2",
2828
"next": "15.2.2-canary.4",
2929
"react": "^19.0.0",

packages/adapter-bucket/.eslintrc.cjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module.exports = {
2+
root: true,
3+
extends: [require.resolve('@pyra/eslint-config/components')],
4+
};

packages/adapter-bucket/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Flags SDK - Bucket Provider
2+
3+
The [Bucket provider](https://flags-sdk.dev/docs/api-reference/adapters/bucket) for the [Flags SDK](https://flags-sdk.dev/) contains support for Bucket's feature flags.
4+
5+
## Setup
6+
7+
The Bucket provider is available in the `@flags-sdk/bucket` module. You can install it with
8+
9+
```bash
10+
pnpm i @flags-sdk/bucket
11+
```
12+
13+
## Provider Instance
14+
15+
You can import the default adapter instance `bucketAdapter` from `@flags-sdk/bucket`:
16+
17+
```ts
18+
import { bucketAdapter } from '@flags-sdk/bucket';
19+
```
20+
21+
## Example
22+
23+
```ts
24+
import { flag } from 'flags/next';
25+
import { bucketAdapter } from '@flags-sdk/bucket';
26+
27+
export const huddleFlag = flag<boolean>({
28+
key: 'huddle',
29+
adapter: bucketAdapter.featureIsEnabled(),
30+
});
31+
```
32+
33+
## Documentation
34+
35+
Please check out the [Bucket provider documentation](https://flags-sdk.dev/docs/api-reference/adapters/bucket) for more information.

packages/adapter-bucket/package.json

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"name": "@flags-sdk/bucket",
3+
"version": "0.0.1",
4+
"description": "Bucket.co provider for the Flags SDK",
5+
"keywords": [
6+
"flags-sdk",
7+
"bucket.co",
8+
"bucket",
9+
"vercel",
10+
"edge config",
11+
"feature flags",
12+
"flags"
13+
],
14+
"homepage": "https://flags-sdk.dev",
15+
"bugs": {
16+
"url": "https://github.com/vercel/flags/issues"
17+
},
18+
"repository": {
19+
"type": "git",
20+
"url": "git+https://github.com/vercel/flags.git"
21+
},
22+
"license": "MIT",
23+
"author": "",
24+
"sideEffects": false,
25+
"type": "module",
26+
"exports": {
27+
".": {
28+
"import": "./dist/index.js",
29+
"require": "./dist/index.cjs"
30+
}
31+
},
32+
"main": "./dist/index.js",
33+
"typesVersions": {
34+
"*": {
35+
".": [
36+
"dist/*.d.ts",
37+
"dist/*.d.cts"
38+
]
39+
}
40+
},
41+
"files": [
42+
"dist",
43+
"CHANGELOG.md"
44+
],
45+
"scripts": {
46+
"build": "rimraf dist && tsup",
47+
"dev": "tsup --watch --clean=false",
48+
"eslint": "eslint-runner",
49+
"eslint:fix": "eslint-runner --fix",
50+
"type-check": "tsc --noEmit"
51+
},
52+
"dependencies": {
53+
"@bucketco/node-sdk": "1.8.1"
54+
},
55+
"devDependencies": {
56+
"@types/node": "20.11.17",
57+
"eslint-config-custom": "workspace:*",
58+
"flags": "workspace:*",
59+
"msw": "2.6.4",
60+
"rimraf": "6.0.1",
61+
"tsconfig": "workspace:*",
62+
"tsup": "8.0.1",
63+
"typescript": "5.6.3",
64+
"vite": "5.1.1",
65+
"vitest": "1.4.0"
66+
},
67+
"publishConfig": {
68+
"access": "public"
69+
}
70+
}

packages/adapter-bucket/src/index.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { Adapter, FlagDefinitionsType } from 'flags';
2+
import {
3+
BucketClient,
4+
ClientOptions,
5+
Context,
6+
ContextWithTracking,
7+
} from '@bucketco/node-sdk';
8+
import { ProviderData } from 'flags';
9+
10+
export type { Context };
11+
12+
type AdapterOptions = Pick<ContextWithTracking, 'enableTracking' | 'meta'>;
13+
14+
type AdapterResponse = {
15+
featureIsEnabled: (options?: AdapterOptions) => Adapter<boolean, Context>;
16+
/** The Bucket client instance used by the adapter. */
17+
bucketClient: () => Promise<BucketClient>;
18+
};
19+
20+
let defaultBucketAdapter: ReturnType<typeof createBucketAdapter> | undefined;
21+
22+
function assertEnv(name: string): string {
23+
const value = process.env[name];
24+
if (!value) {
25+
throw new Error(`@flags-sdk/bucket: Missing ${name} environment variable`);
26+
}
27+
return value;
28+
}
29+
30+
export function createBucketAdapter(
31+
clientOptions: ClientOptions,
32+
): AdapterResponse {
33+
let bucketClient: BucketClient;
34+
35+
async function initialize() {
36+
if (!bucketClient) {
37+
try {
38+
bucketClient = new BucketClient(clientOptions);
39+
} catch (err) {
40+
// explicitly log out the error, otherwise it's swallowed
41+
console.error('@flags-sdk/bucket: Error creating bucketClient', err);
42+
throw err;
43+
}
44+
}
45+
46+
// this can be called multiple times. Same promise is returned.
47+
return bucketClient.initialize();
48+
}
49+
50+
function featureIsEnabled(
51+
options?: AdapterOptions,
52+
): Adapter<boolean, Context> {
53+
return {
54+
async decide({ key, entities }): Promise<boolean> {
55+
await initialize();
56+
57+
return bucketClient.getFeature({ ...options, ...entities }, key)
58+
.isEnabled;
59+
},
60+
};
61+
}
62+
63+
return {
64+
featureIsEnabled,
65+
bucketClient: async () => {
66+
await initialize();
67+
return bucketClient;
68+
},
69+
};
70+
}
71+
72+
function getOrCreateDefaultAdapter() {
73+
if (!defaultBucketAdapter) {
74+
const secretKey = assertEnv('BUCKET_SECRET_KEY');
75+
76+
defaultBucketAdapter = createBucketAdapter({ secretKey });
77+
}
78+
79+
return defaultBucketAdapter;
80+
}
81+
82+
/**
83+
* The default Bucket adapter.
84+
*
85+
* This is a convenience object that pre-initializes the Bucket SDK and provides
86+
* the adapter function for usage with the Flags SDK.
87+
*
88+
* This is the recommended way to use the Bucket adapter.
89+
*
90+
* ```ts
91+
* // flags.ts
92+
* import { flag } from 'flags/next';
93+
* import { bucketAdapter, type Context } from '@flags-sdk/bucket';
94+
*
95+
* const flag = flag<boolean, Context>({
96+
* key: 'my-flag',
97+
* defaultValue: false,
98+
* identify: () => ({ key: "user-123" }),
99+
* adapter: bucketAdapter.featureIsEnabled(),
100+
* });
101+
* ```
102+
*/
103+
export const bucketAdapter: AdapterResponse = {
104+
featureIsEnabled: (...args) =>
105+
getOrCreateDefaultAdapter().featureIsEnabled(...args),
106+
bucketClient: async () => {
107+
return getOrCreateDefaultAdapter().bucketClient();
108+
},
109+
};
110+
111+
/**
112+
* Get the provider data for the Bucket adapter.
113+
*
114+
* This function is used the the [Flags API endpoint](https://vercel.com/docs/workflow-collaboration/feature-flags/implement-flags-in-toolbar#creating-the-flags-api-endpoint) to load and emit your Bucket data.
115+
*
116+
* ```ts
117+
* // .well-known/vercel/flags/route.ts
118+
* import { NextResponse, type NextRequest } from 'next/server';
119+
* import { verifyAccess, type ApiData } from 'flags';
120+
* import { bucketAdapter, getProviderData } from '@flags-sdk/bucket';
121+
*
122+
* export async function GET(request: NextRequest) {
123+
* const access = await verifyAccess(request.headers.get('Authorization'));
124+
* if (!access) return NextResponse.json(null, { status: 401 });
125+
*
126+
* return NextResponse.json<ApiData>(
127+
* await getProviderData({ bucketClient: await bucketAdapter.bucketClient() }),
128+
* );
129+
* }
130+
* ```
131+
*/
132+
export async function getProviderData({
133+
bucketClient,
134+
}: {
135+
/**
136+
* The BucketClient instance.
137+
*/
138+
bucketClient?: BucketClient;
139+
} = {}): Promise<ProviderData> {
140+
if (!bucketClient) {
141+
bucketClient = await getOrCreateDefaultAdapter().bucketClient();
142+
}
143+
144+
const features = await bucketClient.getFeatureDefinitions();
145+
146+
return {
147+
definitions: features.reduce<FlagDefinitionsType>((acc, item) => {
148+
acc[item.key] = {
149+
options: [
150+
{ label: 'Disabled', value: false },
151+
{ label: 'Enabled', value: true },
152+
],
153+
description: item.description ?? undefined,
154+
};
155+
return acc;
156+
}, {}),
157+
hints: [],
158+
};
159+
}

packages/adapter-bucket/tsconfig.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "tsconfig/base.json",
3+
"include": ["src"],
4+
"compilerOptions": {
5+
"lib": ["esnext"],
6+
"resolveJsonModule": true,
7+
"target": "ES2020"
8+
}
9+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { defineConfig } from 'tsup';
2+
3+
// eslint-disable-next-line import/no-default-export -- [@vercel/style-guide@5 migration]
4+
export default defineConfig({
5+
entry: ['src/index.ts'],
6+
format: ['esm', 'cjs'],
7+
splitting: true,
8+
sourcemap: true,
9+
minify: false,
10+
clean: false,
11+
skipNodeModulesBundle: true,
12+
dts: true,
13+
external: ['node_modules'],
14+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
plugins: [],
5+
test: { environment: 'node' },
6+
});

0 commit comments

Comments
 (0)