Skip to content

Commit ab6681b

Browse files
gnoffdferber90
andauthored
[Edge Config] Support Next.js specific IO semantics (#883)
* Refactor edge-config to prepare for Next.js support This change refactors the edge-config implementation in preparation for supporting Next.js specific builds. The key goal is to prepare the function interfaces where we intend to push the "use cache" boundaries for Next.js support so they are stringly typed. This is to help with optimizing a fast path for "use cache" that can avoid some serialization. One concession in this refactor is we need to move from a closure for the dev-only SWR edge config fetcher. The problem with this architecture is the function created in the closure won't be accessible to the the individual methods (like get) when they are behind a "use cache" because functions are not serializable. The technique now employed is a lazy created SWR deduper that is keyed off the connection string. This implementation does not consider garbage collection when clients are collected because 1) this is dev only and dev servers don't live very long and 2) this library does not typically require creating arbitrarily many clients since connections are typically stable for the lifetime of a deployment. We could use a FinalizationRegistry but that would limit support to runtimes that provide this and require some careful conditional requiring which just adds bundling complexity. * Support Next.js specific IO semantics Adds an export map for `next-js` condition and a new `index.next-js.ts` entrypoint for TSUP. converts the default test fixture to Next.js with cache components which will use the new entrypoint. Renames the existing test fixtures to use the latest Next.js release and not have cache components enabled which will continue to use the default entrypoint. To support cache components we use "use cache". However rather than actually caching all edge config reads we use cacheLife to determine whether the particular API call should be dynamic (zero expiration) or cached (default cache life). by default "use cache" will not ever reach out over the network so this should add minimal overhead to all edge config reads. However there is some serialization overhead. We will be updating Next.js to optimize cache reads that end up not caching the result (expiration zero) so that we can skip serialization and key generation. For now there may be a minor degredation in the fastest reads due to this additional overhead howevcer it will only affect users who are using Cache Components wiht Next.js and won't have any impact on other users. * extract createCreateClient factory (#884) * extract createCreateClient factory * avoid re-exporting types * fix import * drop next-legacy * upgrade test/next to Next.js v16 * skip failing test --------- Co-authored-by: Dominik Ferber <[email protected]>
1 parent 632977b commit ab6681b

File tree

11 files changed

+879
-482
lines changed

11 files changed

+879
-482
lines changed

.changeset/all-forks-camp.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@vercel/edge-config': patch
3+
'vercel-storage-integration-test-suite-legacy': patch
4+
---
5+
6+
New Next.js entrypoint for edge-config

