Skip to content

Commit 48cbe45

Browse files
dferber90AAorris
andauthored
Add @flags-sdk/edge-config adapter (vercel#57)
* add Edge Config Adapter * add README and tests * add changeset * add edge config caching --------- Co-authored-by: Aaron Morris <[email protected]>
1 parent cf532da commit 48cbe45

File tree

11 files changed

+465
-35
lines changed

11 files changed

+465
-35
lines changed

.changeset/nasty-bottles-sip.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@flags-sdk/edge-config': minor
3+
---
4+
5+
initial release

examples/snippets/app/concepts/adapters/edge-config-adapter.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Adapter } from '@vercel/flags';
22
import { createClient, type EdgeConfigClient } from '@vercel/edge-config';
33

44
/**
5-
* Allows creating a custom Edge Config adapter for feature flags
5+
* An Edge Config adapter for the Flags SDK
66
*/
77
export function createEdgeConfigAdapter(
88
connectionString: string | EdgeConfigClient,
@@ -38,14 +38,14 @@ export function createEdgeConfigAdapter(
3838
// if a defaultValue was provided this error will be caught and the defaultValue will be used
3939
if (!definitions) {
4040
throw new Error(
41-
`Edge Config Adapter: Edge Config item "${edgeConfigItemKey}" not found`,
41+
`@flags-sdk/edge-config: Edge Config item "${edgeConfigItemKey}" not found`,
4242
);
4343
}
4444

4545
// if a defaultValue was provided this error will be caught and the defaultValue will be used
4646
if (!(key in definitions)) {
4747
throw new Error(
48-
`Edge Config Adapter: Flag "${key}" not found in Edge Config item "${edgeConfigItemKey}"`,
48+
`@flags-sdk/edge-config: Flag "${key}" not found in Edge Config item "${edgeConfigItemKey}"`,
4949
);
5050
}
5151
return definitions[key] as ValueType;
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+
};
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# `@flags-sdk/edge-config`
2+
3+
## Installation
4+
5+
```bash
6+
npm install @flags-sdk/edge-config
7+
```
8+
9+
## Usage
10+
11+
## Using the default adapter
12+
13+
This adapter will connect to the Edge Config available under the `EDGE_CONFIG` environment variable, and read items from a key in the Edge Config called `flags`.
14+
15+
```ts
16+
import { flag } from '@vercel/flags/next';
17+
import { edgeConfigAdapter } from '@flags-sdk/edge-config';
18+
19+
export const exampleFlag = flag({
20+
key: 'example-flag',
21+
adapter: edgeConfigAdapter(),
22+
});
23+
```
24+
25+
Your Edge Config should look like this:
26+
27+
```json
28+
{
29+
"flags": {
30+
"example-flag": true
31+
}
32+
}
33+
```
34+
35+
## Using a custom adapter
36+
37+
You can specify a custom adapter which connects to a different Edge Config, and reads
38+
39+
```ts
40+
import { flag } from '@vercel/flags/next';
41+
import { createEdgeConfigAdapter } from '@flags-sdk/edge-config';
42+
43+
const edgeConfigAdapter = createEdgeConfigAdapter(process.env.EDGE_CONFIG, {
44+
teamSlug: 'your-team-slug',
45+
edgeConfigItemKey: 'my-flags',
46+
});
47+
48+
export const exampleFlag = flag({
49+
key: 'example-flag',
50+
adapter: edgeConfigAdapter(),
51+
});
52+
```
53+
54+
Your Edge Config should look like this:
55+
56+
```json
57+
{
58+
"my-flags": {
59+
"example-flag": true
60+
}
61+
}
62+
```
63+
64+
Supplying the custom `teamSlug` allows the adapter to generate an `origin` for your flags, which in turn allows the Flags Explorer to link to your Edge Config. This is optional and does not affect runtime behavior.
+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "@flags-sdk/edge-config",
3+
"version": "0.0.0",
4+
"description": "",
5+
"keywords": [],
6+
"license": "MIT",
7+
"author": "",
8+
"sideEffects": false,
9+
"type": "module",
10+
"exports": {
11+
".": {
12+
"import": "./dist/index.js",
13+
"require": "./dist/index.cjs"
14+
}
15+
},
16+
"main": "./dist/index.js",
17+
"typesVersions": {
18+
"*": {
19+
".": [
20+
"dist/*.d.ts",
21+
"dist/*.d.cts"
22+
]
23+
}
24+
},
25+
"files": [
26+
"dist"
27+
],
28+
"scripts": {
29+
"build": "rimraf dist && tsup",
30+
"dev": "tsup --watch --clean=false",
31+
"eslint": "eslint-runner",
32+
"eslint:fix": "eslint-runner --fix",
33+
"test": "vitest --run",
34+
"test:watch": "vitest",
35+
"type-check": "tsc --noEmit"
36+
},
37+
"dependencies": {
38+
"@vercel/edge-config": "^1.2.0"
39+
},
40+
"devDependencies": {
41+
"@types/node": "20.11.17",
42+
"@vercel/edge-config": "1.2.0",
43+
"@vercel/flags": "workspace:*",
44+
"eslint-config-custom": "workspace:*",
45+
"rimraf": "6.0.1",
46+
"tsconfig": "workspace:*",
47+
"tsup": "8.0.1",
48+
"typescript": "5.6.3",
49+
"vite": "5.1.1",
50+
"vitest": "1.4.0"
51+
},
52+
"publishConfig": {
53+
"access": "public"
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { expect, it, describe, vi, beforeEach } from 'vitest';
2+
import {
3+
createEdgeConfigAdapter,
4+
edgeConfigAdapter,
5+
resetDefaultEdgeConfigAdapter,
6+
} from '.';
7+
import type { EdgeConfigClient } from '@vercel/edge-config';
8+
import type { ReadonlyRequestCookies } from '@vercel/flags';
9+
10+
describe('createEdgeConfigAdapter', () => {
11+
it('should allow creating an adapter with a client', () => {
12+
const fakeEdgeConfigClient = {} as EdgeConfigClient;
13+
const adapter = createEdgeConfigAdapter(fakeEdgeConfigClient);
14+
expect(adapter).toBeDefined();
15+
});
16+
17+
it('should allow creating an adapter with a connection string', () => {
18+
const adapter = createEdgeConfigAdapter(
19+
'https://edge-config.vercel.com/ecfg_xxx?token=yyy',
20+
);
21+
expect(adapter).toBeDefined();
22+
});
23+
24+
it('should allow deciding', async () => {
25+
const fakeEdgeConfigClient = {
26+
get: vi.fn(async () => ({ 'test-key': true })),
27+
} as unknown as EdgeConfigClient;
28+
const adapter = createEdgeConfigAdapter(fakeEdgeConfigClient);
29+
await expect(
30+
adapter().decide({
31+
key: 'test-key',
32+
entities: {},
33+
headers: new Headers(),
34+
cookies: {} as ReadonlyRequestCookies,
35+
}),
36+
).resolves.toEqual(true);
37+
expect(fakeEdgeConfigClient.get).toHaveBeenCalledWith('flags');
38+
});
39+
40+
describe('caching', () => {
41+
it('caches for the duration of a request', async () => {
42+
const fakeEdgeConfigClient = {
43+
get: vi.fn(async () => ({ 'test-key': true })),
44+
} as unknown as EdgeConfigClient;
45+
const adapter = createEdgeConfigAdapter(fakeEdgeConfigClient);
46+
47+
const headers = new Headers();
48+
49+
// call once
50+
await expect(
51+
adapter().decide({
52+
key: 'test-key',
53+
entities: {},
54+
headers,
55+
cookies: {} as ReadonlyRequestCookies,
56+
}),
57+
).resolves.toEqual(true);
58+
59+
// call again with the same headers instance
60+
// to simulate a read within the same request
61+
await expect(
62+
adapter().decide({
63+
key: 'test-key',
64+
entities: {},
65+
headers,
66+
cookies: {} as ReadonlyRequestCookies,
67+
}),
68+
).resolves.toEqual(true);
69+
expect(fakeEdgeConfigClient.get).toHaveBeenCalledWith('flags');
70+
expect(fakeEdgeConfigClient.get).toHaveBeenCalledOnce();
71+
});
72+
it('does not cache between requests', async () => {
73+
const fakeEdgeConfigClient = {
74+
get: vi.fn(async () => ({ 'test-key': true })),
75+
} as unknown as EdgeConfigClient;
76+
const adapter = createEdgeConfigAdapter(fakeEdgeConfigClient);
77+
78+
// call once
79+
await expect(
80+
adapter().decide({
81+
key: 'test-key',
82+
entities: {},
83+
headers: new Headers(),
84+
cookies: {} as ReadonlyRequestCookies,
85+
}),
86+
).resolves.toEqual(true);
87+
88+
// call again with a different headers instance
89+
// to simulate a new request
90+
await expect(
91+
adapter().decide({
92+
key: 'test-key',
93+
entities: {},
94+
headers: new Headers(),
95+
cookies: {} as ReadonlyRequestCookies,
96+
}),
97+
).resolves.toEqual(true);
98+
99+
expect(fakeEdgeConfigClient.get).toHaveBeenCalledWith('flags');
100+
expect(fakeEdgeConfigClient.get).toHaveBeenCalledTimes(2);
101+
});
102+
});
103+
});
104+
105+
describe('edgeConfigAdapter', () => {
106+
beforeEach(() => {
107+
resetDefaultEdgeConfigAdapter();
108+
});
109+
110+
it('default adapter should throw on usage when EDGE_CONFIG is not set', () => {
111+
expect(() => edgeConfigAdapter()).toThrowError(
112+
'@flags-sdk/edge-config: Missing EDGE_CONFIG env var',
113+
);
114+
});
115+
116+
it('should export a default adapter', () => {
117+
process.env.EDGE_CONFIG =
118+
'https://edge-config.vercel.com/ecfg_xxx?token=yyy';
119+
const adapter = edgeConfigAdapter();
120+
expect(adapter).toBeDefined();
121+
delete process.env.EDGE_CONFIG;
122+
});
123+
});
+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { Adapter, ReadonlyHeaders } from '@vercel/flags';
2+
import { createClient, type EdgeConfigClient } from '@vercel/edge-config';
3+
4+
export type EdgeConfigFlags = {
5+
[key: string]: boolean | number | string | null;
6+
};
7+
8+
// extend the adapter definition to expose a default adapter
9+
let defaultEdgeConfigAdapter:
10+
| ReturnType<typeof createEdgeConfigAdapter>
11+
| undefined;
12+
13+
/**
14+
* A default Vercel adapter for Edge Config
15+
*
16+
*/
17+
export function edgeConfigAdapter<ValueType, EntitiesType>(): Adapter<
18+
ValueType,
19+
EntitiesType
20+
> {
21+
// Initialized lazily to avoid warning when it is not actually used and env vars are missing.
22+
if (!defaultEdgeConfigAdapter) {
23+
if (!process.env.EDGE_CONFIG) {
24+
throw new Error('@flags-sdk/edge-config: Missing EDGE_CONFIG env var');
25+
}
26+
27+
defaultEdgeConfigAdapter = createEdgeConfigAdapter(process.env.EDGE_CONFIG);
28+
}
29+
30+
return defaultEdgeConfigAdapter<ValueType, EntitiesType>();
31+
}
32+
33+
export function resetDefaultEdgeConfigAdapter() {
34+
defaultEdgeConfigAdapter = undefined;
35+
}
36+
37+
type EdgeConfigItem = Record<string, boolean>;
38+
39+
/**
40+
* Allows creating a custom Edge Config adapter for feature flags
41+
*/
42+
export function createEdgeConfigAdapter(
43+
connectionString: string | EdgeConfigClient,
44+
options?: {
45+
edgeConfigItemKey?: string;
46+
teamSlug?: string;
47+
},
48+
) {
49+
if (!connectionString) {
50+
throw new Error('@flags-sdk/edge-config: Missing connection string');
51+
}
52+
const edgeConfigClient =
53+
typeof connectionString === 'string'
54+
? createClient(connectionString)
55+
: connectionString;
56+
57+
const edgeConfigItemKey = options?.edgeConfigItemKey ?? 'flags';
58+
59+
/**
60+
* Per-request cache to ensure we only ever read Edge Config once per request.
61+
* Uses the request headers reference as the cache key.
62+
*
63+
* ReadonlyHeaders -> Promise<EdgeConfigItem>
64+
*/
65+
const edgeConfigItemCache = new WeakMap<
66+
ReadonlyHeaders,
67+
Promise<EdgeConfigItem | undefined>
68+
>();
69+
70+
return function edgeConfigAdapter<ValueType, EntitiesType>(): Adapter<
71+
ValueType,
72+
EntitiesType
73+
> {
74+
return {
75+
origin: options?.teamSlug
76+
? `https://vercel.com/${options.teamSlug}/~/stores/edge-config/${edgeConfigClient.connection.id}/items#item=${edgeConfigItemKey}`
77+
: undefined,
78+
async decide({ key, headers }): Promise<ValueType> {
79+
const cached = edgeConfigItemCache.get(headers);
80+
let valuePromise: Promise<EdgeConfigItem | undefined>;
81+
82+
if (!cached) {
83+
valuePromise =
84+
edgeConfigClient.get<EdgeConfigItem>(edgeConfigItemKey);
85+
edgeConfigItemCache.set(headers, valuePromise);
86+
} else {
87+
valuePromise = cached;
88+
}
89+
90+
const definitions = await valuePromise;
91+
92+
// if a defaultValue was provided this error will be caught and the defaultValue will be used
93+
if (!definitions) {
94+
throw new Error(
95+
`@flags-sdk/edge-config: Edge Config item "${edgeConfigItemKey}" not found`,
96+
);
97+
}
98+
99+
// if a defaultValue was provided this error will be caught and the defaultValue will be used
100+
if (!(key in definitions)) {
101+
throw new Error(
102+
`@flags-sdk/edge-config: Flag "${key}" not found in Edge Config item "${edgeConfigItemKey}"`,
103+
);
104+
}
105+
return definitions[key] as ValueType;
106+
},
107+
};
108+
};
109+
}

0 commit comments

Comments
 (0)