packages/edge-config/package.json

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@
1212
"sideEffects": false,
1313
"type": "module",
1414
"exports": {
15-
"import": "./dist/index.js",
16-
"require": "./dist/index.cjs"
15+
".": {
16+
"next-js": {
17+
"import": "./dist/index.next-js.js",
18+
"require": "./dist/index.next-js.cjs"
19+
},
20+
"import": "./dist/index.js",
21+
"require": "./dist/index.cjs"
22+
}
1723
},
1824
"main": "./dist/index.cjs",
1925
"module": "./dist/index.js",
@@ -53,6 +59,7 @@
5359
"eslint-config-custom": "workspace:*",
5460
"jest": "29.7.0",
5561
"jest-fetch-mock": "3.0.3",
62+
"next": "16.0.0-canary.15",
5663
"node-domexception": "2.0.1",
5764
"prettier": "3.5.2",
5865
"ts-jest": "29.2.6",
@@ -61,11 +68,15 @@
6168
"typescript": "5.7.3"
6269
},
6370
"peerDependencies": {
64-
"@opentelemetry/api": "^1.7.0"
71+
"@opentelemetry/api": "^1.7.0",
72+
"next": ">=1"
6573
},
6674
"peerDependenciesMeta": {
6775
"@opentelemetry/api": {
6876
"optional": true
77+
},
78+
"next": {
79+
"optional": true
6980
}
7081
},
7182
"engines": {
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { name as sdkName, version as sdkVersion } from '../package.json';
2+
import type * as deps from './edge-config';
3+
import type {
4+
EdgeConfigClient,
5+
EdgeConfigFunctionsOptions,
6+
EdgeConfigItems,
7+
EdgeConfigValue,
8+
EmbeddedEdgeConfig,
9+
} from './types';
10+
import {
11+
assertIsKey,
12+
assertIsKeys,
13+
hasOwnProperty,
14+
isEmptyKey,
15+
parseConnectionString,
16+
pick,
17+
} from './utils';
18+
import { trace } from './utils/tracing';
19+
20+
type CreateClient = (
21+
connectionString: string | undefined,
22+
options?: deps.EdgeConfigClientOptions,
23+
) => EdgeConfigClient;
24+
25+
export function createCreateClient({
26+
getInMemoryEdgeConfig,
27+
getLocalEdgeConfig,
28+
fetchEdgeConfigItem,
29+
fetchEdgeConfigHas,
30+
fetchAllEdgeConfigItem,
31+
fetchEdgeConfigTrace,
32+
}: {
33+
getInMemoryEdgeConfig: typeof deps.getInMemoryEdgeConfig;
34+
getLocalEdgeConfig: typeof deps.getLocalEdgeConfig;
35+
fetchEdgeConfigItem: typeof deps.fetchEdgeConfigItem;
36+
fetchEdgeConfigHas: typeof deps.fetchEdgeConfigHas;
37+
fetchAllEdgeConfigItem: typeof deps.fetchAllEdgeConfigItem;
38+
fetchEdgeConfigTrace: typeof deps.fetchEdgeConfigTrace;
39+
}): CreateClient {
40+
/**
41+
* Create an Edge Config client.
42+
*
43+
* The client has multiple methods which allow you to read the Edge Config.
44+
*
45+
* If you need to programmatically write to an Edge Config, check out the [Update your Edge Config items](https://vercel.com/docs/storage/edge-config/vercel-api#update-your-edge-config-items) section.
46+
*
47+
* @param connectionString - A connection string. Usually you'd pass in `process.env.EDGE_CONFIG` here, which contains a connection string.
48+
* @returns An Edge Config Client instance
49+
*/
50+
return trace(
51+
function createClient(
52+
connectionString,
53+
options = {
54+
staleIfError: 604800 /* one week */,
55+
cache: 'no-store',
56+
},
57+
): EdgeConfigClient {
58+
if (!connectionString)
59+
throw new Error('@vercel/edge-config: No connection string provided');
60+
61+
const connection = parseConnectionString(connectionString);
62+
63+
if (!connection)
64+
throw new Error(
65+
'@vercel/edge-config: Invalid connection string provided',
66+
);
67+
68+
const edgeConfigId = connection.id;
69+
const baseUrl = connection.baseUrl;
70+
const version = connection.version; // version of the edge config read access api we talk to
71+
const headers: Record<string, string> = {
72+
Authorization: `Bearer ${connection.token}`,
73+
};
74+
75+
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- [@vercel/style-guide@5 migration]
76+
if (typeof process !== 'undefined' && process.env.VERCEL_ENV)
77+
headers['x-edge-config-vercel-env'] = process.env.VERCEL_ENV;
78+
79+
if (typeof sdkName === 'string' && typeof sdkVersion === 'string')
80+
headers['x-edge-config-sdk'] = `${sdkName}@${sdkVersion}`;
81+
82+
if (typeof options.staleIfError === 'number' && options.staleIfError > 0)
83+
headers['cache-control'] = `stale-if-error=${options.staleIfError}`;
84+
85+
const fetchCache = options.cache || 'no-store';
86+
87+
/**
88+
* While in development we use SWR-like behavior for the api client to
89+
* reduce latency.
90+
*/
91+
const shouldUseDevelopmentCache =
92+
!options.disableDevelopmentCache &&
93+
process.env.NODE_ENV === 'development' &&
94+
process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1';
95+
96+
const api: Omit<EdgeConfigClient, 'connection'> = {
97+
get: trace(
98+
async function get<T = EdgeConfigValue>(
99+
key: string,
100+
localOptions?: EdgeConfigFunctionsOptions,
101+
): Promise<T | undefined> {
102+
assertIsKey(key);
103+
104+
let localEdgeConfig: EmbeddedEdgeConfig | null = null;
105+
if (localOptions?.consistentRead) {
106+
// fall through to fetching
107+
} else if (shouldUseDevelopmentCache) {
108+
localEdgeConfig = await getInMemoryEdgeConfig(
109+
connectionString,
110+
fetchCache,
111+
options.staleIfError,
112+
);
113+
} else {
114+
localEdgeConfig = await getLocalEdgeConfig(
115+
connection.type,
116+
connection.id,
117+
fetchCache,
118+
);
119+
}
120+
121+
if (localEdgeConfig) {
122+
if (isEmptyKey(key)) return undefined;
123+
// We need to return a clone of the value so users can't modify
124+
// our original value, and so the reference changes.
125+
//
126+
// This makes it consistent with the real API.
127+
return Promise.resolve(localEdgeConfig.items[key] as T);
128+
}
129+
130+
return fetchEdgeConfigItem<T>(
131+
baseUrl,
132+
key,
133+
version,
134+
localOptions?.consistentRead,
135+
headers,
136+
fetchCache,
137+
);
138+
},
139+
{ name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } },
140+
),
141+
has: trace(
142+
async function has(
143+
key,
144+
localOptions?: EdgeConfigFunctionsOptions,
145+
): Promise<boolean> {
146+
assertIsKey(key);
147+
if (isEmptyKey(key)) return false;
148+
149+
let localEdgeConfig: EmbeddedEdgeConfig | null = null;
150+
151+
if (localOptions?.consistentRead) {
152+
// fall through to fetching
153+
} else if (shouldUseDevelopmentCache) {
154+
localEdgeConfig = await getInMemoryEdgeConfig(
155+
connectionString,
156+
fetchCache,
157+
options.staleIfError,
158+
);
159+
} else {
160+
localEdgeConfig = await getLocalEdgeConfig(
161+
connection.type,
162+
connection.id,
163+
fetchCache,
164+
);
165+
}
166+
167+
if (localEdgeConfig) {
168+
return Promise.resolve(
169+
hasOwnProperty(localEdgeConfig.items, key),
170+
);
171+
}
172+
173+
return fetchEdgeConfigHas(
174+
baseUrl,
175+
key,
176+
version,
177+
localOptions?.consistentRead,
178+
headers,
179+
fetchCache,
180+
);
181+
},
182+
{ name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } },
183+
),
184+
getAll: trace(
185+
async function getAll<T = EdgeConfigItems>(
186+
keys?: (keyof T)[],
187+
localOptions?: EdgeConfigFunctionsOptions,
188+
): Promise<T> {
189+
if (keys) {
190+
assertIsKeys(keys);
191+
}
192+
193+
let localEdgeConfig: EmbeddedEdgeConfig | null = null;
194+
195+
if (localOptions?.consistentRead) {
196+
// fall through to fetching
197+
} else if (shouldUseDevelopmentCache) {
198+
localEdgeConfig = await getInMemoryEdgeConfig(
199+
connectionString,
200+
fetchCache,
201+
options.staleIfError,
202+
);
203+
} else {
204+
localEdgeConfig = await getLocalEdgeConfig(
205+
connection.type,
206+
connection.id,
207+
fetchCache,
208+
);
209+
}
210+
211+
if (localEdgeConfig) {
212+
if (keys === undefined) {
213+
return Promise.resolve(localEdgeConfig.items as T);
214+
}
215+
216+
return Promise.resolve(pick(localEdgeConfig.items, keys) as T);
217+
}
218+
219+
return fetchAllEdgeConfigItem<T>(
220+
baseUrl,
221+
keys,
222+
version,
223+
localOptions?.consistentRead,
224+
headers,
225+
fetchCache,
226+
);
227+
},
228+
{
229+
name: 'getAll',
230+
isVerboseTrace: false,
231+
attributes: { edgeConfigId },
232+
},
233+
),
234+
digest: trace(
235+
async function digest(
236+
localOptions?: EdgeConfigFunctionsOptions,
237+
): Promise<string> {
238+
let localEdgeConfig: EmbeddedEdgeConfig | null = null;
239+
240+
if (localOptions?.consistentRead) {
241+
// fall through to fetching
242+
} else if (shouldUseDevelopmentCache) {
243+
localEdgeConfig = await getInMemoryEdgeConfig(
244+
connectionString,
245+
fetchCache,
246+
options.staleIfError,
247+
);
248+
} else {
249+
localEdgeConfig = await getLocalEdgeConfig(
250+
connection.type,
251+
connection.id,
252+
fetchCache,
253+
);
254+
}
255+
256+
if (localEdgeConfig) {
257+
return Promise.resolve(localEdgeConfig.digest);
258+
}
259+
260+
return fetchEdgeConfigTrace(
261+
baseUrl,
262+
version,
263+
localOptions?.consistentRead,
264+
headers,
265+
fetchCache,
266+
);
267+
},
268+
{
269+
name: 'digest',
270+
isVerboseTrace: false,
271+
attributes: { edgeConfigId },
272+
},
273+
),
274+
};
275+
276+
return { ...api, connection };
277+
},
278+
{
279+
name: 'createClient',
280+
},
281+
);
282+
}

packages/edge-config/src/edge-config.ts

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,20 @@
11
import { readFile } from '@vercel/edge-config-fs';
22
import { name as sdkName, version as sdkVersion } from '../package.json';
3-
import {
4-
isEmptyKey,
5-
ERRORS,
6-
UnexpectedNetworkError,
7-
parseConnectionString,
8-
} from './utils';
93
import type {
104
Connection,
11-
EdgeConfigClient,
125
EdgeConfigItems,
136
EdgeConfigValue,
147
EmbeddedEdgeConfig,
158
} from './types';
9+
import {
10+
ERRORS,
11+
isEmptyKey,
12+
parseConnectionString,
13+
UnexpectedNetworkError,
14+
} from './utils';
1615
import { fetchWithCachedResponse } from './utils/fetch-with-cached-response';
1716
import { trace } from './utils/tracing';
1817

19-
export { setTracerProvider } from './utils/tracing';
20-
21-
export {
22-
parseConnectionString,
23-
type EdgeConfigClient,
24-
type EdgeConfigItems,
25-
type EdgeConfigValue,
26-
type EmbeddedEdgeConfig,
27-
};
28-
2918
const X_EDGE_CONFIG_SDK_HEADER =
3019
typeof sdkName === 'string' && typeof sdkVersion === 'string'
3120
? `${sdkName}@${sdkVersion}`

0 commit comments

Comments
 (0)