From 02b4de3cabbf9dd416a15d31836f373f827dcd30 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 15:26:46 +0200 Subject: [PATCH 01/72] prepare postinstall script --- packages/edge-config/.gitignore | 1 + packages/edge-config/package.json | 3 +- packages/edge-config/scripts/postinstall.mts | 81 ++++++++++ packages/edge-config/src/utils/index.ts | 142 +----------------- .../src/utils/parse-connection-string.ts | 140 +++++++++++++++++ 5 files changed, 226 insertions(+), 141 deletions(-) create mode 100644 packages/edge-config/.gitignore create mode 100755 packages/edge-config/scripts/postinstall.mts create mode 100644 packages/edge-config/src/utils/parse-connection-string.ts diff --git a/packages/edge-config/.gitignore b/packages/edge-config/.gitignore new file mode 100644 index 000000000..0a5b7262d --- /dev/null +++ b/packages/edge-config/.gitignore @@ -0,0 +1 @@ +stores/* diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 0bd274fdf..34b191d1c 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -36,7 +36,8 @@ "test:common": "jest --env @edge-runtime/jest-environment .common.test.ts && jest --env node .common.test.ts", "test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts", "test:node": "jest --env node .node.test.ts", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "postinstall": "scripts/postinstall.mts" }, "jest": { "preset": "ts-jest", diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts new file mode 100755 index 000000000..015ccddb6 --- /dev/null +++ b/packages/edge-config/scripts/postinstall.mts @@ -0,0 +1,81 @@ +#!/usr/bin/env node --experimental-strip-types +/// + +// This script runs uncombiled with "node --experimental-strip-types", +// so all imports need to use ".ts" + +/* + * Reads all connected Edge Configs and emits them to the stores folder + * that can be accessed at runtime by the mockable-import function. + * + * Attaches the updatedAt timestamp from the header to the emitted file, since + * the endpoint does not currently include it in the response body. + */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Connection, EmbeddedEdgeConfig } from '../src/types.ts'; +import { parseConnectionString } from '../src/utils/parse-connection-string.ts'; + +// Get the directory where this CLI script is located +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Write to the stores folder relative to the package root +// This works both in development and when installed as a dependency +const getStoresDir = (): string => { + // In development: packages/edge-config/scripts/postinstall.ts -> packages/edge-config/stores/ + // When installed: node_modules/@vercel/edge-config/dist/cli.cjs -> node_modules/@vercel/edge-config/stores/ + return join(__dirname, '..', 'stores'); +}; + +async function main(): Promise { + const connections = Object.values(process.env).reduce( + (acc, value) => { + if (typeof value !== 'string') return acc; + const data = parseConnectionString(value); + + if (data) { + acc.push(data); + } + + return acc; + }, + [], + ); + + const storesDir = getStoresDir(); + // eslint-disable-next-line no-console -- This is a CLI tool + console.log(`Creating stores directory: ${storesDir}`); + await mkdir(storesDir, { recursive: true }); + + await Promise.all( + connections.map(async (connection) => { + const { data, updatedAt } = await fetch(connection.baseUrl, { + headers: { + authorization: `Bearer ${connection.token}`, + // consistentRead + 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, + }, + }).then(async (res) => { + const ts = res.headers.get('x-edge-config-updated-at'); + + return { + data: (await res.json()) as EmbeddedEdgeConfig, + updatedAt: ts ? Number(ts) : undefined, + }; + }); + + const outputPath = join(storesDir, `${connection.id}.json`); + await writeFile(outputPath, JSON.stringify({ ...data, updatedAt })); + // eslint-disable-next-line no-console -- This is a CLI tool + console.log(`Emitted Edge Config for ${connection.id} to: ${outputPath}`); + }), + ); +} + +main().catch((error) => { + // eslint-disable-next-line no-console -- This is a CLI tool + console.error('@vercel/edge-config: postinstall failed', error); + process.exit(1); +}); diff --git a/packages/edge-config/src/utils/index.ts b/packages/edge-config/src/utils/index.ts index 8cc02b738..6028e5c59 100644 --- a/packages/edge-config/src/utils/index.ts +++ b/packages/edge-config/src/utils/index.ts @@ -1,4 +1,3 @@ -import type { Connection } from '../types'; import { trace } from './tracing'; export const ERRORS = { @@ -6,6 +5,8 @@ export const ERRORS = { EDGE_CONFIG_NOT_FOUND: '@vercel/edge-config: Edge Config not found', }; +export { parseConnectionString } from './parse-connection-string'; + export class UnexpectedNetworkError extends Error { constructor(res: Response) { super( @@ -65,142 +66,3 @@ export const clone = trace( }, { name: 'clone' }, ); - -/** - * Parses internal edge config connection strings - * - * Internal edge config connection strings are those which are native to Vercel. - * - * Internal Edge Config Connection Strings look like this: - * https://edge-config.vercel.com/?token= - */ -function parseVercelConnectionStringFromUrl(text: string): Connection | null { - try { - const url = new URL(text); - if (url.host !== 'edge-config.vercel.com') return null; - if (url.protocol !== 'https:') return null; - if (!url.pathname.startsWith('/ecfg')) return null; - - const id = url.pathname.split('/')[1]; - if (!id) return null; - - const token = url.searchParams.get('token'); - if (!token || token === '') return null; - - return { - type: 'vercel', - baseUrl: `https://edge-config.vercel.com/${id}`, - id, - version: '1', - token, - }; - } catch { - return null; - } -} - -/** - * Parses a connection string with the following format: - * `edge-config:id=ecfg_abcd&token=xxx` - */ -function parseConnectionFromQueryParams(text: string): Connection | null { - try { - if (!text.startsWith('edge-config:')) return null; - const params = new URLSearchParams(text.slice(12)); - - const id = params.get('id'); - const token = params.get('token'); - - if (!id || !token) return null; - - return { - type: 'vercel', - baseUrl: `https://edge-config.vercel.com/${id}`, - id, - version: '1', - token, - }; - } catch { - // no-op - } - - return null; -} - -/** - * Parses info contained in connection strings. - * - * This works with the vercel-provided connection strings, but it also - * works with custom connection strings. - * - * The reason we support custom connection strings is that it makes testing - * edge config really straightforward. Users can provide connection strings - * pointing to their own servers and then either have a custom server - * return the desired values or even intercept requests with something like - * msw. - * - * To allow interception we need a custom connection string as the - * edge-config.vercel.com connection string might not always go over - * the network, so msw would not have a chance to intercept. - */ -/** - * Parses external edge config connection strings - * - * External edge config connection strings are those which are foreign to Vercel. - * - * External Edge Config Connection Strings look like this: - * - https://example.com/?id=&token= - * - https://example.com/?token= - */ -function parseExternalConnectionStringFromUrl( - connectionString: string, -): Connection | null { - try { - const url = new URL(connectionString); - - let id: string | null = url.searchParams.get('id'); - const token = url.searchParams.get('token'); - const version = url.searchParams.get('version') || '1'; - - // try to determine id based on pathname if it wasn't provided explicitly - if (!id || url.pathname.startsWith('/ecfg_')) { - id = url.pathname.split('/')[1] || null; - } - - if (!id || !token) return null; - - // remove all search params for use as baseURL - url.search = ''; - - // try to parse as external connection string - return { - type: 'external', - baseUrl: url.toString(), - id, - token, - version, - }; - } catch { - return null; - } -} - -/** - * Parse the edgeConfigId and token from an Edge Config Connection String. - * - * Edge Config Connection Strings usually look like one of the following: - * - https://edge-config.vercel.com/?token= - * - edge-config:id=&token= - * - * @param text - A potential Edge Config Connection String - * @returns The connection parsed from the given Connection String or null. - */ -export function parseConnectionString( - connectionString: string, -): Connection | null { - return ( - parseConnectionFromQueryParams(connectionString) || - parseVercelConnectionStringFromUrl(connectionString) || - parseExternalConnectionStringFromUrl(connectionString) - ); -} diff --git a/packages/edge-config/src/utils/parse-connection-string.ts b/packages/edge-config/src/utils/parse-connection-string.ts new file mode 100644 index 000000000..f09a5ade7 --- /dev/null +++ b/packages/edge-config/src/utils/parse-connection-string.ts @@ -0,0 +1,140 @@ +import type { Connection } from '../types'; + +/** + * Parses internal edge config connection strings + * + * Internal edge config connection strings are those which are native to Vercel. + * + * Internal Edge Config Connection Strings look like this: + * https://edge-config.vercel.com/?token= + */ +function parseVercelConnectionStringFromUrl(text: string): Connection | null { + try { + const url = new URL(text); + if (url.host !== 'edge-config.vercel.com') return null; + if (url.protocol !== 'https:') return null; + if (!url.pathname.startsWith('/ecfg')) return null; + + const id = url.pathname.split('/')[1]; + if (!id) return null; + + const token = url.searchParams.get('token'); + if (!token || token === '') return null; + + return { + type: 'vercel', + baseUrl: `https://edge-config.vercel.com/${id}`, + id, + version: '1', + token, + }; + } catch { + return null; + } +} + +/** + * Parses a connection string with the following format: + * `edge-config:id=ecfg_abcd&token=xxx` + */ +function parseConnectionFromQueryParams(text: string): Connection | null { + try { + if (!text.startsWith('edge-config:')) return null; + const params = new URLSearchParams(text.slice(12)); + + const id = params.get('id'); + const token = params.get('token'); + + if (!id || !token) return null; + + return { + type: 'vercel', + baseUrl: `https://edge-config.vercel.com/${id}`, + id, + version: '1', + token, + }; + } catch { + // no-op + } + + return null; +} + +/** + * Parses info contained in connection strings. + * + * This works with the vercel-provided connection strings, but it also + * works with custom connection strings. + * + * The reason we support custom connection strings is that it makes testing + * edge config really straightforward. Users can provide connection strings + * pointing to their own servers and then either have a custom server + * return the desired values or even intercept requests with something like + * msw. + * + * To allow interception we need a custom connection string as the + * edge-config.vercel.com connection string might not always go over + * the network, so msw would not have a chance to intercept. + */ +/** + * Parses external edge config connection strings + * + * External edge config connection strings are those which are foreign to Vercel. + * + * External Edge Config Connection Strings look like this: + * - https://example.com/?id=&token= + * - https://example.com/?token= + */ +function parseExternalConnectionStringFromUrl( + connectionString: string, +): Connection | null { + try { + const url = new URL(connectionString); + + let id: string | null = url.searchParams.get('id'); + const token = url.searchParams.get('token'); + const version = url.searchParams.get('version') || '1'; + + // try to determine id based on pathname if it wasn't provided explicitly + if (!id || url.pathname.startsWith('/ecfg_')) { + id = url.pathname.split('/')[1] || null; + } + + if (!id || !token) return null; + + // remove all search params for use as baseURL + url.search = ''; + + // try to parse as external connection string + return { + type: 'external', + baseUrl: url.toString(), + id, + token, + version, + }; + } catch { + return null; + } +} + +/** + * Parse the edgeConfigId and token from an Edge Config Connection String. + * + * Edge Config Connection Strings usually look like one of the following: + * - https://edge-config.vercel.com/?token= + * - edge-config:id=&token= + * + * @param text - A potential Edge Config Connection String + * @returns The connection parsed from the given Connection String or null. + */ +export function parseConnectionString( + connectionString: string, +): Connection | null { + return ( + parseConnectionFromQueryParams(connectionString) || + parseVercelConnectionStringFromUrl(connectionString) || + parseExternalConnectionStringFromUrl(connectionString) + ); +} From 2d88e680c851c92808a4055d30414630b1ee332c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 16:34:55 +0200 Subject: [PATCH 02/72] wip --- packages/edge-config/package.json | 3 +- .../edge-config/src/create-create-client.ts | 28 +++++++++++++++++++ packages/edge-config/src/edge-config.ts | 10 +++++++ packages/edge-config/src/index.next-js.ts | 13 +++++++++ packages/edge-config/src/index.ts | 2 ++ .../src/read-build-embedded-edge-config.ts | 28 +++++++++++++++++++ test/next/tsconfig.json | 19 +++++++++---- 7 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 packages/edge-config/src/read-build-embedded-edge-config.ts diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 34b191d1c..cf29846b5 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -19,7 +19,8 @@ }, "import": "./dist/index.js", "require": "./dist/index.cjs" - } + }, + "./stores/*": "./stores/*" }, "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 9df61745a..684e0349c 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -23,6 +23,7 @@ type CreateClient = ( ) => EdgeConfigClient; export function createCreateClient({ + getBuildEmbeddedEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, fetchEdgeConfigItem, @@ -30,6 +31,7 @@ export function createCreateClient({ fetchAllEdgeConfigItem, fetchEdgeConfigTrace, }: { + getBuildEmbeddedEdgeConfig: typeof deps.getBuildEmbeddedEdgeConfig; getInMemoryEdgeConfig: typeof deps.getInMemoryEdgeConfig; getLocalEdgeConfig: typeof deps.getLocalEdgeConfig; fetchEdgeConfigItem: typeof deps.fetchEdgeConfigItem; @@ -92,6 +94,27 @@ export function createCreateClient({ process.env.NODE_ENV === 'development' && process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; + let buildEmbeddedEdgeConfigPromise: + | Promise + | undefined = undefined; + + function getBuildEmbeddedEdgeConfigPromise() { + if (buildEmbeddedEdgeConfigPromise !== undefined) + return buildEmbeddedEdgeConfigPromise; + + if (!connection) { + buildEmbeddedEdgeConfigPromise = Promise.resolve(null); + return buildEmbeddedEdgeConfigPromise; + } + + buildEmbeddedEdgeConfigPromise = getBuildEmbeddedEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + return buildEmbeddedEdgeConfigPromise; + } + const api: Omit = { get: trace( async function get( @@ -100,6 +123,11 @@ export function createCreateClient({ ): Promise { assertIsKey(key); + const buildEmbeddedEdgeConfig = + await getBuildEmbeddedEdgeConfigPromise(); + + console.log('buildEmbeddedEdgeConfig', buildEmbeddedEdgeConfig); + let localEdgeConfig: EmbeddedEdgeConfig | null = null; if (localOptions?.consistentRead) { // fall through to fetching diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index 32833bfa3..764b0b879 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -1,5 +1,6 @@ import { readFile } from '@vercel/edge-config-fs'; import { name as sdkName, version as sdkVersion } from '../package.json'; +import { readBuildEmbeddedEdgeConfig } from './read-build-embedded-edge-config'; import type { Connection, EdgeConfigItems, @@ -105,6 +106,15 @@ const getPrivateEdgeConfig = trace( }, ); +export async function getBuildEmbeddedEdgeConfig( + connectionType: Connection['type'], + connectionId: Connection['id'], + _fetchCache: EdgeConfigClientOptions['cache'], +): Promise { + if (connectionType !== 'vercel') return null; + return readBuildEmbeddedEdgeConfig(connectionId); +} + /** * Reads the Edge Config from a local provider, if available, * to avoid Network requests. diff --git a/packages/edge-config/src/index.next-js.ts b/packages/edge-config/src/index.next-js.ts index b78b71330..c32697644 100644 --- a/packages/edge-config/src/index.next-js.ts +++ b/packages/edge-config/src/index.next-js.ts @@ -5,6 +5,7 @@ import { fetchEdgeConfigHas, fetchEdgeConfigItem, fetchEdgeConfigTrace, + getBuildEmbeddedEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, } from './edge-config'; @@ -48,6 +49,17 @@ function setCacheLifeFromFetchCache( } } +async function getBuildEmbeddedEdgeConfigForNext( + ...args: Parameters +): ReturnType { + 'use cache'; + + const [type, id, fetchCache] = args; + setCacheLifeFromFetchCache(fetchCache); + + return getBuildEmbeddedEdgeConfig(type, id, fetchCache); +} + async function getInMemoryEdgeConfigForNext( ...args: Parameters ): ReturnType { @@ -125,6 +137,7 @@ async function fetchEdgeConfigTraceForNext( * @returns An Edge Config Client instance */ export const createClient = createCreateClient({ + getBuildEmbeddedEdgeConfig: getBuildEmbeddedEdgeConfigForNext, getInMemoryEdgeConfig: getInMemoryEdgeConfigForNext, getLocalEdgeConfig: getLocalEdgeConfigForNext, fetchEdgeConfigItem: fetchEdgeConfigItemForNext, diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 4726c45c2..cca9affb1 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -4,6 +4,7 @@ import { fetchEdgeConfigHas, fetchEdgeConfigItem, fetchEdgeConfigTrace, + getBuildEmbeddedEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, } from './edge-config'; @@ -36,6 +37,7 @@ export { * @returns An Edge Config Client instance */ export const createClient = createCreateClient({ + getBuildEmbeddedEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, fetchEdgeConfigItem, diff --git a/packages/edge-config/src/read-build-embedded-edge-config.ts b/packages/edge-config/src/read-build-embedded-edge-config.ts new file mode 100644 index 000000000..bdbaeb0a8 --- /dev/null +++ b/packages/edge-config/src/read-build-embedded-edge-config.ts @@ -0,0 +1,28 @@ +/** + * Reads the local edge config that gets embedded at build time. + * + * We currently use webpackIgnore to avoid bundling the local edge config. + */ +export async function readBuildEmbeddedEdgeConfig( + id: string, +): Promise { + try { + console.log('attempting to read build embedded edge config', id); + // @ts-expect-error this file is generated later + return (await import(`@vercel/edge-config/stores.json`).then( + (module) => module.default[id], + )) as Promise; + } catch (e) { + if ( + typeof e === 'object' && + e !== null && + 'code' in e && + (e.code === 'ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING' || + e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') + ) { + return null; + } + + throw e; + } +} diff --git a/test/next/tsconfig.json b/test/next/tsconfig.json index e1063c1e6..2f20dc76b 100644 --- a/test/next/tsconfig.json +++ b/test/next/tsconfig.json @@ -2,7 +2,11 @@ "schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -13,7 +17,7 @@ "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -21,7 +25,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ @@ -29,7 +35,10 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - "src/app/vercel/blob/script.mts" + "src/app/vercel/blob/script.mts", + ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } From 489fbb692451eff6ecf78968449c9c0cef9fac50 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 16:39:38 +0200 Subject: [PATCH 03/72] working --- packages/edge-config/.gitignore | 2 +- packages/edge-config/package.json | 2 +- packages/edge-config/scripts/postinstall.mts | 10 +++++++--- packages/edge-config/stores.json | 7 +++++++ 4 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 packages/edge-config/stores.json diff --git a/packages/edge-config/.gitignore b/packages/edge-config/.gitignore index 0a5b7262d..0ea1a4e2f 100644 --- a/packages/edge-config/.gitignore +++ b/packages/edge-config/.gitignore @@ -1 +1 @@ -stores/* +./stores.json diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index cf29846b5..434cb0539 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -20,7 +20,7 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./stores/*": "./stores/*" + "./stores.json": "./stores.json" }, "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index 015ccddb6..a9eb5663f 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -26,7 +26,7 @@ const __dirname = dirname(__filename); const getStoresDir = (): string => { // In development: packages/edge-config/scripts/postinstall.ts -> packages/edge-config/stores/ // When installed: node_modules/@vercel/edge-config/dist/cli.cjs -> node_modules/@vercel/edge-config/stores/ - return join(__dirname, '..', 'stores'); + return join(__dirname, '..'); }; async function main(): Promise { @@ -66,8 +66,12 @@ async function main(): Promise { }; }); - const outputPath = join(storesDir, `${connection.id}.json`); - await writeFile(outputPath, JSON.stringify({ ...data, updatedAt })); + // TODO move out of loop + const outputPath = join(storesDir, `stores.json`); + await writeFile( + outputPath, + JSON.stringify({ [connection.id]: { ...data, updatedAt } }), + ); // eslint-disable-next-line no-console -- This is a CLI tool console.log(`Emitted Edge Config for ${connection.id} to: ${outputPath}`); }), diff --git a/packages/edge-config/stores.json b/packages/edge-config/stores.json new file mode 100644 index 000000000..4bfe63f3b --- /dev/null +++ b/packages/edge-config/stores.json @@ -0,0 +1,7 @@ +{ + "ecfg_euamc8ncmprhdjlztrar1ssw6ruf": { + "digest": "4292c235789a8e29dcddb476bd92b2c5c18a0d1a7b1e6e31d60eb90867ce0d82", + "updatedAt": 1761143376379, + "items": { "keyForTest": "valueForTest1", "t": 1 } + } +} From b0e4ac213dd7069ccc5b21abd8c4af1d5ac6c920 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 16:56:52 +0200 Subject: [PATCH 04/72] use stores.json in postinstall and readBuildEmbeddedEdgeConfig --- packages/edge-config/.gitignore | 1 - packages/edge-config/package.json | 2 +- packages/edge-config/scripts/postinstall.mts | 56 ++++++++----------- packages/edge-config/src/edge-config.ts | 2 +- .../read-build-embedded-edge-config.ts | 2 +- packages/edge-config/stores.json | 7 --- 6 files changed, 26 insertions(+), 44 deletions(-) delete mode 100644 packages/edge-config/.gitignore rename packages/edge-config/src/{ => utils}/read-build-embedded-edge-config.ts (90%) delete mode 100644 packages/edge-config/stores.json diff --git a/packages/edge-config/.gitignore b/packages/edge-config/.gitignore deleted file mode 100644 index 0ea1a4e2f..000000000 --- a/packages/edge-config/.gitignore +++ /dev/null @@ -1 +0,0 @@ -./stores.json diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 434cb0539..bbd929d8d 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -20,7 +20,7 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./stores.json": "./stores.json" + "./dist/stores.json": "./dist/stores.json" }, "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index a9eb5663f..b0f0aec5a 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -11,7 +11,7 @@ * Attaches the updatedAt timestamp from the header to the emitted file, since * the endpoint does not currently include it in the response body. */ -import { mkdir, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Connection, EmbeddedEdgeConfig } from '../src/types.ts'; @@ -21,12 +21,11 @@ import { parseConnectionString } from '../src/utils/parse-connection-string.ts'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Write to the stores folder relative to the package root -// This works both in development and when installed as a dependency -const getStoresDir = (): string => { - // In development: packages/edge-config/scripts/postinstall.ts -> packages/edge-config/stores/ - // When installed: node_modules/@vercel/edge-config/dist/cli.cjs -> node_modules/@vercel/edge-config/stores/ - return join(__dirname, '..'); +// Write to the stores.json file of the package itself +const getOutputPath = (): string => { + // During development: packages/edge-config/stores.json + // When installed: node_modules/@vercel/edge-config/stores.json + return join(__dirname, '..', 'dist', 'stores.json'); }; async function main(): Promise { @@ -34,48 +33,39 @@ async function main(): Promise { (acc, value) => { if (typeof value !== 'string') return acc; const data = parseConnectionString(value); - - if (data) { - acc.push(data); - } - + if (data) acc.push(data); return acc; }, [], ); - const storesDir = getStoresDir(); - // eslint-disable-next-line no-console -- This is a CLI tool - console.log(`Creating stores directory: ${storesDir}`); - await mkdir(storesDir, { recursive: true }); + const outputPath = getOutputPath(); - await Promise.all( + const values = await Promise.all( connections.map(async (connection) => { - const { data, updatedAt } = await fetch(connection.baseUrl, { + const res = await fetch(connection.baseUrl, { headers: { authorization: `Bearer ${connection.token}`, // consistentRead 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, }, - }).then(async (res) => { - const ts = res.headers.get('x-edge-config-updated-at'); - - return { - data: (await res.json()) as EmbeddedEdgeConfig, - updatedAt: ts ? Number(ts) : undefined, - }; }); - // TODO move out of loop - const outputPath = join(storesDir, `stores.json`); - await writeFile( - outputPath, - JSON.stringify({ [connection.id]: { ...data, updatedAt } }), - ); - // eslint-disable-next-line no-console -- This is a CLI tool - console.log(`Emitted Edge Config for ${connection.id} to: ${outputPath}`); + const ts = res.headers.get('x-edge-config-updated-at'); + const data: EmbeddedEdgeConfig = await res.json(); + return { data, updatedAt: ts ? Number(ts) : undefined }; }), ); + + const stores = connections.reduce((acc, connection, index) => { + const value = values[index]; + acc[connection.id] = value; + return acc; + }, {}); + + await writeFile(outputPath, JSON.stringify(stores)); + // eslint-disable-next-line no-console -- This is a CLI tool + console.log(`Emitted ${outputPath}`); } main().catch((error) => { diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index 764b0b879..5b2087211 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -1,6 +1,5 @@ import { readFile } from '@vercel/edge-config-fs'; import { name as sdkName, version as sdkVersion } from '../package.json'; -import { readBuildEmbeddedEdgeConfig } from './read-build-embedded-edge-config'; import type { Connection, EdgeConfigItems, @@ -14,6 +13,7 @@ import { UnexpectedNetworkError, } from './utils'; import { fetchWithCachedResponse } from './utils/fetch-with-cached-response'; +import { readBuildEmbeddedEdgeConfig } from './utils/read-build-embedded-edge-config'; import { trace } from './utils/tracing'; const X_EDGE_CONFIG_SDK_HEADER = diff --git a/packages/edge-config/src/read-build-embedded-edge-config.ts b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts similarity index 90% rename from packages/edge-config/src/read-build-embedded-edge-config.ts rename to packages/edge-config/src/utils/read-build-embedded-edge-config.ts index bdbaeb0a8..b2306650a 100644 --- a/packages/edge-config/src/read-build-embedded-edge-config.ts +++ b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts @@ -9,7 +9,7 @@ export async function readBuildEmbeddedEdgeConfig( try { console.log('attempting to read build embedded edge config', id); // @ts-expect-error this file is generated later - return (await import(`@vercel/edge-config/stores.json`).then( + return (await import(`@vercel/edge-config/dist/stores.json`).then( (module) => module.default[id], )) as Promise; } catch (e) { diff --git a/packages/edge-config/stores.json b/packages/edge-config/stores.json deleted file mode 100644 index 4bfe63f3b..000000000 --- a/packages/edge-config/stores.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "ecfg_euamc8ncmprhdjlztrar1ssw6ruf": { - "digest": "4292c235789a8e29dcddb476bd92b2c5c18a0d1a7b1e6e31d60eb90867ce0d82", - "updatedAt": 1761143376379, - "items": { "keyForTest": "valueForTest1", "t": 1 } - } -} From e24da87441e8dbfe55cdc78ea85a0ad13d35afdd Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 17:37:10 +0200 Subject: [PATCH 05/72] fall back to embedded version in create-create-client --- .../edge-config/src/create-create-client.ts | 283 ++++++++++-------- packages/edge-config/src/edge-config.ts | 12 +- packages/edge-config/src/index.next-js.ts | 4 +- .../utils/read-build-embedded-edge-config.ts | 4 +- 4 files changed, 166 insertions(+), 137 deletions(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 684e0349c..273ed5993 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -22,6 +22,8 @@ type CreateClient = ( options?: deps.EdgeConfigClientOptions, ) => EdgeConfigClient; +const FALLBACK_WARNING = '@vercel/edge-config: Falling back to build embed'; + export function createCreateClient({ getBuildEmbeddedEdgeConfig, getInMemoryEdgeConfig, @@ -94,26 +96,10 @@ export function createCreateClient({ process.env.NODE_ENV === 'development' && process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; - let buildEmbeddedEdgeConfigPromise: - | Promise - | undefined = undefined; - - function getBuildEmbeddedEdgeConfigPromise() { - if (buildEmbeddedEdgeConfigPromise !== undefined) - return buildEmbeddedEdgeConfigPromise; - - if (!connection) { - buildEmbeddedEdgeConfigPromise = Promise.resolve(null); - return buildEmbeddedEdgeConfigPromise; - } - - buildEmbeddedEdgeConfigPromise = getBuildEmbeddedEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); - return buildEmbeddedEdgeConfigPromise; - } + const buildEmbeddedEdgeConfigPromise = (() => { + if (!connection || connection.type !== 'vercel') return null; + return getBuildEmbeddedEdgeConfig(connection.id, fetchCache); + })(); const api: Omit = { get: trace( @@ -124,44 +110,50 @@ export function createCreateClient({ assertIsKey(key); const buildEmbeddedEdgeConfig = - await getBuildEmbeddedEdgeConfigPromise(); - - console.log('buildEmbeddedEdgeConfig', buildEmbeddedEdgeConfig); + await buildEmbeddedEdgeConfigPromise; - let localEdgeConfig: EmbeddedEdgeConfig | null = null; - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); - } - - if (localEdgeConfig) { + function select(edgeConfig: EmbeddedEdgeConfig) { if (isEmptyKey(key)) return undefined; // We need to return a clone of the value so users can't modify // our original value, and so the reference changes. // // This makes it consistent with the real API. - return Promise.resolve(localEdgeConfig.items[key] as T); + return Promise.resolve(edgeConfig.items[key] as T); } - return fetchEdgeConfigItem( - baseUrl, - key, - version, - localOptions?.consistentRead, - headers, - fetchCache, - ); + try { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } + + if (localEdgeConfig) return select(localEdgeConfig); + + return fetchEdgeConfigItem( + baseUrl, + key, + version, + localOptions?.consistentRead, + headers, + fetchCache, + ); + } catch (error) { + if (!buildEmbeddedEdgeConfig) throw error; + console.warn(FALLBACK_WARNING); + return select(buildEmbeddedEdgeConfig.data); + } }, { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, ), @@ -173,36 +165,49 @@ export function createCreateClient({ assertIsKey(key); if (isEmptyKey(key)) return false; - let localEdgeConfig: EmbeddedEdgeConfig | null = null; + const buildEmbeddedEdgeConfig = + await buildEmbeddedEdgeConfigPromise; - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); + function select(edgeConfig: EmbeddedEdgeConfig) { + return Promise.resolve(hasOwn(edgeConfig.items, key)); } - if (localEdgeConfig) { - return Promise.resolve(hasOwn(localEdgeConfig.items, key)); - } + try { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } - return fetchEdgeConfigHas( - baseUrl, - key, - version, - localOptions?.consistentRead, - headers, - fetchCache, - ); + if (localEdgeConfig) { + return Promise.resolve(hasOwn(localEdgeConfig.items, key)); + } + + return fetchEdgeConfigHas( + baseUrl, + key, + version, + localOptions?.consistentRead, + headers, + fetchCache, + ); + } catch (error) { + if (!buildEmbeddedEdgeConfig) throw error; + console.warn(FALLBACK_WARNING); + return select(buildEmbeddedEdgeConfig.data); + } }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, ), @@ -215,40 +220,49 @@ export function createCreateClient({ assertIsKeys(keys); } - let localEdgeConfig: EmbeddedEdgeConfig | null = null; + const buildEmbeddedEdgeConfig = + await buildEmbeddedEdgeConfigPromise; - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); + function select(edgeConfig: EmbeddedEdgeConfig) { + return keys === undefined + ? Promise.resolve(edgeConfig.items as T) + : Promise.resolve(pick(edgeConfig.items as T, keys) as T); } - if (localEdgeConfig) { - if (keys === undefined) { - return Promise.resolve(localEdgeConfig.items as T); + try { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); } - return Promise.resolve(pick(localEdgeConfig.items, keys) as T); - } + if (localEdgeConfig) return select(localEdgeConfig); - return fetchAllEdgeConfigItem( - baseUrl, - keys, - version, - localOptions?.consistentRead, - headers, - fetchCache, - ); + return fetchAllEdgeConfigItem( + baseUrl, + keys, + version, + localOptions?.consistentRead, + headers, + fetchCache, + ); + } catch (error) { + if (!buildEmbeddedEdgeConfig) throw error; + console.warn(FALLBACK_WARNING); + return select(buildEmbeddedEdgeConfig.data); + } }, { name: 'getAll', @@ -260,35 +274,46 @@ export function createCreateClient({ async function digest( localOptions?: EdgeConfigFunctionsOptions, ): Promise { - let localEdgeConfig: EmbeddedEdgeConfig | null = null; + const buildEmbeddedEdgeConfig = + await buildEmbeddedEdgeConfigPromise; - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); + function select(embeddedEdgeConfig: EmbeddedEdgeConfig) { + return embeddedEdgeConfig.digest; } - if (localEdgeConfig) { - return Promise.resolve(localEdgeConfig.digest); - } + try { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } + + if (localEdgeConfig) return select(localEdgeConfig); - return fetchEdgeConfigTrace( - baseUrl, - version, - localOptions?.consistentRead, - headers, - fetchCache, - ); + return fetchEdgeConfigTrace( + baseUrl, + version, + localOptions?.consistentRead, + headers, + fetchCache, + ); + } catch (error) { + if (!buildEmbeddedEdgeConfig) throw error; + console.warn(FALLBACK_WARNING); + return select(buildEmbeddedEdgeConfig.data); + } }, { name: 'digest', diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index 5b2087211..75f09f418 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -107,12 +107,16 @@ const getPrivateEdgeConfig = trace( ); export async function getBuildEmbeddedEdgeConfig( - connectionType: Connection['type'], connectionId: Connection['id'], _fetchCache: EdgeConfigClientOptions['cache'], -): Promise { - if (connectionType !== 'vercel') return null; - return readBuildEmbeddedEdgeConfig(connectionId); +): Promise<{ + data: EmbeddedEdgeConfig; + updatedAt: number | undefined; +} | null> { + return readBuildEmbeddedEdgeConfig<{ + data: EmbeddedEdgeConfig; + updatedAt: number | undefined; + }>(connectionId); } /** diff --git a/packages/edge-config/src/index.next-js.ts b/packages/edge-config/src/index.next-js.ts index c32697644..f0f4055a8 100644 --- a/packages/edge-config/src/index.next-js.ts +++ b/packages/edge-config/src/index.next-js.ts @@ -54,10 +54,10 @@ async function getBuildEmbeddedEdgeConfigForNext( ): ReturnType { 'use cache'; - const [type, id, fetchCache] = args; + const [id, fetchCache] = args; setCacheLifeFromFetchCache(fetchCache); - return getBuildEmbeddedEdgeConfig(type, id, fetchCache); + return getBuildEmbeddedEdgeConfig(id, fetchCache); } async function getInMemoryEdgeConfigForNext( diff --git a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts index b2306650a..e53c1dbb2 100644 --- a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts +++ b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts @@ -10,8 +10,8 @@ export async function readBuildEmbeddedEdgeConfig( console.log('attempting to read build embedded edge config', id); // @ts-expect-error this file is generated later return (await import(`@vercel/edge-config/dist/stores.json`).then( - (module) => module.default[id], - )) as Promise; + (module) => module.default[id] ?? null, + )) as Promise; } catch (e) { if ( typeof e === 'object' && From f8d89f0561a6297ab9e9603e954bef8197ff16a1 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 17:44:35 +0200 Subject: [PATCH 06/72] use build embed for prod builds --- .../edge-config/src/create-create-client.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 273ed5993..205f7b9a3 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -101,6 +101,10 @@ export function createCreateClient({ return getBuildEmbeddedEdgeConfig(connection.id, fetchCache); })(); + const isBuildStep = + process.env.CI === '1' || + process.env.NEXT_PHASE === 'phase-production-build'; + const api: Omit = { get: trace( async function get( @@ -121,6 +125,10 @@ export function createCreateClient({ return Promise.resolve(edgeConfig.items[key] as T); } + if (buildEmbeddedEdgeConfig && isBuildStep) { + return select(buildEmbeddedEdgeConfig.data); + } + try { let localEdgeConfig: EmbeddedEdgeConfig | null = null; if (localOptions?.consistentRead) { @@ -172,6 +180,10 @@ export function createCreateClient({ return Promise.resolve(hasOwn(edgeConfig.items, key)); } + if (buildEmbeddedEdgeConfig && isBuildStep) { + return select(buildEmbeddedEdgeConfig.data); + } + try { let localEdgeConfig: EmbeddedEdgeConfig | null = null; @@ -229,6 +241,10 @@ export function createCreateClient({ : Promise.resolve(pick(edgeConfig.items as T, keys) as T); } + if (buildEmbeddedEdgeConfig && isBuildStep) { + return select(buildEmbeddedEdgeConfig.data); + } + try { let localEdgeConfig: EmbeddedEdgeConfig | null = null; @@ -281,6 +297,10 @@ export function createCreateClient({ return embeddedEdgeConfig.digest; } + if (buildEmbeddedEdgeConfig && isBuildStep) { + return select(buildEmbeddedEdgeConfig.data); + } + try { let localEdgeConfig: EmbeddedEdgeConfig | null = null; From 4ebf39c739337e43e698b307f34a231401980324 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 17:58:47 +0200 Subject: [PATCH 07/72] allow skipping embedding with EDGE_CONFIG_SKIP_BUILD_EMBEDDING --- .changeset/famous-games-sleep.md | 5 +++++ packages/edge-config/package.json | 2 +- packages/edge-config/scripts/postinstall.mts | 8 +++++++- packages/edge-config/src/create-create-client.ts | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 .changeset/famous-games-sleep.md diff --git a/.changeset/famous-games-sleep.md b/.changeset/famous-games-sleep.md new file mode 100644 index 000000000..3695a23b9 --- /dev/null +++ b/.changeset/famous-games-sleep.md @@ -0,0 +1,5 @@ +--- +"@vercel/edge-config": minor +--- + +Embed fallback Edge Config during build diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index bbd929d8d..ab13921ca 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -38,7 +38,7 @@ "test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts", "test:node": "jest --env node .node.test.ts", "type-check": "tsc --noEmit", - "postinstall": "scripts/postinstall.mts" + "postinstall": "test -z \"$EDGE_CONFIG_SKIP_BUILD_EMBEDDING\" && ./scripts/postinstall.mts || true" }, "jest": { "preset": "ts-jest", diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index b0f0aec5a..16291f819 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -65,7 +65,13 @@ async function main(): Promise { await writeFile(outputPath, JSON.stringify(stores)); // eslint-disable-next-line no-console -- This is a CLI tool - console.log(`Emitted ${outputPath}`); + if (Object.keys(stores).length === 0) { + console.error(`@vercel/edge-config: Embedded no stores`); + } else { + console.log( + `@vercel/edge-config: Embedded ${Object.keys(stores).join(', ')}`, + ); + } } main().catch((error) => { diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 205f7b9a3..7208363ea 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -97,6 +97,7 @@ export function createCreateClient({ process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; const buildEmbeddedEdgeConfigPromise = (() => { + if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING) return null; if (!connection || connection.type !== 'vercel') return null; return getBuildEmbeddedEdgeConfig(connection.id, fetchCache); })(); From c06bc1975448419e1fb889a989e3debd9c67edae Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:07:15 +0200 Subject: [PATCH 08/72] respect EDGE_CONFIG_SKIP_BUILD_EMBEDDING from within postinstall.mts --- packages/edge-config/package.json | 2 +- packages/edge-config/scripts/postinstall.mts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index ab13921ca..bbd929d8d 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -38,7 +38,7 @@ "test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts", "test:node": "jest --env node .node.test.ts", "type-check": "tsc --noEmit", - "postinstall": "test -z \"$EDGE_CONFIG_SKIP_BUILD_EMBEDDING\" && ./scripts/postinstall.mts || true" + "postinstall": "scripts/postinstall.mts" }, "jest": { "preset": "ts-jest", diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index 16291f819..714b08a10 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -29,6 +29,8 @@ const getOutputPath = (): string => { }; async function main(): Promise { + if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING) return; + const connections = Object.values(process.env).reduce( (acc, value) => { if (typeof value !== 'string') return acc; From 7365805b8672f4a203f896b5075e19b9ae2a3ee1 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:07:41 +0200 Subject: [PATCH 09/72] compare to 1 --- packages/edge-config/scripts/postinstall.mts | 2 +- packages/edge-config/src/create-create-client.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index 714b08a10..d3999cbcf 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -29,7 +29,7 @@ const getOutputPath = (): string => { }; async function main(): Promise { - if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING) return; + if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return; const connections = Object.values(process.env).reduce( (acc, value) => { diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 7208363ea..4ab085b00 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -97,7 +97,7 @@ export function createCreateClient({ process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; const buildEmbeddedEdgeConfigPromise = (() => { - if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING) return null; + if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return null; if (!connection || connection.type !== 'vercel') return null; return getBuildEmbeddedEdgeConfig(connection.id, fetchCache); })(); From 0b2f68b5b6d6f0022c8bb106a7d8188a8c742044 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:09:51 +0200 Subject: [PATCH 10/72] use -S --- packages/edge-config/scripts/postinstall.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index d3999cbcf..29d5ee225 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -1,4 +1,4 @@ -#!/usr/bin/env node --experimental-strip-types +#!/usr/bin/env -S node --experimental-strip-types /// // This script runs uncombiled with "node --experimental-strip-types", From 52e5dec36c3162ffa2464913e5391a0cc5a82341 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:12:12 +0200 Subject: [PATCH 11/72] ensure folder exists --- packages/edge-config/scripts/postinstall.mts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index 29d5ee225..ed3487c30 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -11,7 +11,7 @@ * Attaches the updatedAt timestamp from the header to the emitted file, since * the endpoint does not currently include it in the response body. */ -import { writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Connection, EmbeddedEdgeConfig } from '../src/types.ts'; @@ -65,6 +65,8 @@ async function main(): Promise { return acc; }, {}); + // Ensure the dist directory exists before writing + await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, JSON.stringify(stores)); // eslint-disable-next-line no-console -- This is a CLI tool if (Object.keys(stores).length === 0) { From 2d76f1cda365afb1921f71409d043cfcd30d4310 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:13:25 +0200 Subject: [PATCH 12/72] log npm_lifecycle_event --- packages/edge-config/scripts/postinstall.mts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index ed3487c30..a6138b1f6 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -29,6 +29,10 @@ const getOutputPath = (): string => { }; async function main(): Promise { + console.log( + 'process.env.npm_lifecycle_event', + process.env.npm_lifecycle_event, + ); if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return; const connections = Object.values(process.env).reduce( From 8ce7568d8b015e505456b3a92b1a5e5842c22b0f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:17:52 +0200 Subject: [PATCH 13/72] ignore MODULE_NOT_FOUND --- packages/edge-config/scripts/postinstall.mts | 4 ---- .../edge-config/src/utils/read-build-embedded-edge-config.ts | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index a6138b1f6..ed3487c30 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -29,10 +29,6 @@ const getOutputPath = (): string => { }; async function main(): Promise { - console.log( - 'process.env.npm_lifecycle_event', - process.env.npm_lifecycle_event, - ); if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return; const connections = Object.values(process.env).reduce( diff --git a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts index e53c1dbb2..5c122fc83 100644 --- a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts +++ b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts @@ -18,7 +18,8 @@ export async function readBuildEmbeddedEdgeConfig( e !== null && 'code' in e && (e.code === 'ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING' || - e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') + e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || + e.code === 'MODULE_NOT_FOUND') ) { return null; } From 899ac4683e776851b78c87f41690840d187fadc1 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:27:23 +0200 Subject: [PATCH 14/72] =?UTF-8?q?update=20node=20iron=20=E2=86=92=20krypto?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .node-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.node-version b/.node-version index 9de225682..b03f40867 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -lts/iron +lts/krypton From 47f31ccff5f7a76235506813f6e57d207c452498 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:32:08 +0200 Subject: [PATCH 15/72] include scripts folder --- packages/edge-config/package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index bbd929d8d..ccfe1adeb 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -25,7 +25,8 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "files": [ - "dist" + "dist", + "scripts" ], "scripts": { "build": "tsup", @@ -78,6 +79,6 @@ } }, "engines": { - "node": ">=14.6" + "node": ">=22" } } From 4a351052f08b6e43fe9af80a7d70b409ff0d0756 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:35:50 +0200 Subject: [PATCH 16/72] manually call node --- packages/edge-config/package.json | 2 +- packages/edge-config/scripts/postinstall.mts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index ccfe1adeb..4163655da 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -39,7 +39,7 @@ "test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts", "test:node": "jest --env node .node.test.ts", "type-check": "tsc --noEmit", - "postinstall": "scripts/postinstall.mts" + "postinstall": "node --experimental-strip-types ./scripts/postinstall.mts" }, "jest": { "preset": "ts-jest", diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.mts index ed3487c30..b7485cef9 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.mts @@ -1,4 +1,3 @@ -#!/usr/bin/env -S node --experimental-strip-types /// // This script runs uncombiled with "node --experimental-strip-types", From cb20773f8e1d1888386a39381765f2beede2e58d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:39:35 +0200 Subject: [PATCH 17/72] temporary: publish src files --- packages/edge-config/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 4163655da..4ac194cb8 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -25,6 +25,7 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "files": [ + "src", "dist", "scripts" ], From 25cad9865196a441e6966bfb19fc99523a0e20e7 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:47:43 +0200 Subject: [PATCH 18/72] compile postinstall script --- packages/edge-config/package.json | 8 +++----- .../scripts/{postinstall.mts => postinstall.ts} | 14 +++++++++++--- packages/edge-config/tsconfig.json | 2 +- packages/edge-config/tsup.config.js | 12 ++++++++++++ 4 files changed, 27 insertions(+), 9 deletions(-) rename packages/edge-config/scripts/{postinstall.mts => postinstall.ts} (92%) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 4ac194cb8..3d4030e60 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -25,9 +25,7 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "files": [ - "src", - "dist", - "scripts" + "dist" ], "scripts": { "build": "tsup", @@ -40,7 +38,7 @@ "test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts", "test:node": "jest --env node .node.test.ts", "type-check": "tsc --noEmit", - "postinstall": "node --experimental-strip-types ./scripts/postinstall.mts" + "postinstall": "node ./dist/postinstall.js" }, "jest": { "preset": "ts-jest", @@ -80,6 +78,6 @@ } }, "engines": { - "node": ">=22" + "node": ">=14.6" } } diff --git a/packages/edge-config/scripts/postinstall.mts b/packages/edge-config/scripts/postinstall.ts similarity index 92% rename from packages/edge-config/scripts/postinstall.mts rename to packages/edge-config/scripts/postinstall.ts index b7485cef9..0c3741603 100755 --- a/packages/edge-config/scripts/postinstall.mts +++ b/packages/edge-config/scripts/postinstall.ts @@ -13,13 +13,21 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { Connection, EmbeddedEdgeConfig } from '../src/types.ts'; -import { parseConnectionString } from '../src/utils/parse-connection-string.ts'; +import type { Connection, EmbeddedEdgeConfig } from '../src/types'; +import { parseConnectionString } from '../src/utils/parse-connection-string'; // Get the directory where this CLI script is located const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +type StoresJson = Record< + string, + { + data: EmbeddedEdgeConfig; + updatedAt: number | undefined; + } +>; + // Write to the stores.json file of the package itself const getOutputPath = (): string => { // During development: packages/edge-config/stores.json @@ -58,7 +66,7 @@ async function main(): Promise { }), ); - const stores = connections.reduce((acc, connection, index) => { + const stores = connections.reduce((acc, connection, index) => { const value = values[index]; acc[connection.id] = value; return acc; diff --git a/packages/edge-config/tsconfig.json b/packages/edge-config/tsconfig.json index ba7a29ed6..e82c3c44e 100644 --- a/packages/edge-config/tsconfig.json +++ b/packages/edge-config/tsconfig.json @@ -4,5 +4,5 @@ "resolveJsonModule": true, "target": "ESNext" }, - "include": ["src"] + "include": ["src", "scripts"] } diff --git a/packages/edge-config/tsup.config.js b/packages/edge-config/tsup.config.js index 9f00fb676..1f36817a7 100644 --- a/packages/edge-config/tsup.config.js +++ b/packages/edge-config/tsup.config.js @@ -24,4 +24,16 @@ export default [ dts: true, external: ['node_modules'], }), + // postinstall script + defineConfig({ + entry: ['scripts/postinstall.ts'], + format: 'esm', + splitting: true, + sourcemap: true, + minify: false, + clean: true, + skipNodeModulesBundle: true, + dts: true, + external: ['node_modules'], + }), ]; From a08d53e80ad9d222d566c345d774c3899f1a7195 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:52:22 +0200 Subject: [PATCH 19/72] commit dist-postinstall --- .../dist-postinstall/postinstall.d.ts | 1 + .../dist-postinstall/postinstall.js | 123 ++++++++++++++++++ .../dist-postinstall/postinstall.js.map | 1 + packages/edge-config/package.json | 2 +- packages/edge-config/tsup.config.js | 4 + 5 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 packages/edge-config/dist-postinstall/postinstall.d.ts create mode 100644 packages/edge-config/dist-postinstall/postinstall.js create mode 100644 packages/edge-config/dist-postinstall/postinstall.js.map diff --git a/packages/edge-config/dist-postinstall/postinstall.d.ts b/packages/edge-config/dist-postinstall/postinstall.d.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/edge-config/dist-postinstall/postinstall.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/edge-config/dist-postinstall/postinstall.js b/packages/edge-config/dist-postinstall/postinstall.js new file mode 100644 index 000000000..8fbbcbb56 --- /dev/null +++ b/packages/edge-config/dist-postinstall/postinstall.js @@ -0,0 +1,123 @@ +// scripts/postinstall.ts +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// src/utils/parse-connection-string.ts +function parseVercelConnectionStringFromUrl(text) { + try { + const url = new URL(text); + if (url.host !== 'edge-config.vercel.com') return null; + if (url.protocol !== 'https:') return null; + if (!url.pathname.startsWith('/ecfg')) return null; + const id = url.pathname.split('/')[1]; + if (!id) return null; + const token = url.searchParams.get('token'); + if (!token || token === '') return null; + return { + type: 'vercel', + baseUrl: `https://edge-config.vercel.com/${id}`, + id, + version: '1', + token, + }; + } catch { + return null; + } +} +function parseConnectionFromQueryParams(text) { + try { + if (!text.startsWith('edge-config:')) return null; + const params = new URLSearchParams(text.slice(12)); + const id = params.get('id'); + const token = params.get('token'); + if (!id || !token) return null; + return { + type: 'vercel', + baseUrl: `https://edge-config.vercel.com/${id}`, + id, + version: '1', + token, + }; + } catch {} + return null; +} +function parseExternalConnectionStringFromUrl(connectionString) { + try { + const url = new URL(connectionString); + let id = url.searchParams.get('id'); + const token = url.searchParams.get('token'); + const version = url.searchParams.get('version') || '1'; + if (!id || url.pathname.startsWith('/ecfg_')) { + id = url.pathname.split('/')[1] || null; + } + if (!id || !token) return null; + url.search = ''; + return { + type: 'external', + baseUrl: url.toString(), + id, + token, + version, + }; + } catch { + return null; + } +} +function parseConnectionString(connectionString) { + return ( + parseConnectionFromQueryParams(connectionString) || + parseVercelConnectionStringFromUrl(connectionString) || + parseExternalConnectionStringFromUrl(connectionString) + ); +} + +// scripts/postinstall.ts +var __filename = fileURLToPath(import.meta.url); +var __dirname = dirname(__filename); +var getOutputPath = () => { + return join(__dirname, '..', 'dist', 'stores.json'); +}; +async function main() { + if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return; + const connections = Object.values(process.env).reduce((acc, value) => { + if (typeof value !== 'string') return acc; + const data = parseConnectionString(value); + if (data) acc.push(data); + return acc; + }, []); + const outputPath = getOutputPath(); + const values = await Promise.all( + connections.map(async (connection) => { + const res = await fetch(connection.baseUrl, { + headers: { + authorization: `Bearer ${connection.token}`, + // consistentRead + 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, + }, + }); + const ts = res.headers.get('x-edge-config-updated-at'); + const data = await res.json(); + return { data, updatedAt: ts ? Number(ts) : void 0 }; + }), + ); + const stores = connections.reduce((acc, connection, index) => { + const value = values[index]; + acc[connection.id] = value; + return acc; + }, {}); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, JSON.stringify(stores)); + if (Object.keys(stores).length === 0) { + console.error(`@vercel/edge-config: Embedded no stores`); + } else { + console.log( + `@vercel/edge-config: Embedded ${Object.keys(stores).join(', ')}`, + ); + } +} +main().catch((error) => { + console.error('@vercel/edge-config: postinstall failed', error); + process.exit(1); +}); +//# sourceMappingURL=postinstall.js.map diff --git a/packages/edge-config/dist-postinstall/postinstall.js.map b/packages/edge-config/dist-postinstall/postinstall.js.map new file mode 100644 index 000000000..8ef73780b --- /dev/null +++ b/packages/edge-config/dist-postinstall/postinstall.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../scripts/postinstall.ts","../src/utils/parse-connection-string.ts"],"sourcesContent":["/// \n\n// This script runs uncombiled with \"node --experimental-strip-types\",\n// so all imports need to use \".ts\"\n\n/*\n * Reads all connected Edge Configs and emits them to the stores folder\n * that can be accessed at runtime by the mockable-import function.\n *\n * Attaches the updatedAt timestamp from the header to the emitted file, since\n * the endpoint does not currently include it in the response body.\n */\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport type { Connection, EmbeddedEdgeConfig } from '../src/types';\nimport { parseConnectionString } from '../src/utils/parse-connection-string';\n\n// Get the directory where this CLI script is located\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\ntype StoresJson = Record<\n string,\n {\n data: EmbeddedEdgeConfig;\n updatedAt: number | undefined;\n }\n>;\n\n// Write to the stores.json file of the package itself\nconst getOutputPath = (): string => {\n // During development: packages/edge-config/stores.json\n // When installed: node_modules/@vercel/edge-config/stores.json\n return join(__dirname, '..', 'dist', 'stores.json');\n};\n\nasync function main(): Promise {\n if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return;\n\n const connections = Object.values(process.env).reduce(\n (acc, value) => {\n if (typeof value !== 'string') return acc;\n const data = parseConnectionString(value);\n if (data) acc.push(data);\n return acc;\n },\n [],\n );\n\n const outputPath = getOutputPath();\n\n const values = await Promise.all(\n connections.map(async (connection) => {\n const res = await fetch(connection.baseUrl, {\n headers: {\n authorization: `Bearer ${connection.token}`,\n // consistentRead\n 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`,\n },\n });\n\n const ts = res.headers.get('x-edge-config-updated-at');\n const data: EmbeddedEdgeConfig = await res.json();\n return { data, updatedAt: ts ? Number(ts) : undefined };\n }),\n );\n\n const stores = connections.reduce((acc, connection, index) => {\n const value = values[index];\n acc[connection.id] = value;\n return acc;\n }, {});\n\n // Ensure the dist directory exists before writing\n await mkdir(dirname(outputPath), { recursive: true });\n await writeFile(outputPath, JSON.stringify(stores));\n // eslint-disable-next-line no-console -- This is a CLI tool\n if (Object.keys(stores).length === 0) {\n console.error(`@vercel/edge-config: Embedded no stores`);\n } else {\n console.log(\n `@vercel/edge-config: Embedded ${Object.keys(stores).join(', ')}`,\n );\n }\n}\n\nmain().catch((error) => {\n // eslint-disable-next-line no-console -- This is a CLI tool\n console.error('@vercel/edge-config: postinstall failed', error);\n process.exit(1);\n});\n","import type { Connection } from '../types';\n\n/**\n * Parses internal edge config connection strings\n *\n * Internal edge config connection strings are those which are native to Vercel.\n *\n * Internal Edge Config Connection Strings look like this:\n * https://edge-config.vercel.com/?token=\n */\nfunction parseVercelConnectionStringFromUrl(text: string): Connection | null {\n try {\n const url = new URL(text);\n if (url.host !== 'edge-config.vercel.com') return null;\n if (url.protocol !== 'https:') return null;\n if (!url.pathname.startsWith('/ecfg')) return null;\n\n const id = url.pathname.split('/')[1];\n if (!id) return null;\n\n const token = url.searchParams.get('token');\n if (!token || token === '') return null;\n\n return {\n type: 'vercel',\n baseUrl: `https://edge-config.vercel.com/${id}`,\n id,\n version: '1',\n token,\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Parses a connection string with the following format:\n * `edge-config:id=ecfg_abcd&token=xxx`\n */\nfunction parseConnectionFromQueryParams(text: string): Connection | null {\n try {\n if (!text.startsWith('edge-config:')) return null;\n const params = new URLSearchParams(text.slice(12));\n\n const id = params.get('id');\n const token = params.get('token');\n\n if (!id || !token) return null;\n\n return {\n type: 'vercel',\n baseUrl: `https://edge-config.vercel.com/${id}`,\n id,\n version: '1',\n token,\n };\n } catch {\n // no-op\n }\n\n return null;\n}\n\n/**\n * Parses info contained in connection strings.\n *\n * This works with the vercel-provided connection strings, but it also\n * works with custom connection strings.\n *\n * The reason we support custom connection strings is that it makes testing\n * edge config really straightforward. Users can provide connection strings\n * pointing to their own servers and then either have a custom server\n * return the desired values or even intercept requests with something like\n * msw.\n *\n * To allow interception we need a custom connection string as the\n * edge-config.vercel.com connection string might not always go over\n * the network, so msw would not have a chance to intercept.\n */\n/**\n * Parses external edge config connection strings\n *\n * External edge config connection strings are those which are foreign to Vercel.\n *\n * External Edge Config Connection Strings look like this:\n * - https://example.com/?id=&token=\n * - https://example.com/?token=\n */\nfunction parseExternalConnectionStringFromUrl(\n connectionString: string,\n): Connection | null {\n try {\n const url = new URL(connectionString);\n\n let id: string | null = url.searchParams.get('id');\n const token = url.searchParams.get('token');\n const version = url.searchParams.get('version') || '1';\n\n // try to determine id based on pathname if it wasn't provided explicitly\n if (!id || url.pathname.startsWith('/ecfg_')) {\n id = url.pathname.split('/')[1] || null;\n }\n\n if (!id || !token) return null;\n\n // remove all search params for use as baseURL\n url.search = '';\n\n // try to parse as external connection string\n return {\n type: 'external',\n baseUrl: url.toString(),\n id,\n token,\n version,\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Parse the edgeConfigId and token from an Edge Config Connection String.\n *\n * Edge Config Connection Strings usually look like one of the following:\n * - https://edge-config.vercel.com/?token=\n * - edge-config:id=&token=\n *\n * @param text - A potential Edge Config Connection String\n * @returns The connection parsed from the given Connection String or null.\n */\nexport function parseConnectionString(\n connectionString: string,\n): Connection | null {\n return (\n parseConnectionFromQueryParams(connectionString) ||\n parseVercelConnectionStringFromUrl(connectionString) ||\n parseExternalConnectionStringFromUrl(connectionString)\n );\n}\n"],"mappings":";AAYA,SAAS,OAAO,iBAAiB;AACjC,SAAS,SAAS,YAAY;AAC9B,SAAS,qBAAqB;;;ACJ9B,SAAS,mCAAmC,MAAiC;AAC3E,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,IAAI;AACxB,QAAI,IAAI,SAAS,yBAA0B,QAAO;AAClD,QAAI,IAAI,aAAa,SAAU,QAAO;AACtC,QAAI,CAAC,IAAI,SAAS,WAAW,OAAO,EAAG,QAAO;AAE9C,UAAM,KAAK,IAAI,SAAS,MAAM,GAAG,EAAE,CAAC;AACpC,QAAI,CAAC,GAAI,QAAO;AAEhB,UAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,QAAI,CAAC,SAAS,UAAU,GAAI,QAAO;AAEnC,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS,kCAAkC,EAAE;AAAA,MAC7C;AAAA,MACA,SAAS;AAAA,MACT;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMA,SAAS,+BAA+B,MAAiC;AACvE,MAAI;AACF,QAAI,CAAC,KAAK,WAAW,cAAc,EAAG,QAAO;AAC7C,UAAM,SAAS,IAAI,gBAAgB,KAAK,MAAM,EAAE,CAAC;AAEjD,UAAM,KAAK,OAAO,IAAI,IAAI;AAC1B,UAAM,QAAQ,OAAO,IAAI,OAAO;AAEhC,QAAI,CAAC,MAAM,CAAC,MAAO,QAAO;AAE1B,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS,kCAAkC,EAAE;AAAA,MAC7C;AAAA,MACA,SAAS;AAAA,MACT;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,SAAO;AACT;AA2BA,SAAS,qCACP,kBACmB;AACnB,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,gBAAgB;AAEpC,QAAI,KAAoB,IAAI,aAAa,IAAI,IAAI;AACjD,UAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,UAAM,UAAU,IAAI,aAAa,IAAI,SAAS,KAAK;AAGnD,QAAI,CAAC,MAAM,IAAI,SAAS,WAAW,QAAQ,GAAG;AAC5C,WAAK,IAAI,SAAS,MAAM,GAAG,EAAE,CAAC,KAAK;AAAA,IACrC;AAEA,QAAI,CAAC,MAAM,CAAC,MAAO,QAAO;AAG1B,QAAI,SAAS;AAGb,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS,IAAI,SAAS;AAAA,MACtB;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAYO,SAAS,sBACd,kBACmB;AACnB,SACE,+BAA+B,gBAAgB,KAC/C,mCAAmC,gBAAgB,KACnD,qCAAqC,gBAAgB;AAEzD;;;ADxHA,IAAM,aAAa,cAAc,YAAY,GAAG;AAChD,IAAM,YAAY,QAAQ,UAAU;AAWpC,IAAM,gBAAgB,MAAc;AAGlC,SAAO,KAAK,WAAW,MAAM,QAAQ,aAAa;AACpD;AAEA,eAAe,OAAsB;AACnC,MAAI,QAAQ,IAAI,qCAAqC,IAAK;AAE1D,QAAM,cAAc,OAAO,OAAO,QAAQ,GAAG,EAAE;AAAA,IAC7C,CAAC,KAAK,UAAU;AACd,UAAI,OAAO,UAAU,SAAU,QAAO;AACtC,YAAM,OAAO,sBAAsB,KAAK;AACxC,UAAI,KAAM,KAAI,KAAK,IAAI;AACvB,aAAO;AAAA,IACT;AAAA,IACA,CAAC;AAAA,EACH;AAEA,QAAM,aAAa,cAAc;AAEjC,QAAM,SAAS,MAAM,QAAQ;AAAA,IAC3B,YAAY,IAAI,OAAO,eAAe;AACpC,YAAM,MAAM,MAAM,MAAM,WAAW,SAAS;AAAA,QAC1C,SAAS;AAAA,UACP,eAAe,UAAU,WAAW,KAAK;AAAA;AAAA,UAEzC,gCAAgC,GAAG,OAAO,gBAAgB;AAAA,QAC5D;AAAA,MACF,CAAC;AAED,YAAM,KAAK,IAAI,QAAQ,IAAI,0BAA0B;AACrD,YAAM,OAA2B,MAAM,IAAI,KAAK;AAChD,aAAO,EAAE,MAAM,WAAW,KAAK,OAAO,EAAE,IAAI,OAAU;AAAA,IACxD,CAAC;AAAA,EACH;AAEA,QAAM,SAAS,YAAY,OAAmB,CAAC,KAAK,YAAY,UAAU;AACxE,UAAM,QAAQ,OAAO,KAAK;AAC1B,QAAI,WAAW,EAAE,IAAI;AACrB,WAAO;AAAA,EACT,GAAG,CAAC,CAAC;AAGL,QAAM,MAAM,QAAQ,UAAU,GAAG,EAAE,WAAW,KAAK,CAAC;AACpD,QAAM,UAAU,YAAY,KAAK,UAAU,MAAM,CAAC;AAElD,MAAI,OAAO,KAAK,MAAM,EAAE,WAAW,GAAG;AACpC,YAAQ,MAAM,yCAAyC;AAAA,EACzD,OAAO;AACL,YAAQ;AAAA,MACN,iCAAiC,OAAO,KAAK,MAAM,EAAE,KAAK,IAAI,CAAC;AAAA,IACjE;AAAA,EACF;AACF;AAEA,KAAK,EAAE,MAAM,CAAC,UAAU;AAEtB,UAAQ,MAAM,2CAA2C,KAAK;AAC9D,UAAQ,KAAK,CAAC;AAChB,CAAC;","names":[]} \ No newline at end of file diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 3d4030e60..31f79d14a 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -38,7 +38,7 @@ "test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts", "test:node": "jest --env node .node.test.ts", "type-check": "tsc --noEmit", - "postinstall": "node ./dist/postinstall.js" + "postinstall": "node ./dist-postinstall/postinstall.js" }, "jest": { "preset": "ts-jest", diff --git a/packages/edge-config/tsup.config.js b/packages/edge-config/tsup.config.js index 1f36817a7..176ff05ef 100644 --- a/packages/edge-config/tsup.config.js +++ b/packages/edge-config/tsup.config.js @@ -27,6 +27,10 @@ export default [ // postinstall script defineConfig({ entry: ['scripts/postinstall.ts'], + // TODO workaround since the repo itself also runs postinstall, at which + // point the dist folder does not exist yet. So we need to commit the file + // for the repo itself + outDir: 'dist-postinstall', format: 'esm', splitting: true, sourcemap: true, From 9f9572b0734971d440648843cdbf4a42518b9996 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 18:52:53 +0200 Subject: [PATCH 20/72] dist-postinstall --- packages/edge-config/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 31f79d14a..bbf5bbef1 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -25,7 +25,8 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "files": [ - "dist" + "dist", + "dist-postinstall" ], "scripts": { "build": "tsup", From b752a7f1ccc9d3cf97077895fb056a8db0c9d2f2 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 19:43:18 +0200 Subject: [PATCH 21/72] add debug log --- packages/edge-config/src/create-create-client.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 4ab085b00..9addb4ffb 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -99,7 +99,12 @@ export function createCreateClient({ const buildEmbeddedEdgeConfigPromise = (() => { if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return null; if (!connection || connection.type !== 'vercel') return null; - return getBuildEmbeddedEdgeConfig(connection.id, fetchCache); + return getBuildEmbeddedEdgeConfig(connection.id, fetchCache).then( + (value) => { + console.log('@vercel/edge-config: preloaded', connection.id, value); + return value; + }, + ); })(); const isBuildStep = From a0ada7cd0bbba81a6e93a23fa1acdd6a40395fce Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 20:18:53 +0200 Subject: [PATCH 22/72] swap postinstall to cli --- .../dist-postinstall/postinstall.d.ts | 1 - .../dist-postinstall/postinstall.js | 123 ------------------ .../dist-postinstall/postinstall.js.map | 1 - packages/edge-config/package.json | 12 +- .../{scripts/postinstall.ts => src/cli.ts} | 65 ++++----- .../edge-config/src/create-create-client.ts | 1 - packages/edge-config/tsup.config.js | 8 +- pnpm-lock.yaml | 14 +- 8 files changed, 56 insertions(+), 169 deletions(-) delete mode 100644 packages/edge-config/dist-postinstall/postinstall.d.ts delete mode 100644 packages/edge-config/dist-postinstall/postinstall.js delete mode 100644 packages/edge-config/dist-postinstall/postinstall.js.map rename packages/edge-config/{scripts/postinstall.ts => src/cli.ts} (61%) diff --git a/packages/edge-config/dist-postinstall/postinstall.d.ts b/packages/edge-config/dist-postinstall/postinstall.d.ts deleted file mode 100644 index cb0ff5c3b..000000000 --- a/packages/edge-config/dist-postinstall/postinstall.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/edge-config/dist-postinstall/postinstall.js b/packages/edge-config/dist-postinstall/postinstall.js deleted file mode 100644 index 8fbbcbb56..000000000 --- a/packages/edge-config/dist-postinstall/postinstall.js +++ /dev/null @@ -1,123 +0,0 @@ -// scripts/postinstall.ts -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// src/utils/parse-connection-string.ts -function parseVercelConnectionStringFromUrl(text) { - try { - const url = new URL(text); - if (url.host !== 'edge-config.vercel.com') return null; - if (url.protocol !== 'https:') return null; - if (!url.pathname.startsWith('/ecfg')) return null; - const id = url.pathname.split('/')[1]; - if (!id) return null; - const token = url.searchParams.get('token'); - if (!token || token === '') return null; - return { - type: 'vercel', - baseUrl: `https://edge-config.vercel.com/${id}`, - id, - version: '1', - token, - }; - } catch { - return null; - } -} -function parseConnectionFromQueryParams(text) { - try { - if (!text.startsWith('edge-config:')) return null; - const params = new URLSearchParams(text.slice(12)); - const id = params.get('id'); - const token = params.get('token'); - if (!id || !token) return null; - return { - type: 'vercel', - baseUrl: `https://edge-config.vercel.com/${id}`, - id, - version: '1', - token, - }; - } catch {} - return null; -} -function parseExternalConnectionStringFromUrl(connectionString) { - try { - const url = new URL(connectionString); - let id = url.searchParams.get('id'); - const token = url.searchParams.get('token'); - const version = url.searchParams.get('version') || '1'; - if (!id || url.pathname.startsWith('/ecfg_')) { - id = url.pathname.split('/')[1] || null; - } - if (!id || !token) return null; - url.search = ''; - return { - type: 'external', - baseUrl: url.toString(), - id, - token, - version, - }; - } catch { - return null; - } -} -function parseConnectionString(connectionString) { - return ( - parseConnectionFromQueryParams(connectionString) || - parseVercelConnectionStringFromUrl(connectionString) || - parseExternalConnectionStringFromUrl(connectionString) - ); -} - -// scripts/postinstall.ts -var __filename = fileURLToPath(import.meta.url); -var __dirname = dirname(__filename); -var getOutputPath = () => { - return join(__dirname, '..', 'dist', 'stores.json'); -}; -async function main() { - if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return; - const connections = Object.values(process.env).reduce((acc, value) => { - if (typeof value !== 'string') return acc; - const data = parseConnectionString(value); - if (data) acc.push(data); - return acc; - }, []); - const outputPath = getOutputPath(); - const values = await Promise.all( - connections.map(async (connection) => { - const res = await fetch(connection.baseUrl, { - headers: { - authorization: `Bearer ${connection.token}`, - // consistentRead - 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, - }, - }); - const ts = res.headers.get('x-edge-config-updated-at'); - const data = await res.json(); - return { data, updatedAt: ts ? Number(ts) : void 0 }; - }), - ); - const stores = connections.reduce((acc, connection, index) => { - const value = values[index]; - acc[connection.id] = value; - return acc; - }, {}); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, JSON.stringify(stores)); - if (Object.keys(stores).length === 0) { - console.error(`@vercel/edge-config: Embedded no stores`); - } else { - console.log( - `@vercel/edge-config: Embedded ${Object.keys(stores).join(', ')}`, - ); - } -} -main().catch((error) => { - console.error('@vercel/edge-config: postinstall failed', error); - process.exit(1); -}); -//# sourceMappingURL=postinstall.js.map diff --git a/packages/edge-config/dist-postinstall/postinstall.js.map b/packages/edge-config/dist-postinstall/postinstall.js.map deleted file mode 100644 index 8ef73780b..000000000 --- a/packages/edge-config/dist-postinstall/postinstall.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["../scripts/postinstall.ts","../src/utils/parse-connection-string.ts"],"sourcesContent":["/// \n\n// This script runs uncombiled with \"node --experimental-strip-types\",\n// so all imports need to use \".ts\"\n\n/*\n * Reads all connected Edge Configs and emits them to the stores folder\n * that can be accessed at runtime by the mockable-import function.\n *\n * Attaches the updatedAt timestamp from the header to the emitted file, since\n * the endpoint does not currently include it in the response body.\n */\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport type { Connection, EmbeddedEdgeConfig } from '../src/types';\nimport { parseConnectionString } from '../src/utils/parse-connection-string';\n\n// Get the directory where this CLI script is located\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\ntype StoresJson = Record<\n string,\n {\n data: EmbeddedEdgeConfig;\n updatedAt: number | undefined;\n }\n>;\n\n// Write to the stores.json file of the package itself\nconst getOutputPath = (): string => {\n // During development: packages/edge-config/stores.json\n // When installed: node_modules/@vercel/edge-config/stores.json\n return join(__dirname, '..', 'dist', 'stores.json');\n};\n\nasync function main(): Promise {\n if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return;\n\n const connections = Object.values(process.env).reduce(\n (acc, value) => {\n if (typeof value !== 'string') return acc;\n const data = parseConnectionString(value);\n if (data) acc.push(data);\n return acc;\n },\n [],\n );\n\n const outputPath = getOutputPath();\n\n const values = await Promise.all(\n connections.map(async (connection) => {\n const res = await fetch(connection.baseUrl, {\n headers: {\n authorization: `Bearer ${connection.token}`,\n // consistentRead\n 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`,\n },\n });\n\n const ts = res.headers.get('x-edge-config-updated-at');\n const data: EmbeddedEdgeConfig = await res.json();\n return { data, updatedAt: ts ? Number(ts) : undefined };\n }),\n );\n\n const stores = connections.reduce((acc, connection, index) => {\n const value = values[index];\n acc[connection.id] = value;\n return acc;\n }, {});\n\n // Ensure the dist directory exists before writing\n await mkdir(dirname(outputPath), { recursive: true });\n await writeFile(outputPath, JSON.stringify(stores));\n // eslint-disable-next-line no-console -- This is a CLI tool\n if (Object.keys(stores).length === 0) {\n console.error(`@vercel/edge-config: Embedded no stores`);\n } else {\n console.log(\n `@vercel/edge-config: Embedded ${Object.keys(stores).join(', ')}`,\n );\n }\n}\n\nmain().catch((error) => {\n // eslint-disable-next-line no-console -- This is a CLI tool\n console.error('@vercel/edge-config: postinstall failed', error);\n process.exit(1);\n});\n","import type { Connection } from '../types';\n\n/**\n * Parses internal edge config connection strings\n *\n * Internal edge config connection strings are those which are native to Vercel.\n *\n * Internal Edge Config Connection Strings look like this:\n * https://edge-config.vercel.com/?token=\n */\nfunction parseVercelConnectionStringFromUrl(text: string): Connection | null {\n try {\n const url = new URL(text);\n if (url.host !== 'edge-config.vercel.com') return null;\n if (url.protocol !== 'https:') return null;\n if (!url.pathname.startsWith('/ecfg')) return null;\n\n const id = url.pathname.split('/')[1];\n if (!id) return null;\n\n const token = url.searchParams.get('token');\n if (!token || token === '') return null;\n\n return {\n type: 'vercel',\n baseUrl: `https://edge-config.vercel.com/${id}`,\n id,\n version: '1',\n token,\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Parses a connection string with the following format:\n * `edge-config:id=ecfg_abcd&token=xxx`\n */\nfunction parseConnectionFromQueryParams(text: string): Connection | null {\n try {\n if (!text.startsWith('edge-config:')) return null;\n const params = new URLSearchParams(text.slice(12));\n\n const id = params.get('id');\n const token = params.get('token');\n\n if (!id || !token) return null;\n\n return {\n type: 'vercel',\n baseUrl: `https://edge-config.vercel.com/${id}`,\n id,\n version: '1',\n token,\n };\n } catch {\n // no-op\n }\n\n return null;\n}\n\n/**\n * Parses info contained in connection strings.\n *\n * This works with the vercel-provided connection strings, but it also\n * works with custom connection strings.\n *\n * The reason we support custom connection strings is that it makes testing\n * edge config really straightforward. Users can provide connection strings\n * pointing to their own servers and then either have a custom server\n * return the desired values or even intercept requests with something like\n * msw.\n *\n * To allow interception we need a custom connection string as the\n * edge-config.vercel.com connection string might not always go over\n * the network, so msw would not have a chance to intercept.\n */\n/**\n * Parses external edge config connection strings\n *\n * External edge config connection strings are those which are foreign to Vercel.\n *\n * External Edge Config Connection Strings look like this:\n * - https://example.com/?id=&token=\n * - https://example.com/?token=\n */\nfunction parseExternalConnectionStringFromUrl(\n connectionString: string,\n): Connection | null {\n try {\n const url = new URL(connectionString);\n\n let id: string | null = url.searchParams.get('id');\n const token = url.searchParams.get('token');\n const version = url.searchParams.get('version') || '1';\n\n // try to determine id based on pathname if it wasn't provided explicitly\n if (!id || url.pathname.startsWith('/ecfg_')) {\n id = url.pathname.split('/')[1] || null;\n }\n\n if (!id || !token) return null;\n\n // remove all search params for use as baseURL\n url.search = '';\n\n // try to parse as external connection string\n return {\n type: 'external',\n baseUrl: url.toString(),\n id,\n token,\n version,\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Parse the edgeConfigId and token from an Edge Config Connection String.\n *\n * Edge Config Connection Strings usually look like one of the following:\n * - https://edge-config.vercel.com/?token=\n * - edge-config:id=&token=\n *\n * @param text - A potential Edge Config Connection String\n * @returns The connection parsed from the given Connection String or null.\n */\nexport function parseConnectionString(\n connectionString: string,\n): Connection | null {\n return (\n parseConnectionFromQueryParams(connectionString) ||\n parseVercelConnectionStringFromUrl(connectionString) ||\n parseExternalConnectionStringFromUrl(connectionString)\n );\n}\n"],"mappings":";AAYA,SAAS,OAAO,iBAAiB;AACjC,SAAS,SAAS,YAAY;AAC9B,SAAS,qBAAqB;;;ACJ9B,SAAS,mCAAmC,MAAiC;AAC3E,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,IAAI;AACxB,QAAI,IAAI,SAAS,yBAA0B,QAAO;AAClD,QAAI,IAAI,aAAa,SAAU,QAAO;AACtC,QAAI,CAAC,IAAI,SAAS,WAAW,OAAO,EAAG,QAAO;AAE9C,UAAM,KAAK,IAAI,SAAS,MAAM,GAAG,EAAE,CAAC;AACpC,QAAI,CAAC,GAAI,QAAO;AAEhB,UAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,QAAI,CAAC,SAAS,UAAU,GAAI,QAAO;AAEnC,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS,kCAAkC,EAAE;AAAA,MAC7C;AAAA,MACA,SAAS;AAAA,MACT;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMA,SAAS,+BAA+B,MAAiC;AACvE,MAAI;AACF,QAAI,CAAC,KAAK,WAAW,cAAc,EAAG,QAAO;AAC7C,UAAM,SAAS,IAAI,gBAAgB,KAAK,MAAM,EAAE,CAAC;AAEjD,UAAM,KAAK,OAAO,IAAI,IAAI;AAC1B,UAAM,QAAQ,OAAO,IAAI,OAAO;AAEhC,QAAI,CAAC,MAAM,CAAC,MAAO,QAAO;AAE1B,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS,kCAAkC,EAAE;AAAA,MAC7C;AAAA,MACA,SAAS;AAAA,MACT;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,SAAO;AACT;AA2BA,SAAS,qCACP,kBACmB;AACnB,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,gBAAgB;AAEpC,QAAI,KAAoB,IAAI,aAAa,IAAI,IAAI;AACjD,UAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,UAAM,UAAU,IAAI,aAAa,IAAI,SAAS,KAAK;AAGnD,QAAI,CAAC,MAAM,IAAI,SAAS,WAAW,QAAQ,GAAG;AAC5C,WAAK,IAAI,SAAS,MAAM,GAAG,EAAE,CAAC,KAAK;AAAA,IACrC;AAEA,QAAI,CAAC,MAAM,CAAC,MAAO,QAAO;AAG1B,QAAI,SAAS;AAGb,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS,IAAI,SAAS;AAAA,MACtB;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAYO,SAAS,sBACd,kBACmB;AACnB,SACE,+BAA+B,gBAAgB,KAC/C,mCAAmC,gBAAgB,KACnD,qCAAqC,gBAAgB;AAEzD;;;ADxHA,IAAM,aAAa,cAAc,YAAY,GAAG;AAChD,IAAM,YAAY,QAAQ,UAAU;AAWpC,IAAM,gBAAgB,MAAc;AAGlC,SAAO,KAAK,WAAW,MAAM,QAAQ,aAAa;AACpD;AAEA,eAAe,OAAsB;AACnC,MAAI,QAAQ,IAAI,qCAAqC,IAAK;AAE1D,QAAM,cAAc,OAAO,OAAO,QAAQ,GAAG,EAAE;AAAA,IAC7C,CAAC,KAAK,UAAU;AACd,UAAI,OAAO,UAAU,SAAU,QAAO;AACtC,YAAM,OAAO,sBAAsB,KAAK;AACxC,UAAI,KAAM,KAAI,KAAK,IAAI;AACvB,aAAO;AAAA,IACT;AAAA,IACA,CAAC;AAAA,EACH;AAEA,QAAM,aAAa,cAAc;AAEjC,QAAM,SAAS,MAAM,QAAQ;AAAA,IAC3B,YAAY,IAAI,OAAO,eAAe;AACpC,YAAM,MAAM,MAAM,MAAM,WAAW,SAAS;AAAA,QAC1C,SAAS;AAAA,UACP,eAAe,UAAU,WAAW,KAAK;AAAA;AAAA,UAEzC,gCAAgC,GAAG,OAAO,gBAAgB;AAAA,QAC5D;AAAA,MACF,CAAC;AAED,YAAM,KAAK,IAAI,QAAQ,IAAI,0BAA0B;AACrD,YAAM,OAA2B,MAAM,IAAI,KAAK;AAChD,aAAO,EAAE,MAAM,WAAW,KAAK,OAAO,EAAE,IAAI,OAAU;AAAA,IACxD,CAAC;AAAA,EACH;AAEA,QAAM,SAAS,YAAY,OAAmB,CAAC,KAAK,YAAY,UAAU;AACxE,UAAM,QAAQ,OAAO,KAAK;AAC1B,QAAI,WAAW,EAAE,IAAI;AACrB,WAAO;AAAA,EACT,GAAG,CAAC,CAAC;AAGL,QAAM,MAAM,QAAQ,UAAU,GAAG,EAAE,WAAW,KAAK,CAAC;AACpD,QAAM,UAAU,YAAY,KAAK,UAAU,MAAM,CAAC;AAElD,MAAI,OAAO,KAAK,MAAM,EAAE,WAAW,GAAG;AACpC,YAAQ,MAAM,yCAAyC;AAAA,EACzD,OAAO;AACL,YAAQ;AAAA,MACN,iCAAiC,OAAO,KAAK,MAAM,EAAE,KAAK,IAAI,CAAC;AAAA,IACjE;AAAA,EACF;AACF;AAEA,KAAK,EAAE,MAAM,CAAC,UAAU;AAEtB,UAAQ,MAAM,2CAA2C,KAAK;AAC9D,UAAQ,KAAK,CAAC;AAChB,CAAC;","names":[]} \ No newline at end of file diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index bbf5bbef1..2790830f6 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -25,8 +25,7 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "files": [ - "dist", - "dist-postinstall" + "dist" ], "scripts": { "build": "tsup", @@ -38,8 +37,10 @@ "test:common": "jest --env @edge-runtime/jest-environment .common.test.ts && jest --env node .common.test.ts", "test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts", "test:node": "jest --env node .node.test.ts", - "type-check": "tsc --noEmit", - "postinstall": "node ./dist-postinstall/postinstall.js" + "type-check": "tsc --noEmit" + }, + "bin": { + "edge-config": "./dist/cli.js" }, "jest": { "preset": "ts-jest", @@ -49,7 +50,8 @@ "testEnvironment": "node" }, "dependencies": { - "@vercel/edge-config-fs": "workspace:*" + "@vercel/edge-config-fs": "workspace:*", + "commander": "14.0.2" }, "devDependencies": { "@changesets/cli": "2.29.7", diff --git a/packages/edge-config/scripts/postinstall.ts b/packages/edge-config/src/cli.ts similarity index 61% rename from packages/edge-config/scripts/postinstall.ts rename to packages/edge-config/src/cli.ts index 0c3741603..b7ad09578 100755 --- a/packages/edge-config/scripts/postinstall.ts +++ b/packages/edge-config/src/cli.ts @@ -1,8 +1,3 @@ -/// - -// This script runs uncombiled with "node --experimental-strip-types", -// so all imports need to use ".ts" - /* * Reads all connected Edge Configs and emits them to the stores folder * that can be accessed at runtime by the mockable-import function. @@ -10,9 +5,12 @@ * Attaches the updatedAt timestamp from the header to the emitted file, since * the endpoint does not currently include it in the response body. */ + import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { Command } from 'commander'; +import { version } from '../package.json'; import type { Connection, EmbeddedEdgeConfig } from '../src/types'; import { parseConnectionString } from '../src/utils/parse-connection-string'; @@ -28,16 +26,11 @@ type StoresJson = Record< } >; -// Write to the stores.json file of the package itself -const getOutputPath = (): string => { - // During development: packages/edge-config/stores.json - // When installed: node_modules/@vercel/edge-config/stores.json - return join(__dirname, '..', 'dist', 'stores.json'); +type PrepareOptions = { + verbose?: boolean; }; -async function main(): Promise { - if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return; - +async function prepare(output: string, options: PrepareOptions): Promise { const connections = Object.values(process.env).reduce( (acc, value) => { if (typeof value !== 'string') return acc; @@ -48,8 +41,6 @@ async function main(): Promise { [], ); - const outputPath = getOutputPath(); - const values = await Promise.all( connections.map(async (connection) => { const res = await fetch(connection.baseUrl, { @@ -73,20 +64,36 @@ async function main(): Promise { }, {}); // Ensure the dist directory exists before writing - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, JSON.stringify(stores)); - // eslint-disable-next-line no-console -- This is a CLI tool - if (Object.keys(stores).length === 0) { - console.error(`@vercel/edge-config: Embedded no stores`); - } else { - console.log( - `@vercel/edge-config: Embedded ${Object.keys(stores).join(', ')}`, - ); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, JSON.stringify(stores)); + if (options.verbose) { + console.log(`@vercel/edge-config prepare`); + console.log(` → created ${output}`); + if (Object.keys(stores).length === 0) { + console.log(` → no edge configs included`); + } else { + console.log(` → included ${Object.keys(stores).join(', ')}`); + } } } -main().catch((error) => { - // eslint-disable-next-line no-console -- This is a CLI tool - console.error('@vercel/edge-config: postinstall failed', error); - process.exit(1); -}); +const program = new Command(); +program + .name('@vercel/edge-config') + .description('Vercel Edge Config CLI') + .version(version); + +program + .command('prepare') + .description('Prepare Edge Config stores.json file for build time embedding') + .argument( + '[string]', + 'Where the output file should be written', + join(__dirname, '..', 'dist', 'stores.json'), + ) + .option('--verbose', 'Enable verbose logging') + .action(async (output, options: PrepareOptions) => { + await prepare(output, options); + }); + +program.parse(); diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 9addb4ffb..212c14ae3 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -97,7 +97,6 @@ export function createCreateClient({ process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; const buildEmbeddedEdgeConfigPromise = (() => { - if (process.env.EDGE_CONFIG_SKIP_BUILD_EMBEDDING === '1') return null; if (!connection || connection.type !== 'vercel') return null; return getBuildEmbeddedEdgeConfig(connection.id, fetchCache).then( (value) => { diff --git a/packages/edge-config/tsup.config.js b/packages/edge-config/tsup.config.js index 176ff05ef..6917409e2 100644 --- a/packages/edge-config/tsup.config.js +++ b/packages/edge-config/tsup.config.js @@ -24,13 +24,9 @@ export default [ dts: true, external: ['node_modules'], }), - // postinstall script + // cli defineConfig({ - entry: ['scripts/postinstall.ts'], - // TODO workaround since the repo itself also runs postinstall, at which - // point the dist folder does not exist yet. So we need to commit the file - // for the repo itself - outDir: 'dist-postinstall', + entry: ['src/cli.ts'], format: 'esm', splitting: true, sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 904ddd46c..9a6deed36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@vercel/edge-config-fs': specifier: workspace:* version: link:../edge-config-fs + commander: + specifier: 14.0.2 + version: 14.0.2 devDependencies: '@changesets/cli': specifier: 2.29.7 @@ -2000,6 +2003,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -5222,8 +5229,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.5': - optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.18': dependencies: @@ -5249,7 +5255,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@manypkg/find-root@1.1.0': dependencies: @@ -5905,6 +5911,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.2: {} + commander@4.1.1: {} concat-map@0.0.1: {} From 83fe996b80fef804c495db598b90a30326f4ceeb Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 28 Nov 2025 20:19:56 +0200 Subject: [PATCH 23/72] comment --- packages/edge-config/src/cli.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index b7ad09578..b4baf3722 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -1,9 +1,12 @@ /* - * Reads all connected Edge Configs and emits them to the stores folder - * that can be accessed at runtime by the mockable-import function. + * Edge Config CLI * - * Attaches the updatedAt timestamp from the header to the emitted file, since - * the endpoint does not currently include it in the response body. + * command: prepare + * Reads all connected Edge Configs and emits a single stores.json file. + * that can be accessed at runtime by the mockable-import function. + * + * Attaches the updatedAt timestamp from the header to the emitted file, since + * the endpoint does not currently include it in the response body. */ import { mkdir, writeFile } from 'node:fs/promises'; From db283f1f98f7cddefe1a9a694dcc56c31cd8ad46 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 07:55:37 +0200 Subject: [PATCH 24/72] =?UTF-8?q?ecfg-1=20=E2=86=92=20ecfg=5F1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/edge-config/jest/setup.js | 2 +- packages/edge-config/src/index.edge.test.ts | 10 ++++----- packages/edge-config/src/index.node.test.ts | 24 ++++++++++----------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/edge-config/jest/setup.js b/packages/edge-config/jest/setup.js index 971565061..065980f20 100644 --- a/packages/edge-config/jest/setup.js +++ b/packages/edge-config/jest/setup.js @@ -1,6 +1,6 @@ require('jest-fetch-mock').enableMocks(); -process.env.EDGE_CONFIG = 'https://edge-config.vercel.com/ecfg-1?token=token-1'; +process.env.EDGE_CONFIG = 'https://edge-config.vercel.com/ecfg_1?token=token-1'; process.env.VERCEL_ENV = 'test'; // Adds a DOMException polyfill diff --git a/packages/edge-config/src/index.edge.test.ts b/packages/edge-config/src/index.edge.test.ts index d23b37d4a..0dbda18d3 100644 --- a/packages/edge-config/src/index.edge.test.ts +++ b/packages/edge-config/src/index.edge.test.ts @@ -4,7 +4,7 @@ import { createClient, digest, get, getAll, has } from './index'; import { cache } from './utils/fetch-with-cached-response'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; -const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; +const baseUrl = 'https://edge-config.vercel.com/ecfg_1'; describe('default Edge Config', () => { beforeEach(() => { @@ -15,7 +15,7 @@ describe('default Edge Config', () => { describe('test conditions', () => { it('should have an env var called EDGE_CONFIG', () => { expect(process.env.EDGE_CONFIG).toEqual( - 'https://edge-config.vercel.com/ecfg-1?token=token-1', + 'https://edge-config.vercel.com/ecfg_1?token=token-1', ); }); }); @@ -102,7 +102,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -241,7 +241,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -375,7 +375,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, diff --git a/packages/edge-config/src/index.node.test.ts b/packages/edge-config/src/index.node.test.ts index 4a3ee1ce5..cbed01088 100644 --- a/packages/edge-config/src/index.node.test.ts +++ b/packages/edge-config/src/index.node.test.ts @@ -6,7 +6,7 @@ import type { EmbeddedEdgeConfig } from './types'; import { cache } from './utils/fetch-with-cached-response'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; -const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; +const baseUrl = 'https://edge-config.vercel.com/ecfg_1'; beforeEach(() => { fetchMock.resetMocks(); @@ -31,7 +31,7 @@ describe('default Edge Config', () => { describe('test conditions', () => { it('should have an env var called EDGE_CONFIG', () => { expect(process.env.EDGE_CONFIG).toEqual( - 'https://edge-config.vercel.com/ecfg-1?token=token-1', + 'https://edge-config.vercel.com/ecfg_1?token=token-1', ); }); }); @@ -118,7 +118,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -257,7 +257,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -391,7 +391,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -524,7 +524,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -537,7 +537,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -577,7 +577,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -590,7 +590,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -604,7 +604,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -622,14 +622,14 @@ describe('createClient', () => { // returns undefined as file does not exist expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); // ensure fetch was called with the right options expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - 'https://edge-config.vercel.com/ecfg-1/item/foo?version=1', + 'https://edge-config.vercel.com/ecfg_1/item/foo?version=1', { cache: 'force-cache', headers: new Headers({ From af035c28d99269ca491b3f0c8230cef7e9c3b7c5 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 07:55:47 +0200 Subject: [PATCH 25/72] quotes --- .../edge-config/src/utils/read-build-embedded-edge-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts index 5c122fc83..4a664dc3b 100644 --- a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts +++ b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts @@ -9,7 +9,7 @@ export async function readBuildEmbeddedEdgeConfig( try { console.log('attempting to read build embedded edge config', id); // @ts-expect-error this file is generated later - return (await import(`@vercel/edge-config/dist/stores.json`).then( + return (await import('@vercel/edge-config/dist/stores.json').then( (module) => module.default[id] ?? null, )) as Promise; } catch (e) { From 3dc59952ac1717f968aee062d21c261bedd8fe86 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 08:25:15 +0200 Subject: [PATCH 26/72] tests --- .../edge-config/src/create-create-client.ts | 8 +- .../index.build-embedded-fallbacks.test.ts | 190 ++++++++++++++++++ packages/edge-config/src/index.common.test.ts | 2 +- 3 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 packages/edge-config/src/index.build-embedded-fallbacks.test.ts diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 212c14ae3..c668572ab 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -154,7 +154,7 @@ export function createCreateClient({ if (localEdgeConfig) return select(localEdgeConfig); - return fetchEdgeConfigItem( + return await fetchEdgeConfigItem( baseUrl, key, version, @@ -212,7 +212,7 @@ export function createCreateClient({ return Promise.resolve(hasOwn(localEdgeConfig.items, key)); } - return fetchEdgeConfigHas( + return await fetchEdgeConfigHas( baseUrl, key, version, @@ -271,7 +271,7 @@ export function createCreateClient({ if (localEdgeConfig) return select(localEdgeConfig); - return fetchAllEdgeConfigItem( + return await fetchAllEdgeConfigItem( baseUrl, keys, version, @@ -327,7 +327,7 @@ export function createCreateClient({ if (localEdgeConfig) return select(localEdgeConfig); - return fetchEdgeConfigTrace( + return await fetchEdgeConfigTrace( baseUrl, version, localOptions?.consistentRead, diff --git a/packages/edge-config/src/index.build-embedded-fallbacks.test.ts b/packages/edge-config/src/index.build-embedded-fallbacks.test.ts new file mode 100644 index 000000000..5bffd9580 --- /dev/null +++ b/packages/edge-config/src/index.build-embedded-fallbacks.test.ts @@ -0,0 +1,190 @@ +import fetchMock from 'jest-fetch-mock'; +import { version as pkgVersion } from '../package.json'; +import { get, getAll, has } from './index'; +import type { EmbeddedEdgeConfig } from './types'; +import { cache } from './utils/fetch-with-cached-response'; + +jest.mock('@vercel/edge-config/dist/stores.json', () => { + return { + ecfg_1: { + updatedAt: 100, + data: { + items: { foo: 'foo-build-embedded', bar: 'bar-build-embedded' }, + digest: 'a', + }, + }, + }; +}); + +const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; +const baseUrl = 'https://edge-config.vercel.com/ecfg_1'; + +beforeEach(() => { + fetchMock.resetMocks(); + cache.clear(); +}); + +// mock fs for test +jest.mock('@vercel/edge-config-fs', () => { + const embeddedEdgeConfig: EmbeddedEdgeConfig = { + digest: 'awe1', + items: { foo: 'bar', someArray: [] }, + }; + + return { + readFile: jest.fn((): Promise => { + return Promise.resolve(JSON.stringify(embeddedEdgeConfig)); + }), + }; +}); + +describe('default Edge Config', () => { + describe('test conditions', () => { + it('should have an env var called EDGE_CONFIG', () => { + expect(process.env.EDGE_CONFIG).toEqual( + 'https://edge-config.vercel.com/ecfg_1?token=token-1', + ); + }); + }); + + describe('get(key)', () => { + describe('when fetch aborts', () => { + it('should fall back to the build embedded config', async () => { + fetchMock.mockAbort(); + await expect(get('foo')).resolves.toEqual('foo-build-embedded'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + + describe('when fetch rejects', () => { + it('should fall back to the build embedded config', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + await expect(get('foo')).resolves.toEqual('foo-build-embedded'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + }); + + describe('getAll(keys)', () => { + describe('when called without keys', () => { + it('should return all items', async () => { + fetchMock.mockAbort(); + + await expect(getAll()).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }); + }); + }); + + describe('when called with keys', () => { + it('should return the selected items', async () => { + fetchMock.mockReject(new Error('mock failed error')); + + await expect(getAll(['foo', 'bar'])).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/items?version=1&key=foo&key=bar`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + }); + + describe('has(key)', () => { + describe('when item exists', () => { + it('should return true', async () => { + fetchMock.mockAbort(); + + await expect(has('foo')).resolves.toEqual(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + + describe('when the item does not exist', () => { + it('should return false', async () => { + fetchMock.mockAbort(); + + await expect(has('foo-does-not-exist')).resolves.toEqual(false); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo-does-not-exist?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + }); +}); diff --git a/packages/edge-config/src/index.common.test.ts b/packages/edge-config/src/index.common.test.ts index 71ac8e93b..9e4604db3 100644 --- a/packages/edge-config/src/index.common.test.ts +++ b/packages/edge-config/src/index.common.test.ts @@ -14,7 +14,7 @@ const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; describe('test conditions', () => { it('should have an env var called EDGE_CONFIG', () => { expect(process.env.EDGE_CONFIG).toEqual( - 'https://edge-config.vercel.com/ecfg-1?token=token-1', + 'https://edge-config.vercel.com/ecfg_1?token=token-1', ); }); }); From e3d4ec1ac522e97d731943dfad18401afae6637f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 12:06:21 +0200 Subject: [PATCH 27/72] with type json --- .../edge-config/src/create-create-client.ts | 48 ++++++++++++------- .../utils/read-build-embedded-edge-config.ts | 7 +-- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index c668572ab..655b4c89c 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -96,15 +96,32 @@ export function createCreateClient({ process.env.NODE_ENV === 'development' && process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; - const buildEmbeddedEdgeConfigPromise = (() => { + /** + * The edge config embedded at build time + */ + let buildEmbeddedEdgeConfigPromise: Promise<{ + data: EmbeddedEdgeConfig; + updatedAt: number | undefined; + } | null> | null; + /** + * Function to load the edge config embedded at build time or null. + */ + async function getEmbeddedEdgeConfigPromise() { if (!connection || connection.type !== 'vercel') return null; - return getBuildEmbeddedEdgeConfig(connection.id, fetchCache).then( - (value) => { - console.log('@vercel/edge-config: preloaded', connection.id, value); - return value; - }, + + if (buildEmbeddedEdgeConfigPromise) + return await buildEmbeddedEdgeConfigPromise; + + const { id } = connection; + buildEmbeddedEdgeConfigPromise = getBuildEmbeddedEdgeConfig( + id, + fetchCache, ); - })(); + + const value = await buildEmbeddedEdgeConfigPromise; + console.log('@vercel/edge-config: preloaded', id, value); + return value; + } const isBuildStep = process.env.CI === '1' || @@ -118,8 +135,7 @@ export function createCreateClient({ ): Promise { assertIsKey(key); - const buildEmbeddedEdgeConfig = - await buildEmbeddedEdgeConfigPromise; + const embeddedEdgeConfig = await getEmbeddedEdgeConfigPromise(); function select(edgeConfig: EmbeddedEdgeConfig) { if (isEmptyKey(key)) return undefined; @@ -130,8 +146,8 @@ export function createCreateClient({ return Promise.resolve(edgeConfig.items[key] as T); } - if (buildEmbeddedEdgeConfig && isBuildStep) { - return select(buildEmbeddedEdgeConfig.data); + if (embeddedEdgeConfig && isBuildStep) { + return select(embeddedEdgeConfig.data); } try { @@ -163,9 +179,9 @@ export function createCreateClient({ fetchCache, ); } catch (error) { - if (!buildEmbeddedEdgeConfig) throw error; + if (!embeddedEdgeConfig) throw error; console.warn(FALLBACK_WARNING); - return select(buildEmbeddedEdgeConfig.data); + return select(embeddedEdgeConfig.data); } }, { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, @@ -179,7 +195,7 @@ export function createCreateClient({ if (isEmptyKey(key)) return false; const buildEmbeddedEdgeConfig = - await buildEmbeddedEdgeConfigPromise; + await getEmbeddedEdgeConfigPromise(); function select(edgeConfig: EmbeddedEdgeConfig) { return Promise.resolve(hasOwn(edgeConfig.items, key)); @@ -238,7 +254,7 @@ export function createCreateClient({ } const buildEmbeddedEdgeConfig = - await buildEmbeddedEdgeConfigPromise; + await getEmbeddedEdgeConfigPromise(); function select(edgeConfig: EmbeddedEdgeConfig) { return keys === undefined @@ -296,7 +312,7 @@ export function createCreateClient({ localOptions?: EdgeConfigFunctionsOptions, ): Promise { const buildEmbeddedEdgeConfig = - await buildEmbeddedEdgeConfigPromise; + await getEmbeddedEdgeConfigPromise(); function select(embeddedEdgeConfig: EmbeddedEdgeConfig) { return embeddedEdgeConfig.digest; diff --git a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts index 4a664dc3b..2931d23cb 100644 --- a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts +++ b/packages/edge-config/src/utils/read-build-embedded-edge-config.ts @@ -9,9 +9,10 @@ export async function readBuildEmbeddedEdgeConfig( try { console.log('attempting to read build embedded edge config', id); // @ts-expect-error this file is generated later - return (await import('@vercel/edge-config/dist/stores.json').then( - (module) => module.default[id] ?? null, - )) as Promise; + const mod = await import('@vercel/edge-config/dist/stores.json', { + with: { type: 'json' }, + }); + return (mod.default[id] as M | undefined) ?? null; } catch (e) { if ( typeof e === 'object' && From 6a2720ed3ac242d35ec9eb672f0c1b39193b0fd8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 14:08:14 +0200 Subject: [PATCH 28/72] adjust --- packages/edge-config/src/index.build-embedded-fallbacks.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/edge-config/src/index.build-embedded-fallbacks.test.ts b/packages/edge-config/src/index.build-embedded-fallbacks.test.ts index 5bffd9580..878a41d35 100644 --- a/packages/edge-config/src/index.build-embedded-fallbacks.test.ts +++ b/packages/edge-config/src/index.build-embedded-fallbacks.test.ts @@ -167,9 +167,7 @@ describe('default Edge Config', () => { describe('when the item does not exist', () => { it('should return false', async () => { fetchMock.mockAbort(); - await expect(has('foo-does-not-exist')).resolves.toEqual(false); - expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( `${baseUrl}/item/foo-does-not-exist?version=1`, From fa22248d95a097657d71ad1e7b8f7b851b11989f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 14:22:21 +0200 Subject: [PATCH 29/72] rename --- .../edge-config/src/create-create-client.ts | 70 ++++++++----------- packages/edge-config/src/edge-config.ts | 2 +- packages/edge-config/src/index.next-js.ts | 10 +-- packages/edge-config/src/index.ts | 4 +- 4 files changed, 37 insertions(+), 49 deletions(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 655b4c89c..8a84a71ca 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -25,7 +25,7 @@ type CreateClient = ( const FALLBACK_WARNING = '@vercel/edge-config: Falling back to build embed'; export function createCreateClient({ - getBuildEmbeddedEdgeConfig, + getBundledEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, fetchEdgeConfigItem, @@ -33,7 +33,7 @@ export function createCreateClient({ fetchAllEdgeConfigItem, fetchEdgeConfigTrace, }: { - getBuildEmbeddedEdgeConfig: typeof deps.getBuildEmbeddedEdgeConfig; + getBundledEdgeConfig: typeof deps.getBundledEdgeConfig; getInMemoryEdgeConfig: typeof deps.getInMemoryEdgeConfig; getLocalEdgeConfig: typeof deps.getLocalEdgeConfig; fetchEdgeConfigItem: typeof deps.fetchEdgeConfigItem; @@ -97,30 +97,21 @@ export function createCreateClient({ process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; /** - * The edge config embedded at build time + * The edge config bundled at build time */ - let buildEmbeddedEdgeConfigPromise: Promise<{ + let bundledEdgeConfigPromise: Promise<{ data: EmbeddedEdgeConfig; updatedAt: number | undefined; } | null> | null; /** - * Function to load the edge config embedded at build time or null. + * Function to load the bundled edge config or null. */ - async function getEmbeddedEdgeConfigPromise() { + async function getBundledEdgeConfigPromise() { if (!connection || connection.type !== 'vercel') return null; - - if (buildEmbeddedEdgeConfigPromise) - return await buildEmbeddedEdgeConfigPromise; - + if (bundledEdgeConfigPromise) return await bundledEdgeConfigPromise; const { id } = connection; - buildEmbeddedEdgeConfigPromise = getBuildEmbeddedEdgeConfig( - id, - fetchCache, - ); - - const value = await buildEmbeddedEdgeConfigPromise; - console.log('@vercel/edge-config: preloaded', id, value); - return value; + bundledEdgeConfigPromise = getBundledEdgeConfig(id, fetchCache); + return await bundledEdgeConfigPromise; } const isBuildStep = @@ -135,7 +126,7 @@ export function createCreateClient({ ): Promise { assertIsKey(key); - const embeddedEdgeConfig = await getEmbeddedEdgeConfigPromise(); + const bundledEdgeConfig = await getBundledEdgeConfigPromise(); function select(edgeConfig: EmbeddedEdgeConfig) { if (isEmptyKey(key)) return undefined; @@ -146,8 +137,8 @@ export function createCreateClient({ return Promise.resolve(edgeConfig.items[key] as T); } - if (embeddedEdgeConfig && isBuildStep) { - return select(embeddedEdgeConfig.data); + if (bundledEdgeConfig && isBuildStep) { + return select(bundledEdgeConfig.data); } try { @@ -179,9 +170,9 @@ export function createCreateClient({ fetchCache, ); } catch (error) { - if (!embeddedEdgeConfig) throw error; + if (!bundledEdgeConfig) throw error; console.warn(FALLBACK_WARNING); - return select(embeddedEdgeConfig.data); + return select(bundledEdgeConfig.data); } }, { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, @@ -194,15 +185,14 @@ export function createCreateClient({ assertIsKey(key); if (isEmptyKey(key)) return false; - const buildEmbeddedEdgeConfig = - await getEmbeddedEdgeConfigPromise(); + const bundledEdgeConfig = await getBundledEdgeConfigPromise(); function select(edgeConfig: EmbeddedEdgeConfig) { return Promise.resolve(hasOwn(edgeConfig.items, key)); } - if (buildEmbeddedEdgeConfig && isBuildStep) { - return select(buildEmbeddedEdgeConfig.data); + if (BundledEdgeConfig && isBuildStep) { + return select(BundledEdgeConfig.data); } try { @@ -237,9 +227,9 @@ export function createCreateClient({ fetchCache, ); } catch (error) { - if (!buildEmbeddedEdgeConfig) throw error; + if (!BundledEdgeConfig) throw error; console.warn(FALLBACK_WARNING); - return select(buildEmbeddedEdgeConfig.data); + return select(BundledEdgeConfig.data); } }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, @@ -253,8 +243,7 @@ export function createCreateClient({ assertIsKeys(keys); } - const buildEmbeddedEdgeConfig = - await getEmbeddedEdgeConfigPromise(); + const bundledEdgeConfig = await getBundledEdgeConfigPromise(); function select(edgeConfig: EmbeddedEdgeConfig) { return keys === undefined @@ -262,8 +251,8 @@ export function createCreateClient({ : Promise.resolve(pick(edgeConfig.items as T, keys) as T); } - if (buildEmbeddedEdgeConfig && isBuildStep) { - return select(buildEmbeddedEdgeConfig.data); + if (BundledEdgeConfig && isBuildStep) { + return select(BundledEdgeConfig.data); } try { @@ -296,9 +285,9 @@ export function createCreateClient({ fetchCache, ); } catch (error) { - if (!buildEmbeddedEdgeConfig) throw error; + if (!BundledEdgeConfig) throw error; console.warn(FALLBACK_WARNING); - return select(buildEmbeddedEdgeConfig.data); + return select(BundledEdgeConfig.data); } }, { @@ -311,15 +300,14 @@ export function createCreateClient({ async function digest( localOptions?: EdgeConfigFunctionsOptions, ): Promise { - const buildEmbeddedEdgeConfig = - await getEmbeddedEdgeConfigPromise(); + const bundledEdgeConfig = await getBundledEdgeConfigPromise(); function select(embeddedEdgeConfig: EmbeddedEdgeConfig) { return embeddedEdgeConfig.digest; } - if (buildEmbeddedEdgeConfig && isBuildStep) { - return select(buildEmbeddedEdgeConfig.data); + if (BundledEdgeConfig && isBuildStep) { + return select(BundledEdgeConfig.data); } try { @@ -351,9 +339,9 @@ export function createCreateClient({ fetchCache, ); } catch (error) { - if (!buildEmbeddedEdgeConfig) throw error; + if (!BundledEdgeConfig) throw error; console.warn(FALLBACK_WARNING); - return select(buildEmbeddedEdgeConfig.data); + return select(BundledEdgeConfig.data); } }, { diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index 75f09f418..f7d628e39 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -106,7 +106,7 @@ const getPrivateEdgeConfig = trace( }, ); -export async function getBuildEmbeddedEdgeConfig( +export async function getBundledEdgeConfig( connectionId: Connection['id'], _fetchCache: EdgeConfigClientOptions['cache'], ): Promise<{ diff --git a/packages/edge-config/src/index.next-js.ts b/packages/edge-config/src/index.next-js.ts index f0f4055a8..c6ea84b5f 100644 --- a/packages/edge-config/src/index.next-js.ts +++ b/packages/edge-config/src/index.next-js.ts @@ -5,7 +5,7 @@ import { fetchEdgeConfigHas, fetchEdgeConfigItem, fetchEdgeConfigTrace, - getBuildEmbeddedEdgeConfig, + getBundledEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, } from './edge-config'; @@ -50,14 +50,14 @@ function setCacheLifeFromFetchCache( } async function getBuildEmbeddedEdgeConfigForNext( - ...args: Parameters -): ReturnType { + ...args: Parameters +): ReturnType { 'use cache'; const [id, fetchCache] = args; setCacheLifeFromFetchCache(fetchCache); - return getBuildEmbeddedEdgeConfig(id, fetchCache); + return getBundledEdgeConfig(id, fetchCache); } async function getInMemoryEdgeConfigForNext( @@ -137,7 +137,7 @@ async function fetchEdgeConfigTraceForNext( * @returns An Edge Config Client instance */ export const createClient = createCreateClient({ - getBuildEmbeddedEdgeConfig: getBuildEmbeddedEdgeConfigForNext, + getBundledEdgeConfig: getBuildEmbeddedEdgeConfigForNext, getInMemoryEdgeConfig: getInMemoryEdgeConfigForNext, getLocalEdgeConfig: getLocalEdgeConfigForNext, fetchEdgeConfigItem: fetchEdgeConfigItemForNext, diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index cca9affb1..acaa624cc 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -4,7 +4,7 @@ import { fetchEdgeConfigHas, fetchEdgeConfigItem, fetchEdgeConfigTrace, - getBuildEmbeddedEdgeConfig, + getBundledEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, } from './edge-config'; @@ -37,7 +37,7 @@ export { * @returns An Edge Config Client instance */ export const createClient = createCreateClient({ - getBuildEmbeddedEdgeConfig, + getBundledEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, fetchEdgeConfigItem, From 97f33a196ed42194beba4653f3cc5959585c4c26 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 14:30:40 +0200 Subject: [PATCH 30/72] complete renaming --- .../edge-config/src/create-create-client.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 8a84a71ca..2e2d8636c 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -191,8 +191,8 @@ export function createCreateClient({ return Promise.resolve(hasOwn(edgeConfig.items, key)); } - if (BundledEdgeConfig && isBuildStep) { - return select(BundledEdgeConfig.data); + if (bundledEdgeConfig && isBuildStep) { + return select(bundledEdgeConfig.data); } try { @@ -227,9 +227,9 @@ export function createCreateClient({ fetchCache, ); } catch (error) { - if (!BundledEdgeConfig) throw error; + if (!bundledEdgeConfig) throw error; console.warn(FALLBACK_WARNING); - return select(BundledEdgeConfig.data); + return select(bundledEdgeConfig.data); } }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, @@ -251,8 +251,8 @@ export function createCreateClient({ : Promise.resolve(pick(edgeConfig.items as T, keys) as T); } - if (BundledEdgeConfig && isBuildStep) { - return select(BundledEdgeConfig.data); + if (bundledEdgeConfig && isBuildStep) { + return select(bundledEdgeConfig.data); } try { @@ -285,9 +285,9 @@ export function createCreateClient({ fetchCache, ); } catch (error) { - if (!BundledEdgeConfig) throw error; + if (!bundledEdgeConfig) throw error; console.warn(FALLBACK_WARNING); - return select(BundledEdgeConfig.data); + return select(bundledEdgeConfig.data); } }, { @@ -306,8 +306,8 @@ export function createCreateClient({ return embeddedEdgeConfig.digest; } - if (BundledEdgeConfig && isBuildStep) { - return select(BundledEdgeConfig.data); + if (bundledEdgeConfig && isBuildStep) { + return select(bundledEdgeConfig.data); } try { @@ -339,9 +339,9 @@ export function createCreateClient({ fetchCache, ); } catch (error) { - if (!BundledEdgeConfig) throw error; + if (!bundledEdgeConfig) throw error; console.warn(FALLBACK_WARNING); - return select(BundledEdgeConfig.data); + return select(bundledEdgeConfig.data); } }, { From 1e09f64418be55f8659d73594a1de28d5604315a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 14:46:30 +0200 Subject: [PATCH 31/72] support timeoutMs to prevent long-running fetches You can now set a timeoutMs which specifies a maximum time the SDK waits for a fetch before either throwing or resolving with the bundled Edge Config value --- packages/edge-config/src/create-create-client.ts | 6 ++++++ packages/edge-config/src/edge-config.ts | 15 +++++++++++++++ packages/edge-config/src/types.ts | 6 ++++++ 3 files changed, 27 insertions(+) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 2e2d8636c..aaa829d53 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -86,6 +86,8 @@ export function createCreateClient({ headers['cache-control'] = `stale-if-error=${options.staleIfError}`; const fetchCache = options.cache || 'no-store'; + const timeoutMs = + typeof options.timeoutMs === 'number' ? options.timeoutMs : undefined; /** * While in development we use SWR-like behavior for the api client to @@ -168,6 +170,7 @@ export function createCreateClient({ localOptions?.consistentRead, headers, fetchCache, + localOptions?.timeoutMs ?? timeoutMs, ); } catch (error) { if (!bundledEdgeConfig) throw error; @@ -225,6 +228,7 @@ export function createCreateClient({ localOptions?.consistentRead, headers, fetchCache, + localOptions?.timeoutMs ?? timeoutMs, ); } catch (error) { if (!bundledEdgeConfig) throw error; @@ -283,6 +287,7 @@ export function createCreateClient({ localOptions?.consistentRead, headers, fetchCache, + localOptions?.timeoutMs ?? timeoutMs, ); } catch (error) { if (!bundledEdgeConfig) throw error; @@ -337,6 +342,7 @@ export function createCreateClient({ localOptions?.consistentRead, headers, fetchCache, + localOptions?.timeoutMs ?? timeoutMs, ); } catch (error) { if (!bundledEdgeConfig) throw error; diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index f7d628e39..7dc3dd7b9 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -271,6 +271,7 @@ export async function fetchEdgeConfigItem( consistentRead: undefined | boolean, localHeaders: HeadersRecord, fetchCache: EdgeConfigClientOptions['cache'], + timeoutMs: number | undefined, ): Promise { if (isEmptyKey(key)) return undefined; @@ -281,6 +282,7 @@ export async function fetchEdgeConfigItem( return fetchWithCachedResponse(`${baseUrl}/item/${key}?version=${version}`, { headers, cache: fetchCache, + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }).then(async (res) => { if (res.ok) return res.json(); await consumeResponseBody(res); @@ -310,6 +312,7 @@ export async function fetchEdgeConfigHas( consistentRead: undefined | boolean, localHeaders: HeadersRecord, fetchCache: EdgeConfigClientOptions['cache'], + timeoutMs: undefined | number, ): Promise { const headers = new Headers(localHeaders); if (consistentRead) { @@ -320,6 +323,7 @@ export async function fetchEdgeConfigHas( method: 'HEAD', headers, cache: fetchCache, + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }).then((res) => { if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); if (res.status === 404) { @@ -345,6 +349,7 @@ export async function fetchAllEdgeConfigItem( consistentRead: undefined | boolean, localHeaders: HeadersRecord, fetchCache: EdgeConfigClientOptions['cache'], + timeoutMs: undefined | number, ): Promise { let url = `${baseUrl}/items?version=${version}`; if (keys) { @@ -368,6 +373,7 @@ export async function fetchAllEdgeConfigItem( return fetchWithCachedResponse(url, { headers, cache: fetchCache, + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }).then(async (res) => { if (res.ok) return res.json(); await consumeResponseBody(res); @@ -391,6 +397,7 @@ export async function fetchEdgeConfigTrace( consistentRead: undefined | boolean, localHeaders: HeadersRecord, fetchCache: EdgeConfigClientOptions['cache'], + timeoutMs: number | undefined, ): Promise { const headers = new Headers(localHeaders); if (consistentRead) { @@ -400,6 +407,7 @@ export async function fetchEdgeConfigTrace( return fetchWithCachedResponse(`${baseUrl}/digest?version=${version}`, { headers, cache: fetchCache, + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }).then(async (res) => { if (res.ok) return res.json() as Promise; await consumeResponseBody(res); @@ -470,4 +478,11 @@ export interface EdgeConfigClientOptions { * Unlike Next.js, this defaults to `no-store`, as you most likely want to use Edge Config dynamically. */ cache?: 'no-store' | 'force-cache'; + + /** + * How long to wait for a fresh value before falling back to a stale value or throwing. + * + * It is recommended to only use this in combination with a bundled Edge Config (see "edge-config prepare" script). + */ + timeoutMs?: number; } diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index d6ce01c1f..d52d05ca8 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -91,4 +91,10 @@ export interface EdgeConfigFunctionsOptions { * need to ensure you generate with the latest content. */ consistentRead?: boolean; + + /** + * How long to wait for the Edge Config to be fetched before timing out + * and falling back to the bundled Edge Config value if present, or throwing. + */ + timeoutMs?: number; } From 18f2c6df9b6ac6e5daea5a0886dca0237a9f899c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 14:48:51 +0200 Subject: [PATCH 32/72] renaming --- packages/edge-config/src/edge-config.ts | 4 ++-- ...mbedded-edge-config.ts => read-bundled-edge-config.ts} | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) rename packages/edge-config/src/utils/{read-build-embedded-edge-config.ts => read-bundled-edge-config.ts} (72%) diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index 7dc3dd7b9..38ddc88d5 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -13,7 +13,7 @@ import { UnexpectedNetworkError, } from './utils'; import { fetchWithCachedResponse } from './utils/fetch-with-cached-response'; -import { readBuildEmbeddedEdgeConfig } from './utils/read-build-embedded-edge-config'; +import { readBundledEdgeConfig } from './utils/read-bundled-edge-config'; import { trace } from './utils/tracing'; const X_EDGE_CONFIG_SDK_HEADER = @@ -113,7 +113,7 @@ export async function getBundledEdgeConfig( data: EmbeddedEdgeConfig; updatedAt: number | undefined; } | null> { - return readBuildEmbeddedEdgeConfig<{ + return readBundledEdgeConfig<{ data: EmbeddedEdgeConfig; updatedAt: number | undefined; }>(connectionId); diff --git a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts b/packages/edge-config/src/utils/read-bundled-edge-config.ts similarity index 72% rename from packages/edge-config/src/utils/read-build-embedded-edge-config.ts rename to packages/edge-config/src/utils/read-bundled-edge-config.ts index 2931d23cb..94f88f009 100644 --- a/packages/edge-config/src/utils/read-build-embedded-edge-config.ts +++ b/packages/edge-config/src/utils/read-bundled-edge-config.ts @@ -1,11 +1,7 @@ /** - * Reads the local edge config that gets embedded at build time. - * - * We currently use webpackIgnore to avoid bundling the local edge config. + * Reads the local edge config that gets bundled at build time (stores.json). */ -export async function readBuildEmbeddedEdgeConfig( - id: string, -): Promise { +export async function readBundledEdgeConfig(id: string): Promise { try { console.log('attempting to read build embedded edge config', id); // @ts-expect-error this file is generated later From f234fb7c4404b02ec0b6a08c5ebaee6e66bc2bfc Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 14:49:52 +0200 Subject: [PATCH 33/72] rename test --- ...x.build-embedded-fallbacks.test.ts => index.bundled.test.ts} | 2 ++ 1 file changed, 2 insertions(+) rename packages/edge-config/src/{index.build-embedded-fallbacks.test.ts => index.bundled.test.ts} (99%) diff --git a/packages/edge-config/src/index.build-embedded-fallbacks.test.ts b/packages/edge-config/src/index.bundled.test.ts similarity index 99% rename from packages/edge-config/src/index.build-embedded-fallbacks.test.ts rename to packages/edge-config/src/index.bundled.test.ts index 878a41d35..d094ffc5a 100644 --- a/packages/edge-config/src/index.build-embedded-fallbacks.test.ts +++ b/packages/edge-config/src/index.bundled.test.ts @@ -1,3 +1,5 @@ +// Tests the bundled Edge Config (stores.json) behavior + import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import { get, getAll, has } from './index'; From 674885077f0175d98dddb407ddaca20e88713fce Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 15:46:04 +0200 Subject: [PATCH 34/72] tests for timeoutMs --- .../edge-config/src/create-create-client.ts | 6 + packages/edge-config/src/edge-config.ts | 41 +- .../edge-config/src/index.bundled.test.ts | 365 +++++++++++++++--- 3 files changed, 342 insertions(+), 70 deletions(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index aaa829d53..be1d6bf8c 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -191,6 +191,7 @@ export function createCreateClient({ const bundledEdgeConfig = await getBundledEdgeConfigPromise(); function select(edgeConfig: EmbeddedEdgeConfig) { + console.log('select', key, edgeConfig.items); return Promise.resolve(hasOwn(edgeConfig.items, key)); } @@ -221,6 +222,10 @@ export function createCreateClient({ return Promise.resolve(hasOwn(localEdgeConfig.items, key)); } + console.log( + 'RESORTING TO FETCH', + localOptions?.timeoutMs ?? timeoutMs, + ); return await fetchEdgeConfigHas( baseUrl, key, @@ -231,6 +236,7 @@ export function createCreateClient({ localOptions?.timeoutMs ?? timeoutMs, ); } catch (error) { + console.log('IN FALLBACK'); if (!bundledEdgeConfig) throw error; console.warn(FALLBACK_WARNING); return select(bundledEdgeConfig.data); diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index 38ddc88d5..263fc6d31 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -318,25 +318,38 @@ export async function fetchEdgeConfigHas( if (consistentRead) { addConsistentReadHeader(headers); } + + const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined; + + signal?.addEventListener('abort', () => { + console.log('Timeout occurred'); + }); + // this is a HEAD request anyhow, no need for fetchWithCachedResponse return fetch(`${baseUrl}/item/${key}?version=${version}`, { method: 'HEAD', headers, cache: fetchCache, - signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, - }).then((res) => { - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - if (res.status === 404) { - // if the x-edge-config-digest header is present, it means - // the edge config exists, but the item does not - if (res.headers.has('x-edge-config-digest')) return false; - // if the x-edge-config-digest header is not present, it means - // the edge config itself does not exist - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - if (res.ok) return true; - throw new UnexpectedNetworkError(res); - }); + signal, + }).then( + (res) => { + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + if (res.status === 404) { + // if the x-edge-config-digest header is present, it means + // the edge config exists, but the item does not + if (res.headers.has('x-edge-config-digest')) return false; + // if the x-edge-config-digest header is not present, it means + // the edge config itself does not exist + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + if (res.ok) return true; + throw new UnexpectedNetworkError(res); + }, + (err) => { + console.log('CAAUUUGHT'); + throw err; + }, + ); } /** diff --git a/packages/edge-config/src/index.bundled.test.ts b/packages/edge-config/src/index.bundled.test.ts index d094ffc5a..3fb3aa082 100644 --- a/packages/edge-config/src/index.bundled.test.ts +++ b/packages/edge-config/src/index.bundled.test.ts @@ -21,6 +21,10 @@ jest.mock('@vercel/edge-config/dist/stores.json', () => { const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; const baseUrl = 'https://edge-config.vercel.com/ecfg_1'; +function delay(data: T, timeoutMs: number): Promise { + return new Promise((resolve) => setTimeout(() => resolve(data), timeoutMs)); +} + beforeEach(() => { fetchMock.resetMocks(); cache.clear(); @@ -91,43 +95,20 @@ describe('default Edge Config', () => { ); }); }); - }); - - describe('getAll(keys)', () => { - describe('when called without keys', () => { - it('should return all items', async () => { - fetchMock.mockAbort(); - - await expect(getAll()).resolves.toEqual({ - foo: 'foo-build-embedded', - bar: 'bar-build-embedded', - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { - headers: new Headers({ - Authorization: 'Bearer token-1', - 'x-edge-config-vercel-env': 'test', - 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, - 'cache-control': 'stale-if-error=604800', - }), - cache: 'no-store', - }); - }); - }); - describe('when called with keys', () => { - it('should return the selected items', async () => { - fetchMock.mockReject(new Error('mock failed error')); - - await expect(getAll(['foo', 'bar'])).resolves.toEqual({ - foo: 'foo-build-embedded', - bar: 'bar-build-embedded', - }); + describe('when fetch times out', () => { + it('should fall back to the build embedded config', async () => { + const timeoutMs = 50; + fetchMock.mockResponseOnce(() => + delay(JSON.stringify('fetched-value'), timeoutMs * 4), + ); + await expect(get('foo', { timeoutMs })).resolves.toEqual( + 'foo-build-embedded', + ); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - `${baseUrl}/items?version=1&key=foo&key=bar`, + `${baseUrl}/item/foo?version=1`, { headers: new Headers({ Authorization: 'Bearer token-1', @@ -136,24 +117,26 @@ describe('default Edge Config', () => { 'cache-control': 'stale-if-error=604800', }), cache: 'no-store', + signal: expect.any(AbortSignal), }, ); }); }); }); - describe('has(key)', () => { - describe('when item exists', () => { - it('should return true', async () => { - fetchMock.mockAbort(); + describe('getAll(keys)', () => { + describe('when fetch aborts', () => { + describe('when called without keys', () => { + it('should return all items', async () => { + fetchMock.mockAbort(); - await expect(has('foo')).resolves.toEqual(true); + await expect(getAll()).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - `${baseUrl}/item/foo?version=1`, - { - method: 'HEAD', + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { headers: new Headers({ Authorization: 'Bearer token-1', 'x-edge-config-vercel-env': 'test', @@ -161,20 +144,47 @@ describe('default Edge Config', () => { 'cache-control': 'stale-if-error=604800', }), cache: 'no-store', - }, - ); + }); + }); + }); + describe('when called with keys', () => { + it('should return the selected items', async () => { + fetchMock.mockAbort(); + + await expect(getAll(['foo', 'bar'])).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/items?version=1&key=foo&key=bar`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); }); }); - describe('when the item does not exist', () => { - it('should return false', async () => { - fetchMock.mockAbort(); - await expect(has('foo-does-not-exist')).resolves.toEqual(false); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - `${baseUrl}/item/foo-does-not-exist?version=1`, - { - method: 'HEAD', + describe('when fetch rejects', () => { + describe('when called without keys', () => { + it('should return all items', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + + await expect(getAll()).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { headers: new Headers({ Authorization: 'Bearer token-1', 'x-edge-config-vercel-env': 'test', @@ -182,8 +192,251 @@ describe('default Edge Config', () => { 'cache-control': 'stale-if-error=604800', }), cache: 'no-store', - }, - ); + }); + }); + }); + describe('when called with keys', () => { + it('should return the selected items', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + + await expect(getAll(['foo', 'bar'])).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/items?version=1&key=foo&key=bar`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + }); + + describe('when fetch times out', () => { + const timeoutMs = 50; + describe('when called without keys', () => { + it('should return all items', async () => { + fetchMock.mockResponseOnce(() => + delay( + JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), + timeoutMs * 4, + ), + ); + + await expect(getAll(undefined, { timeoutMs })).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + signal: expect.any(AbortSignal), + }); + }); + }); + describe('when called with keys', () => { + it('should return the selected items', async () => { + fetchMock.mockResponseOnce(() => + delay( + JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), + timeoutMs * 4, + ), + ); + + await expect(getAll(['foo', 'bar'], { timeoutMs })).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/items?version=1&key=foo&key=bar`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + signal: expect.any(AbortSignal), + }, + ); + }); + }); + }); + }); + + describe('has(key)', () => { + describe('when fetch aborts', () => { + describe('when item exists', () => { + it('should return true', async () => { + fetchMock.mockAbort(); + + await expect(has('foo')).resolves.toEqual(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + + describe('when the item does not exist', () => { + it('should return false', async () => { + fetchMock.mockAbort(); + await expect(has('foo-does-not-exist')).resolves.toEqual(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo-does-not-exist?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + }); + + describe('when fetch rejects', () => { + describe('when item exists', () => { + it('should return true', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + + await expect(has('foo')).resolves.toEqual(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + + describe('when the item does not exist', () => { + it('should return false', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + await expect(has('foo-does-not-exist')).resolves.toEqual(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo-does-not-exist?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); + }); + + describe('when fetch times out', () => { + const timeoutMs = 50; + describe('when item exists', () => { + it('should return true', async () => { + fetchMock.mockResponseOnce(() => + delay( + { + status: 404, + headers: { 'x-edge-config-digest': '1' }, + }, + timeoutMs * 4, + ), + ); + + await expect(has('foo', { timeoutMs })).resolves.toEqual(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + signal: expect.any(AbortSignal), + }, + ); + }); + }); + + describe('when the item does not exist', () => { + it('should return false', async () => { + fetchMock.mockResponseOnce(() => + delay( + JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), + timeoutMs * 4, + ), + ); + await expect( + has('foo-does-not-exist', { timeoutMs }), + ).resolves.toEqual(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo-does-not-exist?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + signal: expect.any(AbortSignal), + }, + ); + }); }); }); }); From 5a0e899e6f8bd952d9ccb7cdd210ffb0408d0f6e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 15:47:44 +0200 Subject: [PATCH 35/72] rm cli option --- packages/edge-config/src/cli.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index b4baf3722..ed0980696 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -89,13 +89,9 @@ program program .command('prepare') .description('Prepare Edge Config stores.json file for build time embedding') - .argument( - '[string]', - 'Where the output file should be written', - join(__dirname, '..', 'dist', 'stores.json'), - ) .option('--verbose', 'Enable verbose logging') - .action(async (output, options: PrepareOptions) => { + .action(async (options: PrepareOptions) => { + const output = join(__dirname, '..', 'dist', 'stores.json'); await prepare(output, options); }); From 2e15100d525823f566976f6e3914dfb7f662f999 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 17:38:40 +0200 Subject: [PATCH 36/72] add shebang --- packages/edge-config/src/cli.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index ed0980696..c27df7047 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node /* * Edge Config CLI * From b3c83f364540d351f5f895812bc203c7e14f20ce Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 17:46:49 +0200 Subject: [PATCH 37/72] clean up --- packages/edge-config/public/stores.json | 1 + .../edge-config/src/create-create-client.ts | 6 --- packages/edge-config/src/edge-config.ts | 40 +++++++------------ packages/edge-config/src/stores.json | 1 + .../src/utils/read-bundled-edge-config.ts | 22 ++++------ packages/edge-config/tsup.config.js | 2 + 6 files changed, 25 insertions(+), 47 deletions(-) create mode 100644 packages/edge-config/public/stores.json create mode 100644 packages/edge-config/src/stores.json diff --git a/packages/edge-config/public/stores.json b/packages/edge-config/public/stores.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/edge-config/public/stores.json @@ -0,0 +1 @@ +{} diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index be1d6bf8c..aaa829d53 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -191,7 +191,6 @@ export function createCreateClient({ const bundledEdgeConfig = await getBundledEdgeConfigPromise(); function select(edgeConfig: EmbeddedEdgeConfig) { - console.log('select', key, edgeConfig.items); return Promise.resolve(hasOwn(edgeConfig.items, key)); } @@ -222,10 +221,6 @@ export function createCreateClient({ return Promise.resolve(hasOwn(localEdgeConfig.items, key)); } - console.log( - 'RESORTING TO FETCH', - localOptions?.timeoutMs ?? timeoutMs, - ); return await fetchEdgeConfigHas( baseUrl, key, @@ -236,7 +231,6 @@ export function createCreateClient({ localOptions?.timeoutMs ?? timeoutMs, ); } catch (error) { - console.log('IN FALLBACK'); if (!bundledEdgeConfig) throw error; console.warn(FALLBACK_WARNING); return select(bundledEdgeConfig.data); diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index 263fc6d31..a54361f5e 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -319,37 +319,25 @@ export async function fetchEdgeConfigHas( addConsistentReadHeader(headers); } - const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined; - - signal?.addEventListener('abort', () => { - console.log('Timeout occurred'); - }); - // this is a HEAD request anyhow, no need for fetchWithCachedResponse return fetch(`${baseUrl}/item/${key}?version=${version}`, { method: 'HEAD', headers, cache: fetchCache, - signal, - }).then( - (res) => { - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - if (res.status === 404) { - // if the x-edge-config-digest header is present, it means - // the edge config exists, but the item does not - if (res.headers.has('x-edge-config-digest')) return false; - // if the x-edge-config-digest header is not present, it means - // the edge config itself does not exist - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - if (res.ok) return true; - throw new UnexpectedNetworkError(res); - }, - (err) => { - console.log('CAAUUUGHT'); - throw err; - }, - ); + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, + }).then((res) => { + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + if (res.status === 404) { + // if the x-edge-config-digest header is present, it means + // the edge config exists, but the item does not + if (res.headers.has('x-edge-config-digest')) return false; + // if the x-edge-config-digest header is not present, it means + // the edge config itself does not exist + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + if (res.ok) return true; + throw new UnexpectedNetworkError(res); + }); } /** diff --git a/packages/edge-config/src/stores.json b/packages/edge-config/src/stores.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/edge-config/src/stores.json @@ -0,0 +1 @@ +{} diff --git a/packages/edge-config/src/utils/read-bundled-edge-config.ts b/packages/edge-config/src/utils/read-bundled-edge-config.ts index 94f88f009..612437b43 100644 --- a/packages/edge-config/src/utils/read-bundled-edge-config.ts +++ b/packages/edge-config/src/utils/read-bundled-edge-config.ts @@ -3,24 +3,16 @@ */ export async function readBundledEdgeConfig(id: string): Promise { try { - console.log('attempting to read build embedded edge config', id); - // @ts-expect-error this file is generated later + // @ts-expect-error this file exists in the final bundle const mod = await import('@vercel/edge-config/dist/stores.json', { with: { type: 'json' }, }); return (mod.default[id] as M | undefined) ?? null; - } catch (e) { - if ( - typeof e === 'object' && - e !== null && - 'code' in e && - (e.code === 'ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING' || - e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || - e.code === 'MODULE_NOT_FOUND') - ) { - return null; - } - - throw e; + } catch (error) { + console.error( + '@vercel/edge-config: Failed to read bundled edge config:', + error, + ); + return null; } } diff --git a/packages/edge-config/tsup.config.js b/packages/edge-config/tsup.config.js index 6917409e2..fa7fedbbf 100644 --- a/packages/edge-config/tsup.config.js +++ b/packages/edge-config/tsup.config.js @@ -11,6 +11,8 @@ export default [ skipNodeModulesBundle: true, dts: true, external: ['node_modules'], + // copies over the stores.json file to dist/ + publicDir: 'public', }), // Separate configs so we don't get split types defineConfig({ From c2ca3948759eddb4132201589202e17c3f1b4161 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 17:49:19 +0200 Subject: [PATCH 38/72] format with biome --- test/next/tsconfig.json | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/test/next/tsconfig.json b/test/next/tsconfig.json index 2f20dc76b..33c26b426 100644 --- a/test/next/tsconfig.json +++ b/test/next/tsconfig.json @@ -2,11 +2,7 @@ "schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -25,9 +21,7 @@ } ], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "include": [ @@ -38,7 +32,5 @@ "src/app/vercel/blob/script.mts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } From ddd1b8b9bf4269bf3ae61ec2c642cdf5b9a7d0c7 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 18:15:33 +0200 Subject: [PATCH 39/72] try readme --- packages/edge-config/README.md | 72 +++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/edge-config/README.md b/packages/edge-config/README.md index dbe82a0d6..7df2743fd 100644 --- a/packages/edge-config/README.md +++ b/packages/edge-config/README.md @@ -8,7 +8,7 @@ A client that lets you read Edge Config. npm install @vercel/edge-config ``` -## Examples +## Quickstart You can use the methods below to read your Edge Config given you have its Connection String stored in an Environment Variable called `process.env.EDGE_CONFIG`. @@ -23,6 +23,44 @@ Returns the value if the key exists. Returns `undefined` if the key does not exist. Throws on invalid tokens, deleted edge configs or network errors. +### Bundling + +Edge Config is a robust service that benefits from multiple layers of redundancy and optimizations. One such layer of redundancy is the ability to bundle a fallback state of Edge Config into your build output. This fallback value is used by the Edge Config SDK in the rare event that the Edge Config service is degraded or unresponsive. It is also used during the build process to guarantee a consistent state of the Edge Config for your build and avoiding unnecessary network requests during the build. + +Add the following `prebuild` script to your `package.json` to set up Edge Config bundling: + +```json +{ + "scripts": { + "prebuild": "edge-config prepare" + } +} +``` + +This will iterate through the environment variables to find Edge Config connection strings, load the latest version from the Edge Config API and emit them to the local file system. When your application is built later on your bundler will automatically include these files as they are imported by the Edge Config SDK. + +### Timeouts + +You can further pass the `timeoutMs` option when creating the Edge Config cilent to set a maximum timeout in milliseconds for any fetch requests the Edge Config SDK makes. If a request does not complete in time the Edge Config SDK will fall back to the bundled version if provided, or throw an error if not. + +```ts +import { createClient } from '@vercel/edge-config'; + +const client = createClient(process.env.EDGE_CONFIG, { timeoutMs: 750 }); +``` + +This option is only recommended when you have set up [Edge Config bundling](#bundling) or when you otherwise catch the thrown error and handle it gracefully. + +## API + + +### Reading a value + +```js +import { get } from '@vercel/edge-config'; +await get('someKey'); +``` + ### Checking if a key exists ```js @@ -54,13 +92,13 @@ await getAll(['keyA', 'keyB']); Returns selected Edge Config items. Throws on invalid tokens, deleted edge configs or network errors. -### Default behaviour +## Default Edge Config Client By default `@vercel/edge-config` will read from the Edge Config stored in `process.env.EDGE_CONFIG`. The exported `get`, `getAll`, `has` and `digest` functions are bound to this default Edge Config Client. -### Reading a value from a specific Edge Config +### Reading a value from a different Edge Config You can use `createClient(connectionString)` to read values from Edge Configs other than the default one. @@ -74,11 +112,33 @@ The `createClient` function connects to a any Edge Config based on the provided It returns the same `get`, `getAll`, `has` and `digest` functions as the default Edge Config Client exports. -### Making a value mutable -By default, the value returned by `get` and `getAll` is immutable. Modifying the object might cause an error or other undefined behaviour. +### Custom Options + +The Edge Config SDK accepts custom options + + +```js +import { createClient } from '@vercel/edge-config'; +const edgeConfig = createClient(process.env.EDGE_CONFIG, { + +export interface EdgeConfigClientOptions { + staleIfError?: number | false; + disableDevelopmentCache?: boolean; + cache?: 'no-store' | 'force-cache'; + timeoutMs?: number; +}); +``` + +**Options** +- `staleIfError`: The time in seconds for how long the Edge Config SDK will consider stale responses in case the Edge Config API responds with an error. Does not affect the usage of [bundled Edge Configs](#bundling) as fallbacks. +- `disableDevelopmentCache`: Disables the default stale-while-revalidate cache that is active during development. +- `cache`: Specifies the cache mode for the Edge Config SDK. Can be either 'no-store' or 'force-cache'. +- `timeoutMs`: Specifies the timeout in milliseconds the Edge Config SDK waits for network requests before it times out. Use this in combination with [bundled Edge Configs](#bundling) to specify an upper bound for the time the Edge Config SDK waits for network requests before it falls back to the bundled config. Throws an error in case no bundled config is available and the timeout is reached. + +### Making a value mutable -In order to make the returned value mutable, you can use the exported function `clone` to safely clone the object and make it mutable. +By default, the value returned by `get` and `getAll` is considered immutable. Modifying the object might cause an error or other undefined behaviour. In order to make the returned value mutable, you must use the exported function `clone` to safely clone the object and make it mutable. ## Writing Edge Config Items From 94e06917bddadabe9b987dd65f00b0654a088980 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 29 Nov 2025 18:33:14 +0200 Subject: [PATCH 40/72] update README --- packages/edge-config/README.md | 428 +++++++++++++++++++++++---------- 1 file changed, 302 insertions(+), 126 deletions(-) diff --git a/packages/edge-config/README.md b/packages/edge-config/README.md index 7df2743fd..67bce9d92 100644 --- a/packages/edge-config/README.md +++ b/packages/edge-config/README.md @@ -1,33 +1,44 @@ # @vercel/edge-config -A client that lets you read Edge Config. +The official JavaScript client for reading from [Vercel Edge Config](https://vercel.com/docs/storage/edge-config) — an ultra-low latency data store for global configuration data. -## Installation +## Quick Start + +### Installation ```sh npm install @vercel/edge-config ``` -## Quickstart +### Setup -You can use the methods below to read your Edge Config given you have its Connection String stored in an Environment Variable called `process.env.EDGE_CONFIG`. +1. Create an Edge Config on [vercel.com](https://vercel.com/d?to=%2F%5Bteam%5D%2F%5Bproject%5D%2Fstores&title=Create+Edge+Config+Store). +2. Connect it to your project to get a connection string +3. The connection string is automatically available as `process.env.EDGE_CONFIG` -### Reading a value +### Basic Usage ```js import { get } from '@vercel/edge-config'; -await get('someKey'); -``` -Returns the value if the key exists. -Returns `undefined` if the key does not exist. -Throws on invalid tokens, deleted edge configs or network errors. +// Read a single value +const value = await get('myKey'); + +// Check if a key exists +import { has } from '@vercel/edge-config'; +const exists = await has('myKey'); // true or false + +// Read multiple values at once +import { getAll } from '@vercel/edge-config'; +const values = await getAll(['keyA', 'keyB', 'keyC']); -### Bundling +// Read all values +const allValues = await getAll(); +``` -Edge Config is a robust service that benefits from multiple layers of redundancy and optimizations. One such layer of redundancy is the ability to bundle a fallback state of Edge Config into your build output. This fallback value is used by the Edge Config SDK in the rare event that the Edge Config service is degraded or unresponsive. It is also used during the build process to guarantee a consistent state of the Edge Config for your build and avoiding unnecessary network requests during the build. +### Production Best Practices -Add the following `prebuild` script to your `package.json` to set up Edge Config bundling: +Add Edge Config bundling for resilience and faster builds: ```json { @@ -37,190 +48,287 @@ Add the following `prebuild` script to your `package.json` to set up Edge Config } ``` -This will iterate through the environment variables to find Edge Config connection strings, load the latest version from the Edge Config API and emit them to the local file system. When your application is built later on your bundler will automatically include these files as they are imported by the Edge Config SDK. - -### Timeouts - -You can further pass the `timeoutMs` option when creating the Edge Config cilent to set a maximum timeout in milliseconds for any fetch requests the Edge Config SDK makes. If a request does not complete in time the Edge Config SDK will fall back to the bundled version if provided, or throw an error if not. +This bundles a snapshot of your Edge Config into your build as a fallback, ensuring your application continues working in the rare event the Edge Config service is temporarily unavailable. -```ts -import { createClient } from '@vercel/edge-config'; +--- -const client = createClient(process.env.EDGE_CONFIG, { timeoutMs: 750 }); -``` +## API Reference -This option is only recommended when you have set up [Edge Config bundling](#bundling) or when you otherwise catch the thrown error and handle it gracefully. +### Default Client Functions -## API +These functions read from the Edge Config specified in `process.env.EDGE_CONFIG`. +#### `get(key)` -### Reading a value +Reads a single value from Edge Config. ```js import { get } from '@vercel/edge-config'; -await get('someKey'); +const value = await get('myKey'); ``` -### Checking if a key exists +**Returns:** +- The value if the key exists +- `undefined` if the key does not exist -```js -import { has } from '@vercel/edge-config'; -await has('someKey'); -``` +**Throws:** +- Error on invalid connection string +- Error on deleted Edge Config +- Error on network failures -Returns `true` if the key exists. -Returns `false` if the key does not exist. -Throws on invalid tokens, deleted edge configs or network errors. +#### `has(key)` -### Reading all items +Checks if a key exists in Edge Config. ```js -import { getAll } from '@vercel/edge-config'; -await getAll(); +import { has } from '@vercel/edge-config'; +const exists = await has('myKey'); ``` -Returns all Edge Config items. -Throws on invalid tokens, deleted edge configs or network errors. +**Returns:** +- `true` if the key exists +- `false` if the key does not exist + +**Throws:** +- Error on invalid connection string +- Error on deleted Edge Config +- Error on network failures + +#### `getAll(keys?)` -### Reading items in batch +Reads multiple or all values from Edge Config. ```js import { getAll } from '@vercel/edge-config'; -await getAll(['keyA', 'keyB']); -``` -Returns selected Edge Config items. -Throws on invalid tokens, deleted edge configs or network errors. +// Get specific keys +const some = await getAll(['keyA', 'keyB']); + +// Get all keys +const all = await getAll(); +``` -## Default Edge Config Client +**Parameters:** +- `keys` (optional): Array of keys to retrieve. If omitted, returns all items. -By default `@vercel/edge-config` will read from the Edge Config stored in `process.env.EDGE_CONFIG`. +**Returns:** +- Object containing the requested key-value pairs -The exported `get`, `getAll`, `has` and `digest` functions are bound to this default Edge Config Client. +**Throws:** +- Error on invalid connection string +- Error on deleted Edge Config +- Error on network failures -### Reading a value from a different Edge Config +#### `digest()` -You can use `createClient(connectionString)` to read values from Edge Configs other than the default one. +Gets the current digest (version hash) of the Edge Config. ```js -import { createClient } from '@vercel/edge-config'; -const edgeConfig = createClient(process.env.ANOTHER_EDGE_CONFIG); -await edgeConfig.get('someKey'); +import { digest } from '@vercel/edge-config'; +const currentDigest = await digest(); ``` -The `createClient` function connects to a any Edge Config based on the provided Connection String. +**Returns:** +- String containing the current digest -It returns the same `get`, `getAll`, `has` and `digest` functions as the default Edge Config Client exports. +**Throws:** +- Error on invalid connection string +- Error on deleted Edge Config +- Error on network failures +--- -### Custom Options +### Custom Client -The Edge Config SDK accepts custom options +Use `createClient()` to connect to a specific Edge Config or customize behavior. +#### `createClient(connectionString, options?)` + +Creates a client instance for a specific Edge Config. ```js import { createClient } from '@vercel/edge-config'; -const edgeConfig = createClient(process.env.EDGE_CONFIG, { - -export interface EdgeConfigClientOptions { + +const client = createClient(process.env.ANOTHER_EDGE_CONFIG); +await client.get('myKey'); +``` + +**Parameters:** + +- `connectionString` (string): The Edge Config connection string +- `options` (object, optional): Configuration options + +**Options:** + +```ts +{ + // Fallback to stale data for N seconds if the API returns an error staleIfError?: number | false; + + // Disable the default development cache (stale-while-revalidate) disableDevelopmentCache?: boolean; + + // Control Next.js fetch cache behavior cache?: 'no-store' | 'force-cache'; + + // Timeout for network requests in milliseconds + // Falls back to bundled config if available, or throws if not timeoutMs?: number; -}); +} ``` -**Options** -- `staleIfError`: The time in seconds for how long the Edge Config SDK will consider stale responses in case the Edge Config API responds with an error. Does not affect the usage of [bundled Edge Configs](#bundling) as fallbacks. -- `disableDevelopmentCache`: Disables the default stale-while-revalidate cache that is active during development. -- `cache`: Specifies the cache mode for the Edge Config SDK. Can be either 'no-store' or 'force-cache'. -- `timeoutMs`: Specifies the timeout in milliseconds the Edge Config SDK waits for network requests before it times out. Use this in combination with [bundled Edge Configs](#bundling) to specify an upper bound for the time the Edge Config SDK waits for network requests before it falls back to the bundled config. Throws an error in case no bundled config is available and the timeout is reached. +**Returns:** +- Client object with `get()`, `getAll()`, `has()`, and `digest()` methods -### Making a value mutable +**Example with options:** -By default, the value returned by `get` and `getAll` is considered immutable. Modifying the object might cause an error or other undefined behaviour. In order to make the returned value mutable, you must use the exported function `clone` to safely clone the object and make it mutable. +```js +const client = createClient(process.env.EDGE_CONFIG, { + timeoutMs: 750, + cache: 'force-cache', + staleIfError: 300, // Use stale data for 5 minutes on error +}); +``` -## Writing Edge Config Items +#### `clone(value)` -Edge Config Items can be managed in two ways: +Creates a mutable copy of a value returned from Edge Config. -- [Using the Dashboard on vercel.com](https://vercel.com/docs/concepts/edge-network/edge-config/edge-config-dashboard#manage-items-in-the-store) -- [Using the Vercel API](https://vercel.com/docs/concepts/edge-network/edge-config/vercel-api#update-your-edge-config) +```js +import { get, clone } from '@vercel/edge-config'; -Keep in mind that Edge Config is built for very high read volume, but for infrequent writes. +const value = await get('myKey'); +const mutableValue = clone(value); +mutableValue.someProperty = 'new value'; // Safe to modify +``` -## Error Handling +**Why this is needed:** For performance, Edge Config returns immutable references. Mutating values directly may cause unexpected behavior. Use `clone()` when you need to modify returned values. -- An error is thrown in case of a network error -- An error is thrown in case of an unexpected response +--- -## Edge Runtime Support +## Advanced Features -`@vercel/edge-config` is compatible with the [Edge Runtime](https://edge-runtime.vercel.app/). It can be used inside environments like [Vercel Edge Functions](https://vercel.com/edge) as follows: +### Edge Config Bundling -```js -// Next.js (pages/api/edge.js) (npm i next@canary) -// Other frameworks (api/edge.js) (npm i -g vercel@canary) +Bundling creates a build-time snapshot of your Edge Config that serves as a fallback and eliminates network requests during builds. -import { get } from '@vercel/edge-config'; +**Setup:** -export default (req) => { - const value = await get("someKey") - return new Response(`someKey contains value "${value})"`); -}; - -export const config = { runtime: 'edge' }; +```json +{ + "scripts": { + "prebuild": "edge-config prepare" + } +} ``` -## OpenTelemetry Tracing +**Benefits:** +- Resilience: Your app continues working if Edge Config is temporarily unavailable +- Faster builds: Only a single network request needed per Edge Config during build +- Consistency: Guarantees the same Edge Config state throughout your build -The `@vercel/edge-config` package makes use of the OpenTelemetry standard to trace certain functions for observability. In order to enable it, use the function `setTracerProvider` to set the `TracerProvider` that should be used by the SDK. +**How it works:** +1. The `edge-config prepare` command scans environment variables for connection strings +2. It fetches the latest version of each Edge Config +3. It saves them to local files that are automatically bundled by your build tool +4. The SDK automatically uses these as fallbacks when needed + +### Timeouts + +Set a maximum wait time for Edge Config requests: ```js -import { setTracerProvider } from '@vercel/edge-config'; -import { trace } from '@opentelemetry/api'; +import { createClient } from '@vercel/edge-config'; -setTracerProvider(trace); +const client = createClient(process.env.EDGE_CONFIG, { + timeoutMs: 750, +}); ``` -More verbose traces can be enabled by setting the `EDGE_CONFIG_TRACE_VERBOSE` environment variable to `true`. +**Behavior:** +- If a request exceeds the timeout, the SDK falls back to the bundled version (if available) +- If no bundled version exists, an error is thrown + +**Recommendation:** Only use timeouts when you have bundling enabled or proper error handling. + +### Writing to Edge Config + +Edge Config is optimized for high-volume reads and infrequent writes. Update values using: -## Frameworks +- [Vercel Dashboard](https://vercel.com/docs/concepts/edge-network/edge-config/edge-config-dashboard#manage-items-in-the-store) — Visual interface +- [Vercel API](https://vercel.com/docs/concepts/edge-network/edge-config/vercel-api#update-your-edge-config) — Programmatic updates + +--- + +## Framework Integration ### Next.js -#### Cache Components +#### App Router (Dynamic Rendering) -The Edge Config SDK supports [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) out of the box. Since Edge Config is a dynamic operation it always triggers dynamic mode unless you explicitly opt out as shown in the next section. +By default, Edge Config triggers dynamic rendering: + +```js +import { get } from '@vercel/edge-config'; -##### Fetch cache +export default async function Page() { + const value = await get('myKey'); + return
{value}
; +} +``` -By default the Edge Config SDK will fetch with `no-store`, which triggers dynamic mode in Next.js ([docs](https://nextjs.org/docs/app/api-reference/functions/fetch#optionscache)). +#### App Router (Static Rendering) -To use Edge Config with static pages, pass the `force-cache` option: +To use Edge Config with static pages, enable caching: ```js import { createClient } from '@vercel/edge-config'; -const edgeConfigClient = createClient(process.env.EDGE_CONFIG, { +const client = createClient(process.env.EDGE_CONFIG, { cache: 'force-cache', }); -// then use the client as usual -edgeConfigClient.get('someKey'); +export default async function Page() { + const value = await client.get('myKey'); + return
{value}
; +} +``` + +**Note:** Static rendering may display stale values until the page is rebuilt. + +#### Pages Router + +```js +// pages/api/config.js +import { get } from '@vercel/edge-config'; + +export default async function handler(req, res) { + const value = await get('myKey'); + res.json({ value }); +} ``` -**Note** This opts out of dynamic behavior, so the page might display stale values. +#### Edge Runtime + +```js +// pages/api/edge.js +import { get } from '@vercel/edge-config'; + +export default async function handler(req) { + const value = await get('myKey'); + return new Response(JSON.stringify({ value })); +} -### Nuxt, SvelteKit and other vite based frameworks +export const config = { runtime: 'edge' }; +``` -`@vercel/edge-config` reads database credentials from the environment variables on `process.env`. In general, `process.env` is automatically populated from your `.env` file during development, which is created when you run `vc env pull`. However, Vite does not expose the `.env` variables on `process.env.` +### Vite-Based Frameworks (Nuxt, SvelteKit, etc.) -You can fix this in **one** of following two ways: +Vite doesn't automatically expose `.env` variables on `process.env`. Choose one solution: -1. You can populate `process.env` yourself using something like `dotenv-expand`: +**Option 1: Populate `process.env` with dotenv-expand** -```shell +```sh pnpm install --save-dev dotenv dotenv-expand ``` @@ -230,43 +338,111 @@ import dotenvExpand from 'dotenv-expand'; import { loadEnv, defineConfig } from 'vite'; export default defineConfig(({ mode }) => { - // This check is important! if (mode === 'development') { const env = loadEnv(mode, process.cwd(), ''); dotenvExpand.expand({ parsed: env }); } return { - ... + // Your config }; }); ``` -2. You can provide the credentials explicitly, instead of relying on a zero-config setup. For example, this is how you could create a client in SvelteKit, which makes private environment variables available via `$env/static/private`: +**Option 2: Pass connection string explicitly** -```diff +```js +// SvelteKit example import { createClient } from '@vercel/edge-config'; -+ import { EDGE_CONFIG } from '$env/static/private'; +import { EDGE_CONFIG } from '$env/static/private'; + +const client = createClient(EDGE_CONFIG); +await client.get('myKey'); +``` + +--- -- const edgeConfig = createClient(process.env.ANOTHER_EDGE_CONFIG); -+ const edgeConfig = createClient(EDGE_CONFIG); -await edgeConfig.get('someKey'); +## Observability + +### OpenTelemetry Tracing + +Enable tracing for observability: + +```js +import { setTracerProvider } from '@vercel/edge-config'; +import { trace } from '@opentelemetry/api'; + +setTracerProvider(trace); +``` + +For verbose traces, set the environment variable: + +```sh +EDGE_CONFIG_TRACE_VERBOSE=true +``` + +--- + +## Error Handling + +Edge Config throws errors in these cases: + +- **Invalid connection string**: The provided connection string is malformed or invalid +- **Deleted Edge Config**: The Edge Config has been deleted +- **Network errors**: Request failed due to network issues +- **Timeout**: Request exceeded `timeoutMs` and no bundled fallback is available + +**Example:** + +```js +import { get } from '@vercel/edge-config'; + +try { + const value = await get('myKey'); +} catch (error) { + console.error('Failed to read Edge Config:', error); + // Handle error appropriately +} +``` + +--- + +## Important Notes + +### Immutability + +Values returned by `get()` and `getAll()` are immutable by default. Do not modify them directly: + +```js +// BAD - Do not do this +const value = await get('myKey'); +value.property = 'new value'; // Causes undefined behavior + +// GOOD - Clone first +import { clone } from '@vercel/edge-config'; +const value = await get('myKey'); +const mutableValue = clone(value); +mutableValue.property = 'new value'; // Safe ``` -## Notes +**Why?** For performance, the SDK returns references to cached objects. Mutations can affect other parts of your application. -### Do not mutate return values +--- -Cloning objects in JavaScript can be slow. That's why the Edge Config SDK uses an optimization which can lead to multiple calls reading the same key all receiving a reference to the same value. +## Contributing -For this reason the value read from Edge Config should never be mutated, otherwise they could affect other parts of the code base reading the same key, or a later request reading the same key. +Found a bug or want to contribute? -If you need to modify, see the `clone` function described [here](#do-not-mutate-return-values). +1. [Fork this repository](https://help.github.com/articles/fork-a-repo/) +2. [Clone it locally](https://help.github.com/articles/cloning-a-repository/) +3. Link the package: `npm link` +4. In your test project: `npm link @vercel/edge-config` +5. Make your changes and run tests: `npm test` -## Caught a Bug? +--- -1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device -2. Link the package to the global module directory: `npm link` -3. Within the module you want to test your local development instance of `@vercel/edge-config`, just link it to the dependencies: `npm link @vercel/edge-config`. Instead of the default one from npm, Node.js will now use your clone of `@vercel/edge-config`! +## Resources -As always, you can run the tests using: `npm test` +- [Edge Config Documentation](https://vercel.com/docs/edge-config) +- [Vercel Dashboard](https://vercel.com/) +- [Report Issues](https://github.com/vercel/storage/issues) From 92ae88ea1570524a72398846cb33a4b32c0dd96a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 2 Dec 2025 18:39:32 +0200 Subject: [PATCH 41/72] update message --- .../edge-config/src/create-create-client.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index aaa829d53..af31c6d23 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -22,8 +22,6 @@ type CreateClient = ( options?: deps.EdgeConfigClientOptions, ) => EdgeConfigClient; -const FALLBACK_WARNING = '@vercel/edge-config: Falling back to build embed'; - export function createCreateClient({ getBundledEdgeConfig, getInMemoryEdgeConfig, @@ -174,7 +172,9 @@ export function createCreateClient({ ); } catch (error) { if (!bundledEdgeConfig) throw error; - console.warn(FALLBACK_WARNING); + console.warn( + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + ); return select(bundledEdgeConfig.data); } }, @@ -232,7 +232,9 @@ export function createCreateClient({ ); } catch (error) { if (!bundledEdgeConfig) throw error; - console.warn(FALLBACK_WARNING); + console.warn( + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + ); return select(bundledEdgeConfig.data); } }, @@ -291,7 +293,9 @@ export function createCreateClient({ ); } catch (error) { if (!bundledEdgeConfig) throw error; - console.warn(FALLBACK_WARNING); + console.warn( + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + ); return select(bundledEdgeConfig.data); } }, @@ -346,7 +350,9 @@ export function createCreateClient({ ); } catch (error) { if (!bundledEdgeConfig) throw error; - console.warn(FALLBACK_WARNING); + console.warn( + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + ); return select(bundledEdgeConfig.data); } }, From def9d7c4c4e28f7590bcbc0365dda34418d0a2d7 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 2 Dec 2025 18:40:21 +0200 Subject: [PATCH 42/72] also log error --- packages/edge-config/src/create-create-client.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index af31c6d23..a4c385281 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -174,6 +174,7 @@ export function createCreateClient({ if (!bundledEdgeConfig) throw error; console.warn( `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + error, ); return select(bundledEdgeConfig.data); } @@ -234,6 +235,7 @@ export function createCreateClient({ if (!bundledEdgeConfig) throw error; console.warn( `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + error, ); return select(bundledEdgeConfig.data); } @@ -295,6 +297,7 @@ export function createCreateClient({ if (!bundledEdgeConfig) throw error; console.warn( `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + error, ); return select(bundledEdgeConfig.data); } @@ -352,6 +355,7 @@ export function createCreateClient({ if (!bundledEdgeConfig) throw error; console.warn( `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + error, ); return select(bundledEdgeConfig.data); } From dc2e855db0c5336d848fdb9d0d0dd7df7cef1dbf Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 2 Dec 2025 18:40:49 +0200 Subject: [PATCH 43/72] enahnce error message --- packages/edge-config/src/create-create-client.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index a4c385281..ad4b35a6d 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -173,7 +173,7 @@ export function createCreateClient({ } catch (error) { if (!bundledEdgeConfig) throw error; console.warn( - `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId} due to the following error`, error, ); return select(bundledEdgeConfig.data); @@ -234,7 +234,7 @@ export function createCreateClient({ } catch (error) { if (!bundledEdgeConfig) throw error; console.warn( - `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId} due to the following error`, error, ); return select(bundledEdgeConfig.data); @@ -296,7 +296,7 @@ export function createCreateClient({ } catch (error) { if (!bundledEdgeConfig) throw error; console.warn( - `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId} due to the following error`, error, ); return select(bundledEdgeConfig.data); @@ -354,7 +354,7 @@ export function createCreateClient({ } catch (error) { if (!bundledEdgeConfig) throw error; console.warn( - `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId}`, + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId} due to the following error`, error, ); return select(bundledEdgeConfig.data); From 377129d9878ea2083260625ff062ad64ca9d4e61 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 08:13:30 +0200 Subject: [PATCH 44/72] simplify --- .../edge-config/src/create-create-client.ts | 30 ++++------------- packages/edge-config/src/edge-config.ts | 14 +------- packages/edge-config/src/index.next-js.ts | 13 -------- packages/edge-config/src/index.ts | 2 -- packages/edge-config/src/stores.json | 2 +- packages/edge-config/src/types.ts | 12 +++++++ .../src/utils/read-bundled-edge-config.ts | 32 +++++++++++++++---- 7 files changed, 46 insertions(+), 59 deletions(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index ad4b35a6d..5a93d29c4 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -1,6 +1,7 @@ import { name as sdkName, version as sdkVersion } from '../package.json'; import type * as deps from './edge-config'; import type { + BundledEdgeConfig, EdgeConfigClient, EdgeConfigFunctionsOptions, EdgeConfigItems, @@ -15,6 +16,7 @@ import { parseConnectionString, pick, } from './utils'; +import { readBundledEdgeConfig } from './utils/read-bundled-edge-config'; import { trace } from './utils/tracing'; type CreateClient = ( @@ -23,7 +25,6 @@ type CreateClient = ( ) => EdgeConfigClient; export function createCreateClient({ - getBundledEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, fetchEdgeConfigItem, @@ -31,7 +32,6 @@ export function createCreateClient({ fetchAllEdgeConfigItem, fetchEdgeConfigTrace, }: { - getBundledEdgeConfig: typeof deps.getBundledEdgeConfig; getInMemoryEdgeConfig: typeof deps.getInMemoryEdgeConfig; getLocalEdgeConfig: typeof deps.getLocalEdgeConfig; fetchEdgeConfigItem: typeof deps.fetchEdgeConfigItem; @@ -99,20 +99,10 @@ export function createCreateClient({ /** * The edge config bundled at build time */ - let bundledEdgeConfigPromise: Promise<{ - data: EmbeddedEdgeConfig; - updatedAt: number | undefined; - } | null> | null; - /** - * Function to load the bundled edge config or null. - */ - async function getBundledEdgeConfigPromise() { - if (!connection || connection.type !== 'vercel') return null; - if (bundledEdgeConfigPromise) return await bundledEdgeConfigPromise; - const { id } = connection; - bundledEdgeConfigPromise = getBundledEdgeConfig(id, fetchCache); - return await bundledEdgeConfigPromise; - } + const bundledEdgeConfig: BundledEdgeConfig | null = + connection && connection.type === 'vercel' + ? readBundledEdgeConfig(connection.id) + : null; const isBuildStep = process.env.CI === '1' || @@ -126,8 +116,6 @@ export function createCreateClient({ ): Promise { assertIsKey(key); - const bundledEdgeConfig = await getBundledEdgeConfigPromise(); - function select(edgeConfig: EmbeddedEdgeConfig) { if (isEmptyKey(key)) return undefined; // We need to return a clone of the value so users can't modify @@ -189,8 +177,6 @@ export function createCreateClient({ assertIsKey(key); if (isEmptyKey(key)) return false; - const bundledEdgeConfig = await getBundledEdgeConfigPromise(); - function select(edgeConfig: EmbeddedEdgeConfig) { return Promise.resolve(hasOwn(edgeConfig.items, key)); } @@ -251,8 +237,6 @@ export function createCreateClient({ assertIsKeys(keys); } - const bundledEdgeConfig = await getBundledEdgeConfigPromise(); - function select(edgeConfig: EmbeddedEdgeConfig) { return keys === undefined ? Promise.resolve(edgeConfig.items as T) @@ -312,8 +296,6 @@ export function createCreateClient({ async function digest( localOptions?: EdgeConfigFunctionsOptions, ): Promise { - const bundledEdgeConfig = await getBundledEdgeConfigPromise(); - function select(embeddedEdgeConfig: EmbeddedEdgeConfig) { return embeddedEdgeConfig.digest; } diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index a54361f5e..cf8c4f85f 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -1,6 +1,7 @@ import { readFile } from '@vercel/edge-config-fs'; import { name as sdkName, version as sdkVersion } from '../package.json'; import type { + BundledEdgeConfig, Connection, EdgeConfigItems, EdgeConfigValue, @@ -106,19 +107,6 @@ const getPrivateEdgeConfig = trace( }, ); -export async function getBundledEdgeConfig( - connectionId: Connection['id'], - _fetchCache: EdgeConfigClientOptions['cache'], -): Promise<{ - data: EmbeddedEdgeConfig; - updatedAt: number | undefined; -} | null> { - return readBundledEdgeConfig<{ - data: EmbeddedEdgeConfig; - updatedAt: number | undefined; - }>(connectionId); -} - /** * Reads the Edge Config from a local provider, if available, * to avoid Network requests. diff --git a/packages/edge-config/src/index.next-js.ts b/packages/edge-config/src/index.next-js.ts index c6ea84b5f..b78b71330 100644 --- a/packages/edge-config/src/index.next-js.ts +++ b/packages/edge-config/src/index.next-js.ts @@ -5,7 +5,6 @@ import { fetchEdgeConfigHas, fetchEdgeConfigItem, fetchEdgeConfigTrace, - getBundledEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, } from './edge-config'; @@ -49,17 +48,6 @@ function setCacheLifeFromFetchCache( } } -async function getBuildEmbeddedEdgeConfigForNext( - ...args: Parameters -): ReturnType { - 'use cache'; - - const [id, fetchCache] = args; - setCacheLifeFromFetchCache(fetchCache); - - return getBundledEdgeConfig(id, fetchCache); -} - async function getInMemoryEdgeConfigForNext( ...args: Parameters ): ReturnType { @@ -137,7 +125,6 @@ async function fetchEdgeConfigTraceForNext( * @returns An Edge Config Client instance */ export const createClient = createCreateClient({ - getBundledEdgeConfig: getBuildEmbeddedEdgeConfigForNext, getInMemoryEdgeConfig: getInMemoryEdgeConfigForNext, getLocalEdgeConfig: getLocalEdgeConfigForNext, fetchEdgeConfigItem: fetchEdgeConfigItemForNext, diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index acaa624cc..4726c45c2 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -4,7 +4,6 @@ import { fetchEdgeConfigHas, fetchEdgeConfigItem, fetchEdgeConfigTrace, - getBundledEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, } from './edge-config'; @@ -37,7 +36,6 @@ export { * @returns An Edge Config Client instance */ export const createClient = createCreateClient({ - getBundledEdgeConfig, getInMemoryEdgeConfig, getLocalEdgeConfig, fetchEdgeConfigItem, diff --git a/packages/edge-config/src/stores.json b/packages/edge-config/src/stores.json index 0967ef424..19765bd50 100644 --- a/packages/edge-config/src/stores.json +++ b/packages/edge-config/src/stores.json @@ -1 +1 @@ -{} +null diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index d52d05ca8..b527938ef 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -3,6 +3,18 @@ export interface EmbeddedEdgeConfig { items: Record; } +/** + * An Edge Config bundled into stores.json + * + * The contents of stores.json itself are either + * - null + * - Record + */ +export type BundledEdgeConfig = { + data: EmbeddedEdgeConfig; + updatedAt: number | undefined; +}; + /** * The parsed info contained in a connection string. */ diff --git a/packages/edge-config/src/utils/read-bundled-edge-config.ts b/packages/edge-config/src/utils/read-bundled-edge-config.ts index 612437b43..f40b35bef 100644 --- a/packages/edge-config/src/utils/read-bundled-edge-config.ts +++ b/packages/edge-config/src/utils/read-bundled-edge-config.ts @@ -1,13 +1,33 @@ +// The stores.json file is overwritten at build time by the app, +// which then becomes part of the actual app's bundle. This is a fallback +// mechanism used so the app can always fall back to a bundled version of +// the config, even if the Edge Config service is degraded or unavailable. +// +// At build time of the actual app the stores.json file is overwritten +// using the "edge-config prepare" script. +// +// At build time of this package we also copy over a placeholder file, +// such that any app not using the "edge-config prepare" script has +// imports an empty object instead. +// +// By default we provide a "stores.json" file that contains "null", which +// allows us to determine whether the "edge-config prepare" script ran. +// If the value is "null" the script did not run. If the value is an empty +// object or an object with keys the script definitely ran. +// +// @ts-expect-error this file exists in the final bundle +import stores from '@vercel/edge-config/dist/stores.json' with { type: 'json' }; +import type { BundledEdgeConfig } from '../types'; + /** * Reads the local edge config that gets bundled at build time (stores.json). */ -export async function readBundledEdgeConfig(id: string): Promise { +export function readBundledEdgeConfig(id: string): BundledEdgeConfig | null { try { - // @ts-expect-error this file exists in the final bundle - const mod = await import('@vercel/edge-config/dist/stores.json', { - with: { type: 'json' }, - }); - return (mod.default[id] as M | undefined) ?? null; + // "edge-config prepare" script did not run + if (stores === null) return null; + + return (stores[id] as BundledEdgeConfig | undefined) ?? null; } catch (error) { console.error( '@vercel/edge-config: Failed to read bundled edge config:', From c4bf645a2c76bb2f4e02158c171073b4dd9c4ba8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 08:14:56 +0200 Subject: [PATCH 45/72] update engines --- packages/edge-config/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 2790830f6..63bd20dee 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -81,6 +81,6 @@ } }, "engines": { - "node": ">=14.6" + "node": ">=20" } } From 2cd0a7688aa540d28752471cfddd0bd06b4270c9 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 08:30:38 +0200 Subject: [PATCH 46/72] avoid spamming console --- .../edge-config/src/index.bundled.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/edge-config/src/index.bundled.test.ts b/packages/edge-config/src/index.bundled.test.ts index 3fb3aa082..06f4e4a2a 100644 --- a/packages/edge-config/src/index.bundled.test.ts +++ b/packages/edge-config/src/index.bundled.test.ts @@ -28,6 +28,8 @@ function delay(data: T, timeoutMs: number): Promise { beforeEach(() => { fetchMock.resetMocks(); cache.clear(); + + jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); }); // mock fs for test @@ -72,6 +74,11 @@ describe('default Edge Config', () => { cache: 'no-store', }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); @@ -93,6 +100,11 @@ describe('default Edge Config', () => { cache: 'no-store', }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); @@ -120,6 +132,11 @@ describe('default Edge Config', () => { signal: expect.any(AbortSignal), }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); }); @@ -145,6 +162,11 @@ describe('default Edge Config', () => { }), cache: 'no-store', }); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); describe('when called with keys', () => { @@ -169,6 +191,11 @@ describe('default Edge Config', () => { cache: 'no-store', }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); }); @@ -193,6 +220,11 @@ describe('default Edge Config', () => { }), cache: 'no-store', }); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); describe('when called with keys', () => { @@ -217,6 +249,11 @@ describe('default Edge Config', () => { cache: 'no-store', }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); }); @@ -248,6 +285,11 @@ describe('default Edge Config', () => { cache: 'no-store', signal: expect.any(AbortSignal), }); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); describe('when called with keys', () => { @@ -278,6 +320,11 @@ describe('default Edge Config', () => { signal: expect.any(AbortSignal), }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); }); @@ -305,6 +352,11 @@ describe('default Edge Config', () => { cache: 'no-store', }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); @@ -326,6 +378,11 @@ describe('default Edge Config', () => { cache: 'no-store', }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); }); @@ -351,6 +408,11 @@ describe('default Edge Config', () => { cache: 'no-store', }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); @@ -372,6 +434,11 @@ describe('default Edge Config', () => { cache: 'no-store', }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); }); @@ -407,6 +474,11 @@ describe('default Edge Config', () => { signal: expect.any(AbortSignal), }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); @@ -436,6 +508,11 @@ describe('default Edge Config', () => { signal: expect.any(AbortSignal), }, ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); }); }); }); From ba7f8540b42d530077880494ca0a4a76b8414d4e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 08:32:44 +0200 Subject: [PATCH 47/72] default stores.json to null --- packages/edge-config/public/stores.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edge-config/public/stores.json b/packages/edge-config/public/stores.json index 0967ef424..19765bd50 100644 --- a/packages/edge-config/public/stores.json +++ b/packages/edge-config/public/stores.json @@ -1 +1 @@ -{} +null From 81b6f076a878c98499efbfa9d477a51e1ef263a3 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 08:48:03 +0200 Subject: [PATCH 48/72] better changelog --- .changeset/famous-games-sleep.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.changeset/famous-games-sleep.md b/.changeset/famous-games-sleep.md index 3695a23b9..9d8d20c9e 100644 --- a/.changeset/famous-games-sleep.md +++ b/.changeset/famous-games-sleep.md @@ -2,4 +2,26 @@ "@vercel/edge-config": minor --- -Embed fallback Edge Config during build +Add optional fallback Edge Config bundling for improved resilience + +You can now bundle fallback Edge Config versions directly into your build to protect against Edge Config service degradation or unavailability. + +**How it works:** +- Your app continues using the latest Edge Config version under normal conditions +- If the Edge Config service is degraded, the SDK automatically falls back to the in-memory version +- If that's unavailable, it uses the bundled version from build time as a last resort +- This ensures your app maintains functionality even if Edge Config is temporarily unavailable + +**Setup:** + +Add the `edge-config prepare` command to your `prebuild` script: + +```json +{ + "scripts": { + "prebuild": "edge-config prepare" + } +} +``` + +The prepare command reads your environment variables and bundles all connected Edge Configs. Use `--verbose` for detailed logs. Note that the bundled Edge Config stores count towards your build [function bundle size limit](https://vercel.com/docs/functions/limitations#bundle-size-limits). From f2f8958624aadaba28adb831501893e8c82fe19b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 08:59:24 +0200 Subject: [PATCH 49/72] ensure cli script fails on errors --- packages/edge-config/src/cli.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index c27df7047..772481b54 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -15,20 +15,18 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import { version } from '../package.json'; -import type { Connection, EmbeddedEdgeConfig } from '../src/types'; +import type { + BundledEdgeConfig, + Connection, + EmbeddedEdgeConfig, +} from '../src/types'; import { parseConnectionString } from '../src/utils/parse-connection-string'; // Get the directory where this CLI script is located const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -type StoresJson = Record< - string, - { - data: EmbeddedEdgeConfig; - updatedAt: number | undefined; - } ->; +type StoresJson = Record; type PrepareOptions = { verbose?: boolean; @@ -45,8 +43,8 @@ async function prepare(output: string, options: PrepareOptions): Promise { [], ); - const values = await Promise.all( - connections.map(async (connection) => { + const values: BundledEdgeConfig[] = await Promise.all( + connections.map>(async (connection) => { const res = await fetch(connection.baseUrl, { headers: { authorization: `Bearer ${connection.token}`, @@ -55,6 +53,12 @@ async function prepare(output: string, options: PrepareOptions): Promise { }, }); + if (!res.ok) { + throw new Error( + `@vercel/edge-config: Failed to prepare edge config ${connection.id}: ${res.status} ${res.statusText}`, + ); + } + const ts = res.headers.get('x-edge-config-updated-at'); const data: EmbeddedEdgeConfig = await res.json(); return { data, updatedAt: ts ? Number(ts) : undefined }; From 60c8f24d060fe20a50bda075ccecf2f03199bc51 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 09:13:13 +0200 Subject: [PATCH 50/72] mention build improvements --- .changeset/famous-games-sleep.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.changeset/famous-games-sleep.md b/.changeset/famous-games-sleep.md index 9d8d20c9e..40fd4528a 100644 --- a/.changeset/famous-games-sleep.md +++ b/.changeset/famous-games-sleep.md @@ -25,3 +25,11 @@ Add the `edge-config prepare` command to your `prebuild` script: ``` The prepare command reads your environment variables and bundles all connected Edge Configs. Use `--verbose` for detailed logs. Note that the bundled Edge Config stores count towards your build [function bundle size limit](https://vercel.com/docs/functions/limitations#bundle-size-limits). + +**Build improvements:** + +Using `edge-config prepare` also improves build performance and consistency: + +- **Faster builds:** The SDK fetches each Edge Config store once per build instead of once per key +- **Eliminates inconsistencies:** Prevents Edge Config changes between individual key reads during the build +- **Automatic optimization:** No code changes required—just add the prebuild script From efef7addf8f99d177459b83a304af19817293b54 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 09:17:33 +0200 Subject: [PATCH 51/72] remove unused imports --- .changeset/famous-games-sleep.md | 16 ++++++++++++++++ packages/edge-config/src/edge-config.ts | 2 -- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.changeset/famous-games-sleep.md b/.changeset/famous-games-sleep.md index 40fd4528a..1b32c9219 100644 --- a/.changeset/famous-games-sleep.md +++ b/.changeset/famous-games-sleep.md @@ -33,3 +33,19 @@ Using `edge-config prepare` also improves build performance and consistency: - **Faster builds:** The SDK fetches each Edge Config store once per build instead of once per key - **Eliminates inconsistencies:** Prevents Edge Config changes between individual key reads during the build - **Automatic optimization:** No code changes required—just add the prebuild script + +**Timeout configuration:** + +You can now configure request timeouts to prevent slow Edge Config reads from blocking your application: + +```ts +// Set timeout when creating the client +const client = createClient(process.env.EDGE_CONFIG, { + timeoutMs: 1000 // timeout after 1 second +}); + +// Or per-request +await client.get('key', { timeoutMs: 500 }); +``` + +When a timeout occurs, the SDK will fall back to the bundled Edge Config if available, or throw an error otherwise. This is particularly useful when combined with bundled Edge Configs to ensure fast, resilient reads. diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index cf8c4f85f..2a2a3b8bf 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -1,7 +1,6 @@ import { readFile } from '@vercel/edge-config-fs'; import { name as sdkName, version as sdkVersion } from '../package.json'; import type { - BundledEdgeConfig, Connection, EdgeConfigItems, EdgeConfigValue, @@ -14,7 +13,6 @@ import { UnexpectedNetworkError, } from './utils'; import { fetchWithCachedResponse } from './utils/fetch-with-cached-response'; -import { readBundledEdgeConfig } from './utils/read-bundled-edge-config'; import { trace } from './utils/tracing'; const X_EDGE_CONFIG_SDK_HEADER = From 63de9f327c3581d8558ba993d054a0a574391582 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 10:10:40 +0200 Subject: [PATCH 52/72] respect timeoutMs for whole operation --- .../edge-config/src/create-create-client.ts | 265 ++++++++++-------- packages/edge-config/src/edge-config.ts | 8 - packages/edge-config/src/index.ts | 1 + .../edge-config/src/utils/timeout-error.ts | 24 ++ 4 files changed, 173 insertions(+), 125 deletions(-) create mode 100644 packages/edge-config/src/utils/timeout-error.ts diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 5a93d29c4..337d0d889 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -17,6 +17,7 @@ import { pick, } from './utils'; import { readBundledEdgeConfig } from './utils/read-bundled-edge-config'; +import { EdgeConfigFetchTimeoutError } from './utils/timeout-error'; import { trace } from './utils/tracing'; type CreateClient = ( @@ -108,6 +109,31 @@ export function createCreateClient({ process.env.CI === '1' || process.env.NEXT_PHASE === 'phase-production-build'; + /** + * Ensures that the provided function runs within a specified timeout. + * If the timeout is reached before the function completes, it returns the fallback. + */ + async function timeout( + method: string, + key: string | string[] | undefined, + localOptions: EdgeConfigFunctionsOptions | undefined, + run: () => Promise, + ): Promise { + const ms = localOptions?.timeoutMs ?? timeoutMs; + return await Promise.race([ + new Promise((resolve, reject) => + setTimeout( + () => + reject( + new EdgeConfigFetchTimeoutError(edgeConfigId, method, key), + ), + ms, + ), + ), + run(), + ]); + } + const api: Omit = { get: trace( async function get( @@ -118,11 +144,7 @@ export function createCreateClient({ function select(edgeConfig: EmbeddedEdgeConfig) { if (isEmptyKey(key)) return undefined; - // We need to return a clone of the value so users can't modify - // our original value, and so the reference changes. - // - // This makes it consistent with the real API. - return Promise.resolve(edgeConfig.items[key] as T); + return edgeConfig.items[key] as T; } if (bundledEdgeConfig && isBuildStep) { @@ -130,34 +152,35 @@ export function createCreateClient({ } try { - let localEdgeConfig: EmbeddedEdgeConfig | null = null; - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, + return await timeout('get', key, localOptions, async () => { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } + + if (localEdgeConfig) return select(localEdgeConfig); + + return await fetchEdgeConfigItem( + baseUrl, + key, + version, + localOptions?.consistentRead, + headers, fetchCache, - options.staleIfError, ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); - } - - if (localEdgeConfig) return select(localEdgeConfig); - - return await fetchEdgeConfigItem( - baseUrl, - key, - version, - localOptions?.consistentRead, - headers, - fetchCache, - localOptions?.timeoutMs ?? timeoutMs, - ); + }); } catch (error) { if (!bundledEdgeConfig) throw error; console.warn( @@ -178,7 +201,7 @@ export function createCreateClient({ if (isEmptyKey(key)) return false; function select(edgeConfig: EmbeddedEdgeConfig) { - return Promise.resolve(hasOwn(edgeConfig.items, key)); + return hasOwn(edgeConfig.items, key); } if (bundledEdgeConfig && isBuildStep) { @@ -186,37 +209,38 @@ export function createCreateClient({ } try { - let localEdgeConfig: EmbeddedEdgeConfig | null = null; - - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, + return await timeout('has', key, localOptions, async () => { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } + + if (localEdgeConfig) { + return Promise.resolve(hasOwn(localEdgeConfig.items, key)); + } + + return await fetchEdgeConfigHas( + baseUrl, + key, + version, + localOptions?.consistentRead, + headers, fetchCache, ); - } - - if (localEdgeConfig) { - return Promise.resolve(hasOwn(localEdgeConfig.items, key)); - } - - return await fetchEdgeConfigHas( - baseUrl, - key, - version, - localOptions?.consistentRead, - headers, - fetchCache, - localOptions?.timeoutMs ?? timeoutMs, - ); + }); } catch (error) { if (!bundledEdgeConfig) throw error; console.warn( @@ -239,8 +263,8 @@ export function createCreateClient({ function select(edgeConfig: EmbeddedEdgeConfig) { return keys === undefined - ? Promise.resolve(edgeConfig.items as T) - : Promise.resolve(pick(edgeConfig.items as T, keys) as T); + ? (edgeConfig.items as T) + : (pick(edgeConfig.items as T, keys) as T); } if (bundledEdgeConfig && isBuildStep) { @@ -248,35 +272,36 @@ export function createCreateClient({ } try { - let localEdgeConfig: EmbeddedEdgeConfig | null = null; - - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, + return await timeout('getAll', keys, localOptions, async () => { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } + + if (localEdgeConfig) return select(localEdgeConfig); + + return await fetchAllEdgeConfigItem( + baseUrl, + keys, + version, + localOptions?.consistentRead, + headers, fetchCache, - options.staleIfError, ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); - } - - if (localEdgeConfig) return select(localEdgeConfig); - - return await fetchAllEdgeConfigItem( - baseUrl, - keys, - version, - localOptions?.consistentRead, - headers, - fetchCache, - localOptions?.timeoutMs ?? timeoutMs, - ); + }); } catch (error) { if (!bundledEdgeConfig) throw error; console.warn( @@ -305,33 +330,39 @@ export function createCreateClient({ } try { - let localEdgeConfig: EmbeddedEdgeConfig | null = null; - - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); - } - - if (localEdgeConfig) return select(localEdgeConfig); - - return await fetchEdgeConfigTrace( - baseUrl, - version, - localOptions?.consistentRead, - headers, - fetchCache, - localOptions?.timeoutMs ?? timeoutMs, + return await timeout( + 'digest', + undefined, + localOptions, + async () => { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } + + if (localEdgeConfig) return select(localEdgeConfig); + + return await fetchEdgeConfigTrace( + baseUrl, + version, + localOptions?.consistentRead, + headers, + fetchCache, + ); + }, ); } catch (error) { if (!bundledEdgeConfig) throw error; diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index 2a2a3b8bf..b12e6af0e 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -257,7 +257,6 @@ export async function fetchEdgeConfigItem( consistentRead: undefined | boolean, localHeaders: HeadersRecord, fetchCache: EdgeConfigClientOptions['cache'], - timeoutMs: number | undefined, ): Promise { if (isEmptyKey(key)) return undefined; @@ -268,7 +267,6 @@ export async function fetchEdgeConfigItem( return fetchWithCachedResponse(`${baseUrl}/item/${key}?version=${version}`, { headers, cache: fetchCache, - signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }).then(async (res) => { if (res.ok) return res.json(); await consumeResponseBody(res); @@ -298,7 +296,6 @@ export async function fetchEdgeConfigHas( consistentRead: undefined | boolean, localHeaders: HeadersRecord, fetchCache: EdgeConfigClientOptions['cache'], - timeoutMs: undefined | number, ): Promise { const headers = new Headers(localHeaders); if (consistentRead) { @@ -310,7 +307,6 @@ export async function fetchEdgeConfigHas( method: 'HEAD', headers, cache: fetchCache, - signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }).then((res) => { if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); if (res.status === 404) { @@ -336,7 +332,6 @@ export async function fetchAllEdgeConfigItem( consistentRead: undefined | boolean, localHeaders: HeadersRecord, fetchCache: EdgeConfigClientOptions['cache'], - timeoutMs: undefined | number, ): Promise { let url = `${baseUrl}/items?version=${version}`; if (keys) { @@ -360,7 +355,6 @@ export async function fetchAllEdgeConfigItem( return fetchWithCachedResponse(url, { headers, cache: fetchCache, - signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }).then(async (res) => { if (res.ok) return res.json(); await consumeResponseBody(res); @@ -384,7 +378,6 @@ export async function fetchEdgeConfigTrace( consistentRead: undefined | boolean, localHeaders: HeadersRecord, fetchCache: EdgeConfigClientOptions['cache'], - timeoutMs: number | undefined, ): Promise { const headers = new Headers(localHeaders); if (consistentRead) { @@ -394,7 +387,6 @@ export async function fetchEdgeConfigTrace( return fetchWithCachedResponse(`${baseUrl}/digest?version=${version}`, { headers, cache: fetchCache, - signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }).then(async (res) => { if (res.ok) return res.json() as Promise; await consumeResponseBody(res); diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 4726c45c2..390a5843b 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -15,6 +15,7 @@ import type { } from './types'; import { parseConnectionString } from './utils'; +export { EdgeConfigFetchTimeoutError } from './utils/timeout-error'; export { setTracerProvider } from './utils/tracing'; export { diff --git a/packages/edge-config/src/utils/timeout-error.ts b/packages/edge-config/src/utils/timeout-error.ts new file mode 100644 index 000000000..ac7fd6779 --- /dev/null +++ b/packages/edge-config/src/utils/timeout-error.ts @@ -0,0 +1,24 @@ +export class EdgeConfigFetchTimeoutError extends Error { + public method: string; + public edgeConfigId: string; + public key: string | string[] | undefined; + + constructor( + edgeConfigId: string, + method: string, + key: string | string[] | undefined, + ) { + super( + `@vercel/edge-config: read timed out for ${edgeConfigId} (${[ + method, + key ? (Array.isArray(key) ? key.join(', ') : key) : '', + ] + .filter((x) => x !== '') + .join(' ')})`, + ); + this.name = 'EdgeConfigFetchTimeoutError'; + this.edgeConfigId = edgeConfigId; + this.key = key; + this.method = method; + } +} From 6e539055a628d079e5a95e8a9bd15617a1a9b156 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 10:28:59 +0200 Subject: [PATCH 53/72] redo --- .../edge-config/src/create-create-client.ts | 22 ++++++++++--------- ...d.test.ts => index.bundled.common.test.ts} | 5 ----- packages/edge-config/src/index.ts | 2 +- .../edge-config/src/utils/timeout-error.ts | 4 ++-- 4 files changed, 15 insertions(+), 18 deletions(-) rename packages/edge-config/src/{index.bundled.test.ts => index.bundled.common.test.ts} (98%) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 337d0d889..b4fe84d6f 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -17,7 +17,7 @@ import { pick, } from './utils'; import { readBundledEdgeConfig } from './utils/read-bundled-edge-config'; -import { EdgeConfigFetchTimeoutError } from './utils/timeout-error'; +import { TimeoutError } from './utils/timeout-error'; import { trace } from './utils/tracing'; type CreateClient = ( @@ -120,18 +120,20 @@ export function createCreateClient({ run: () => Promise, ): Promise { const ms = localOptions?.timeoutMs ?? timeoutMs; - return await Promise.race([ - new Promise((resolve, reject) => - setTimeout( - () => - reject( - new EdgeConfigFetchTimeoutError(edgeConfigId, method, key), - ), + let timer: NodeJS.Timeout | undefined; + // ensure we don't throw within race to avoid throwing after run() completes + const result = await Promise.race([ + new Promise((resolve) => { + timer = setTimeout( + () => resolve(new TimeoutError(edgeConfigId, method, key)), ms, - ), - ), + ); + }), run(), ]); + if (result instanceof TimeoutError) throw result; + clearTimeout(timer); + return result; } const api: Omit = { diff --git a/packages/edge-config/src/index.bundled.test.ts b/packages/edge-config/src/index.bundled.common.test.ts similarity index 98% rename from packages/edge-config/src/index.bundled.test.ts rename to packages/edge-config/src/index.bundled.common.test.ts index 06f4e4a2a..b7de7786c 100644 --- a/packages/edge-config/src/index.bundled.test.ts +++ b/packages/edge-config/src/index.bundled.common.test.ts @@ -129,7 +129,6 @@ describe('default Edge Config', () => { 'cache-control': 'stale-if-error=604800', }), cache: 'no-store', - signal: expect.any(AbortSignal), }, ); @@ -283,7 +282,6 @@ describe('default Edge Config', () => { 'cache-control': 'stale-if-error=604800', }), cache: 'no-store', - signal: expect.any(AbortSignal), }); expect(console.warn).toHaveBeenCalledWith( @@ -317,7 +315,6 @@ describe('default Edge Config', () => { 'cache-control': 'stale-if-error=604800', }), cache: 'no-store', - signal: expect.any(AbortSignal), }, ); @@ -471,7 +468,6 @@ describe('default Edge Config', () => { 'cache-control': 'stale-if-error=604800', }), cache: 'no-store', - signal: expect.any(AbortSignal), }, ); @@ -505,7 +501,6 @@ describe('default Edge Config', () => { 'cache-control': 'stale-if-error=604800', }), cache: 'no-store', - signal: expect.any(AbortSignal), }, ); diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 390a5843b..3647bdbbd 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -15,7 +15,7 @@ import type { } from './types'; import { parseConnectionString } from './utils'; -export { EdgeConfigFetchTimeoutError } from './utils/timeout-error'; +export { TimeoutError } from './utils/timeout-error'; export { setTracerProvider } from './utils/tracing'; export { diff --git a/packages/edge-config/src/utils/timeout-error.ts b/packages/edge-config/src/utils/timeout-error.ts index ac7fd6779..766052dbe 100644 --- a/packages/edge-config/src/utils/timeout-error.ts +++ b/packages/edge-config/src/utils/timeout-error.ts @@ -1,4 +1,4 @@ -export class EdgeConfigFetchTimeoutError extends Error { +export class TimeoutError extends Error { public method: string; public edgeConfigId: string; public key: string | string[] | undefined; @@ -16,7 +16,7 @@ export class EdgeConfigFetchTimeoutError extends Error { .filter((x) => x !== '') .join(' ')})`, ); - this.name = 'EdgeConfigFetchTimeoutError'; + this.name = 'TimeoutError'; this.edgeConfigId = edgeConfigId; this.key = key; this.method = method; From 2a0533e69470e4636014900f45eb04cca0cbdc5c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 10:39:26 +0200 Subject: [PATCH 54/72] fix test setup --- packages/edge-config/package.json | 7 +++---- ...index.bundled.common.test.ts => index.bundled.test.ts} | 0 packages/edge-config/src/index.edge.test.ts | 8 ++++++++ packages/edge-config/src/index.node.test.ts | 8 ++++++++ .../src/{index.common.test.ts => index.test.ts} | 0 5 files changed, 19 insertions(+), 4 deletions(-) rename packages/edge-config/src/{index.bundled.common.test.ts => index.bundled.test.ts} (100%) rename packages/edge-config/src/{index.common.test.ts => index.test.ts} (100%) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 63bd20dee..d69617572 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -33,10 +33,9 @@ "check": "biome check", "prepublishOnly": "pnpm run build", "publint": "npx publint", - "test": "pnpm run test:node && pnpm run test:edge && pnpm run test:common", - "test:common": "jest --env @edge-runtime/jest-environment .common.test.ts && jest --env node .common.test.ts", - "test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts", - "test:node": "jest --env node .node.test.ts", + "test": "jest", + "test:edge": "jest --env @edge-runtime/jest-environment", + "test:node": "jest --env node --testPathIgnorePatterns", "type-check": "tsc --noEmit" }, "bin": { diff --git a/packages/edge-config/src/index.bundled.common.test.ts b/packages/edge-config/src/index.bundled.test.ts similarity index 100% rename from packages/edge-config/src/index.bundled.common.test.ts rename to packages/edge-config/src/index.bundled.test.ts diff --git a/packages/edge-config/src/index.edge.test.ts b/packages/edge-config/src/index.edge.test.ts index 0dbda18d3..50367d448 100644 --- a/packages/edge-config/src/index.edge.test.ts +++ b/packages/edge-config/src/index.edge.test.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment @edge-runtime/jest-environment + */ + import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import { createClient, digest, get, getAll, has } from './index'; @@ -18,6 +22,10 @@ describe('default Edge Config', () => { 'https://edge-config.vercel.com/ecfg_1?token=token-1', ); }); + + it('should use Edge Runtime', () => { + expect(EdgeRuntime).toBe('edge-runtime'); + }); }); it('should fetch an item from the Edge Config specified by process.env.EDGE_CONFIG', async () => { diff --git a/packages/edge-config/src/index.node.test.ts b/packages/edge-config/src/index.node.test.ts index cbed01088..c2a87d06a 100644 --- a/packages/edge-config/src/index.node.test.ts +++ b/packages/edge-config/src/index.node.test.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment node + */ + import { readFile } from '@vercel/edge-config-fs'; import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; @@ -34,6 +38,10 @@ describe('default Edge Config', () => { 'https://edge-config.vercel.com/ecfg_1?token=token-1', ); }); + + it('should use Edge Runtime', () => { + expect(typeof EdgeRuntime).toBe('undefined'); + }); }); it('should fetch an item from the Edge Config specified by process.env.EDGE_CONFIG', async () => { diff --git a/packages/edge-config/src/index.common.test.ts b/packages/edge-config/src/index.test.ts similarity index 100% rename from packages/edge-config/src/index.common.test.ts rename to packages/edge-config/src/index.test.ts From ba74b92dcbfdea8c3e580612946dc88d1b6fb393 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 10:39:55 +0200 Subject: [PATCH 55/72] rm unused option --- packages/edge-config/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index d69617572..334e8a772 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -35,7 +35,7 @@ "publint": "npx publint", "test": "jest", "test:edge": "jest --env @edge-runtime/jest-environment", - "test:node": "jest --env node --testPathIgnorePatterns", + "test:node": "jest --env node", "type-check": "tsc --noEmit" }, "bin": { From 25cfb20e46a55bb49eb6b2493dee6f2ba8f31405 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 10:42:25 +0200 Subject: [PATCH 56/72] simplify include --- packages/edge-config/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edge-config/tsconfig.json b/packages/edge-config/tsconfig.json index e82c3c44e..ba7a29ed6 100644 --- a/packages/edge-config/tsconfig.json +++ b/packages/edge-config/tsconfig.json @@ -4,5 +4,5 @@ "resolveJsonModule": true, "target": "ESNext" }, - "include": ["src", "scripts"] + "include": ["src"] } From d4a21a3925115246afb74de5102749c7b922f9c8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 11:04:37 +0200 Subject: [PATCH 57/72] fix: do not race if timeoutMs is not provided --- packages/edge-config/src/create-create-client.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index b4fe84d6f..6c8316cc5 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -120,6 +120,9 @@ export function createCreateClient({ run: () => Promise, ): Promise { const ms = localOptions?.timeoutMs ?? timeoutMs; + + if (typeof ms !== 'number') return run(); + let timer: NodeJS.Timeout | undefined; // ensure we don't throw within race to avoid throwing after run() completes const result = await Promise.race([ From dca4cb62fd4d9c6dfb092f9504dd8397dfee1dec Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 11:13:02 +0200 Subject: [PATCH 58/72] add test --- .../edge-config/src/index.bundled.test.ts | 7 ++---- packages/edge-config/src/index.test.ts | 25 +++++++++++++++++++ packages/edge-config/src/utils/delay.ts | 3 +++ 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 packages/edge-config/src/utils/delay.ts diff --git a/packages/edge-config/src/index.bundled.test.ts b/packages/edge-config/src/index.bundled.test.ts index b7de7786c..b60ce9beb 100644 --- a/packages/edge-config/src/index.bundled.test.ts +++ b/packages/edge-config/src/index.bundled.test.ts @@ -4,6 +4,7 @@ import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import { get, getAll, has } from './index'; import type { EmbeddedEdgeConfig } from './types'; +import { delay } from './utils/delay'; import { cache } from './utils/fetch-with-cached-response'; jest.mock('@vercel/edge-config/dist/stores.json', () => { @@ -21,10 +22,6 @@ jest.mock('@vercel/edge-config/dist/stores.json', () => { const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; const baseUrl = 'https://edge-config.vercel.com/ecfg_1'; -function delay(data: T, timeoutMs: number): Promise { - return new Promise((resolve) => setTimeout(() => resolve(data), timeoutMs)); -} - beforeEach(() => { fetchMock.resetMocks(); cache.clear(); @@ -112,7 +109,7 @@ describe('default Edge Config', () => { it('should fall back to the build embedded config', async () => { const timeoutMs = 50; fetchMock.mockResponseOnce(() => - delay(JSON.stringify('fetched-value'), timeoutMs * 4), + delay(timeoutMs * 4, JSON.stringify('fetched-value')), ); await expect(get('foo', { timeoutMs })).resolves.toEqual( 'foo-build-embedded', diff --git a/packages/edge-config/src/index.test.ts b/packages/edge-config/src/index.test.ts index 9e4604db3..bf20129aa 100644 --- a/packages/edge-config/src/index.test.ts +++ b/packages/edge-config/src/index.test.ts @@ -7,6 +7,7 @@ import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import * as pkg from './index'; import type { EdgeConfigClient } from './types'; +import { delay } from './utils/delay'; import { cache } from './utils/fetch-with-cached-response'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; @@ -162,6 +163,30 @@ describe('when running without lambda layer or via edge function', () => { expect(fetchMock).toHaveBeenCalledTimes(0); }); }); + + describe('timeoutMs', () => { + it('should not race when timeoutMs is not set', async () => { + fetchMock.mockResponseOnce(() => + delay(10, JSON.stringify('fetched-value')), + ); + + await expect(edgeConfig.get('foo')).resolves.toEqual('fetched-value'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${modifiedBaseUrl}/item/foo?version=1`, + { + headers: new Headers({ + Authorization: 'Bearer token-2', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); }); describe('has(key)', () => { diff --git a/packages/edge-config/src/utils/delay.ts b/packages/edge-config/src/utils/delay.ts new file mode 100644 index 000000000..e0ea4379c --- /dev/null +++ b/packages/edge-config/src/utils/delay.ts @@ -0,0 +1,3 @@ +export function delay(timeoutMs: number, data: T): Promise { + return new Promise((resolve) => setTimeout(() => resolve(data), timeoutMs)); +} From 00148d317d1aee43d02f2a7bab6de8771bab5fa6 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 12:52:52 +0200 Subject: [PATCH 59/72] respect vf --- packages/edge-config/src/cli.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index 772481b54..f5dad82e5 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -32,12 +32,44 @@ type PrepareOptions = { verbose?: boolean; }; +/** + * Parses a connection string with the following format: + * `flags:edgeConfigId=ecfg_abcd&edgeConfigToken=xxx` + */ +function parseConnectionFromFlags(text: string): Connection | null { + try { + if (!text.startsWith('flags:')) return null; + const params = new URLSearchParams(text.slice(6)); + + const id = params.get('edgeConfigId'); + const token = params.get('edgeConfigToken'); + + if (!id || !token) return null; + + return { + type: 'vercel', + baseUrl: `https://edge-config.vercel.com/${id}`, + id, + version: '1', + token, + }; + } catch { + // no-op + } + + return null; +} + async function prepare(output: string, options: PrepareOptions): Promise { const connections = Object.values(process.env).reduce( (acc, value) => { if (typeof value !== 'string') return acc; const data = parseConnectionString(value); if (data) acc.push(data); + + const vfData = parseConnectionFromFlags(value); + if (vfData) acc.push(vfData); + return acc; }, [], From 5431858d25aca5dfe6d79b98cb5cd534a0483805 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 12:56:12 +0200 Subject: [PATCH 60/72] fix delay calls --- packages/edge-config/src/index.bundled.test.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/edge-config/src/index.bundled.test.ts b/packages/edge-config/src/index.bundled.test.ts index b60ce9beb..1344e7b78 100644 --- a/packages/edge-config/src/index.bundled.test.ts +++ b/packages/edge-config/src/index.bundled.test.ts @@ -260,8 +260,8 @@ describe('default Edge Config', () => { it('should return all items', async () => { fetchMock.mockResponseOnce(() => delay( - JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), timeoutMs * 4, + JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), ), ); @@ -291,8 +291,8 @@ describe('default Edge Config', () => { it('should return the selected items', async () => { fetchMock.mockResponseOnce(() => delay( - JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), timeoutMs * 4, + JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), ), ); @@ -442,13 +442,10 @@ describe('default Edge Config', () => { describe('when item exists', () => { it('should return true', async () => { fetchMock.mockResponseOnce(() => - delay( - { - status: 404, - headers: { 'x-edge-config-digest': '1' }, - }, - timeoutMs * 4, - ), + delay(timeoutMs * 4, { + status: 404, + headers: { 'x-edge-config-digest': '1' }, + }), ); await expect(has('foo', { timeoutMs })).resolves.toEqual(true); @@ -479,8 +476,8 @@ describe('default Edge Config', () => { it('should return false', async () => { fetchMock.mockResponseOnce(() => delay( - JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), timeoutMs * 4, + JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), ), ); await expect( From 120de923d1e67a1aae2d28ed625114822424514f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 13:20:56 +0200 Subject: [PATCH 61/72] add user-agent --- packages/edge-config/src/cli.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index f5dad82e5..3a24ed988 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -82,6 +82,7 @@ async function prepare(output: string, options: PrepareOptions): Promise { authorization: `Bearer ${connection.token}`, // consistentRead 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, + 'user-agent': `@vercel/edge-config@${version} (prepare)`, }, }); From 33750e759d4b9374178cb16751272b9c95c5bd95 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 14:53:57 +0200 Subject: [PATCH 62/72] ensure we always cancel timers --- packages/edge-config/src/create-create-client.ts | 13 ++++++------- packages/edge-config/src/utils/delay.ts | 11 +++++++++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 6c8316cc5..360f2cdd9 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -16,6 +16,7 @@ import { parseConnectionString, pick, } from './utils'; +import { delay } from './utils/delay'; import { readBundledEdgeConfig } from './utils/read-bundled-edge-config'; import { TimeoutError } from './utils/timeout-error'; import { trace } from './utils/tracing'; @@ -126,16 +127,14 @@ export function createCreateClient({ let timer: NodeJS.Timeout | undefined; // ensure we don't throw within race to avoid throwing after run() completes const result = await Promise.race([ - new Promise((resolve) => { - timer = setTimeout( - () => resolve(new TimeoutError(edgeConfigId, method, key)), - ms, - ); + delay(ms, new TimeoutError(edgeConfigId, method, key), (t) => { + timer = t; }), run(), - ]); + ]).finally(() => { + clearTimeout(timer); + }); if (result instanceof TimeoutError) throw result; - clearTimeout(timer); return result; } diff --git a/packages/edge-config/src/utils/delay.ts b/packages/edge-config/src/utils/delay.ts index e0ea4379c..35488bfa1 100644 --- a/packages/edge-config/src/utils/delay.ts +++ b/packages/edge-config/src/utils/delay.ts @@ -1,3 +1,10 @@ -export function delay(timeoutMs: number, data: T): Promise { - return new Promise((resolve) => setTimeout(() => resolve(data), timeoutMs)); +export function delay( + timeoutMs: number, + data: T, + assign?: (timeoutId?: NodeJS.Timeout) => void, +): Promise { + return new Promise((resolve) => { + const timeoutId = setTimeout(() => resolve(data), timeoutMs); + assign?.(timeoutId); + }); } From e5ecc20482db860c3e63edec4afbc0df97dfe292 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Dec 2025 16:22:57 +0200 Subject: [PATCH 63/72] expect TimeoutError --- packages/edge-config/src/index.bundled.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/edge-config/src/index.bundled.test.ts b/packages/edge-config/src/index.bundled.test.ts index 1344e7b78..916762218 100644 --- a/packages/edge-config/src/index.bundled.test.ts +++ b/packages/edge-config/src/index.bundled.test.ts @@ -6,6 +6,7 @@ import { get, getAll, has } from './index'; import type { EmbeddedEdgeConfig } from './types'; import { delay } from './utils/delay'; import { cache } from './utils/fetch-with-cached-response'; +import { TimeoutError } from './utils/timeout-error'; jest.mock('@vercel/edge-config/dist/stores.json', () => { return { @@ -131,7 +132,7 @@ describe('default Edge Config', () => { expect(console.warn).toHaveBeenCalledWith( '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', - expect.any(DOMException), + expect.any(TimeoutError), ); }); }); From c67771337a28df2cba9f75c91f75c23cf0c8e6ea Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 8 Dec 2025 10:56:24 +0200 Subject: [PATCH 64/72] allow skipping prepare script with EDGE_CONFIG_SKIP_PREPARE_SCRIPT --- packages/edge-config/src/cli.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index 3a24ed988..58472dda2 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -129,6 +129,8 @@ program .description('Prepare Edge Config stores.json file for build time embedding') .option('--verbose', 'Enable verbose logging') .action(async (options: PrepareOptions) => { + if (process.env.EDGE_CONFIG_DISABLE_EMBEDDING === '1') return; + const output = join(__dirname, '..', 'dist', 'stores.json'); await prepare(output, options); }); From b17b01e813ef604f4764954e9306d74d7e3c0fc8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 8 Dec 2025 14:28:29 +0200 Subject: [PATCH 65/72] fix EDGE_CONFIG_SKIP_PREPARE_SCRIPT --- packages/edge-config/src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index 58472dda2..238e9f63a 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -129,7 +129,7 @@ program .description('Prepare Edge Config stores.json file for build time embedding') .option('--verbose', 'Enable verbose logging') .action(async (options: PrepareOptions) => { - if (process.env.EDGE_CONFIG_DISABLE_EMBEDDING === '1') return; + if (process.env.EDGE_CONFIG_SKIP_PREPARE_SCRIPT === '1') return; const output = join(__dirname, '..', 'dist', 'stores.json'); await prepare(output, options); From 57ee90d3c0ae32e688fbb55b1b11419cf1a23e89 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 16 Dec 2025 07:51:26 +0200 Subject: [PATCH 66/72] snapshot --- packages/edge-config/src/cli.ts | 8 ++++++-- packages/edge-config/src/index.ts | 2 ++ packages/edge-config/src/types.ts | 2 ++ .../edge-config/src/utils/parse-connection-string.ts | 12 ++++++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index 238e9f63a..f94a68f19 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -125,8 +125,12 @@ program .version(version); program - .command('prepare') - .description('Prepare Edge Config stores.json file for build time embedding') + .command('snapshot') + .description( + 'Capture point-in-time snapshots of Edge Configs. ' + + 'Ensures consistent values during build, enables instant bootstrapping, ' + + 'and provides fallback when the service is unavailable.', + ) .option('--verbose', 'Enable verbose logging') .action(async (options: PrepareOptions) => { if (process.env.EDGE_CONFIG_SKIP_PREPARE_SCRIPT === '1') return; diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 3647bdbbd..20e1a5e35 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -51,6 +51,8 @@ let defaultEdgeConfigClient: EdgeConfigClient; // process.env.EDGE_CONFIG is not defined and its methods are never used. function init(): void { if (!defaultEdgeConfigClient) { + // TODO if process.env.EDGE_CONFIG exists we can ALWAYS create + // the client eagerly!!, no need for lazy creation defaultEdgeConfigClient = createClient(process.env.EDGE_CONFIG); } } diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index b527938ef..dd11909de 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -25,6 +25,7 @@ export type Connection = token: string; version: string; type: 'vercel'; + snapshot: 'required' | 'optional'; } | { baseUrl: string; @@ -32,6 +33,7 @@ export type Connection = token: string; version: string; type: 'external'; + snapshot: 'required' | 'optional'; }; /** diff --git a/packages/edge-config/src/utils/parse-connection-string.ts b/packages/edge-config/src/utils/parse-connection-string.ts index f09a5ade7..62fd1eb82 100644 --- a/packages/edge-config/src/utils/parse-connection-string.ts +++ b/packages/edge-config/src/utils/parse-connection-string.ts @@ -21,12 +21,16 @@ function parseVercelConnectionStringFromUrl(text: string): Connection | null { const token = url.searchParams.get('token'); if (!token || token === '') return null; + const snapshot = + url.searchParams.get('snapshot') === 'required' ? 'required' : 'optional'; + return { type: 'vercel', baseUrl: `https://edge-config.vercel.com/${id}`, id, version: '1', token, + snapshot, }; } catch { return null; @@ -47,12 +51,16 @@ function parseConnectionFromQueryParams(text: string): Connection | null { if (!id || !token) return null; + const snapshot = + params.get('snapshot') === 'required' ? 'required' : 'optional'; + return { type: 'vercel', baseUrl: `https://edge-config.vercel.com/${id}`, id, version: '1', token, + snapshot, }; } catch { // no-op @@ -103,6 +111,9 @@ function parseExternalConnectionStringFromUrl( if (!id || !token) return null; + const snapshot = + url.searchParams.get('snapshot') === 'required' ? 'required' : 'optional'; + // remove all search params for use as baseURL url.search = ''; @@ -113,6 +124,7 @@ function parseExternalConnectionStringFromUrl( id, token, version, + snapshot, }; } catch { return null; From 04047b6f350650b316cd669211b92a15bfc5e839 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 16 Dec 2025 08:02:20 +0200 Subject: [PATCH 67/72] eagerly create default client --- packages/edge-config/src/index.ts | 47 ++++++++++++++----------- packages/edge-config/src/utils/index.ts | 2 ++ 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 20e1a5e35..ffcdd170c 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -13,7 +13,7 @@ import type { EdgeConfigValue, EmbeddedEdgeConfig, } from './types'; -import { parseConnectionString } from './utils'; +import { ERRORS, parseConnectionString } from './utils'; export { TimeoutError } from './utils/timeout-error'; export { setTracerProvider } from './utils/tracing'; @@ -45,17 +45,16 @@ export const createClient = createCreateClient({ fetchEdgeConfigTrace, }); -let defaultEdgeConfigClient: EdgeConfigClient; - -// lazy init fn so the default edge config does not throw in case -// process.env.EDGE_CONFIG is not defined and its methods are never used. -function init(): void { - if (!defaultEdgeConfigClient) { - // TODO if process.env.EDGE_CONFIG exists we can ALWAYS create - // the client eagerly!!, no need for lazy creation - defaultEdgeConfigClient = createClient(process.env.EDGE_CONFIG); - } -} +/** + * The default Edge Config client that is automatically created from the `process.env.EDGE_CONFIG` environment variable. + * When using the `get`, `getAl`, `has`, and `digest` exports they use this underlying default client. + */ +export const defaultClient: EdgeConfigClient | null = + typeof process.env.EDGE_CONFIG === 'string' && + (process.env.EDGE_CONFIG.startsWith('edge-config:') || + process.env.EDGE_CONFIG.startsWith('https://edge-config.vercel.com/')) + ? createClient(process.env.EDGE_CONFIG) + : null; /** * Reads a single item from the default Edge Config. @@ -68,8 +67,10 @@ function init(): void { * @returns the value stored under the given key, or undefined */ export const get: EdgeConfigClient['get'] = (...args) => { - init(); - return defaultEdgeConfigClient.get(...args); + if (!defaultClient) { + throw new Error(ERRORS.MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING); + } + return defaultClient.get(...args); }; /** @@ -83,8 +84,10 @@ export const get: EdgeConfigClient['get'] = (...args) => { * @returns the value stored under the given key, or undefined */ export const getAll: EdgeConfigClient['getAll'] = (...args) => { - init(); - return defaultEdgeConfigClient.getAll(...args); + if (!defaultClient) { + throw new Error(ERRORS.MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING); + } + return defaultClient.getAll(...args); }; /** @@ -98,8 +101,10 @@ export const getAll: EdgeConfigClient['getAll'] = (...args) => { * @returns true if the given key exists in the Edge Config. */ export const has: EdgeConfigClient['has'] = (...args) => { - init(); - return defaultEdgeConfigClient.has(...args); + if (!defaultClient) { + throw new Error(ERRORS.MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING); + } + return defaultClient.has(...args); }; /** @@ -112,8 +117,10 @@ export const has: EdgeConfigClient['has'] = (...args) => { * @returns The digest of the Edge Config. */ export const digest: EdgeConfigClient['digest'] = (...args) => { - init(); - return defaultEdgeConfigClient.digest(...args); + if (!defaultClient) { + throw new Error(ERRORS.MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING); + } + return defaultClient.digest(...args); }; /** diff --git a/packages/edge-config/src/utils/index.ts b/packages/edge-config/src/utils/index.ts index 6028e5c59..ae219a75a 100644 --- a/packages/edge-config/src/utils/index.ts +++ b/packages/edge-config/src/utils/index.ts @@ -3,6 +3,8 @@ import { trace } from './tracing'; export const ERRORS = { UNAUTHORIZED: '@vercel/edge-config: Unauthorized', EDGE_CONFIG_NOT_FOUND: '@vercel/edge-config: Edge Config not found', + MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING: + '@vercel/edge-config: Missing default Edge Config connection string', }; export { parseConnectionString } from './parse-connection-string'; From bc96c2e141f03c91fae8c1ade9e68f7d0f547a93 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 16 Dec 2025 08:22:18 +0200 Subject: [PATCH 68/72] accept snapshot and timeoutMs through createClient and connection strings --- .changeset/famous-games-sleep.md | 18 +++++++++++------- packages/edge-config/README.md | 6 +++--- packages/edge-config/src/cli.ts | 2 +- .../edge-config/src/create-create-client.ts | 19 ++++++++++++++++++- packages/edge-config/src/edge-config.ts | 13 ++++++++++++- packages/edge-config/src/types.ts | 2 ++ .../src/utils/parse-connection-string.ts | 16 ++++++++++++++++ .../src/utils/read-bundled-edge-config.ts | 8 ++++---- 8 files changed, 67 insertions(+), 17 deletions(-) diff --git a/.changeset/famous-games-sleep.md b/.changeset/famous-games-sleep.md index 1b32c9219..01c23bbb5 100644 --- a/.changeset/famous-games-sleep.md +++ b/.changeset/famous-games-sleep.md @@ -2,33 +2,37 @@ "@vercel/edge-config": minor --- -Add optional fallback Edge Config bundling for improved resilience +**NEW** Edge Config Snapshotting -You can now bundle fallback Edge Config versions directly into your build to protect against Edge Config service degradation or unavailability. +You can now bundle a snapshot of your Edge Config along with your deployment. This snapshot will be used to guarantee your build consistently uses the same version of each Edge Config and as a fallback in case the Edge Config service is unavailable once your project is deployed to protect against Edge Config service degradation or unavailability. **How it works:** - Your app continues using the latest Edge Config version under normal conditions - If the Edge Config service is degraded, the SDK automatically falls back to the in-memory version -- If that's unavailable, it uses the bundled version from build time as a last resort +- If that's unavailable, it uses the snapshot embedded at build time as a last resort - This ensures your app maintains functionality even if Edge Config is temporarily unavailable +Note that this means your application may serve outdated values in case the Edge Config service is unavailable at runtime. In most cases this is preferred to not serving any values at all. + **Setup:** -Add the `edge-config prepare` command to your `prebuild` script: +Add the `edge-config snapshot` command to your `prebuild` script: ```json { "scripts": { - "prebuild": "edge-config prepare" + "prebuild": "edge-config snapshot" } } ``` -The prepare command reads your environment variables and bundles all connected Edge Configs. Use `--verbose` for detailed logs. Note that the bundled Edge Config stores count towards your build [function bundle size limit](https://vercel.com/docs/functions/limitations#bundle-size-limits). +The snapshot command reads your environment variables and bundles all connected Edge Configs. Use `--verbose` for detailed logs. Technically this works by writing the Edge Config stores to `node_modules/@vercel/edge-config/dist/stores.json`. Note that the bundled Edge Config stores count towards your build [function bundle size limit](https://vercel.com/docs/functions/limitations#bundle-size-limits). + +You can further configure your client to throw errors in case it can not find the Edge Config snapshot by editing the connection string stored in the `EDGE_CONFIG` environment variable and appending `&snapshot=required`. You can also specify `snapshot: "required"` when creating clients using `createClient`. **Build improvements:** -Using `edge-config prepare` also improves build performance and consistency: +Using `edge-config snapshot` also improves build performance and consistency: - **Faster builds:** The SDK fetches each Edge Config store once per build instead of once per key - **Eliminates inconsistencies:** Prevents Edge Config changes between individual key reads during the build diff --git a/packages/edge-config/README.md b/packages/edge-config/README.md index 67bce9d92..ae7d6a927 100644 --- a/packages/edge-config/README.md +++ b/packages/edge-config/README.md @@ -43,7 +43,7 @@ Add Edge Config bundling for resilience and faster builds: ```json { "scripts": { - "prebuild": "edge-config prepare" + "prebuild": "edge-config snapshot" } } ``` @@ -217,7 +217,7 @@ Bundling creates a build-time snapshot of your Edge Config that serves as a fall ```json { "scripts": { - "prebuild": "edge-config prepare" + "prebuild": "edge-config snapshot" } } ``` @@ -228,7 +228,7 @@ Bundling creates a build-time snapshot of your Edge Config that serves as a fall - Consistency: Guarantees the same Edge Config state throughout your build **How it works:** -1. The `edge-config prepare` command scans environment variables for connection strings +1. The `edge-config snapshot` command scans environment variables for connection strings 2. It fetches the latest version of each Edge Config 3. It saves them to local files that are automatically bundled by your build tool 4. The SDK automatically uses these as fallbacks when needed diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index f94a68f19..3e3ce993e 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -108,7 +108,7 @@ async function prepare(output: string, options: PrepareOptions): Promise { await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(stores)); if (options.verbose) { - console.log(`@vercel/edge-config prepare`); + console.log(`@vercel/edge-config snapshot`); console.log(` → created ${output}`); if (Object.keys(stores).length === 0) { console.log(` → no edge configs included`); diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 360f2cdd9..3180fd213 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -57,6 +57,7 @@ export function createCreateClient({ options = { staleIfError: 604800 /* one week */, cache: 'no-store', + snapshot: 'optional', }, ): EdgeConfigClient { if (!connectionString) @@ -85,9 +86,15 @@ export function createCreateClient({ if (typeof options.staleIfError === 'number' && options.staleIfError > 0) headers['cache-control'] = `stale-if-error=${options.staleIfError}`; + const snapshot = options.snapshot ?? connection.snapshot; + const fetchCache = options.cache || 'no-store'; const timeoutMs = - typeof options.timeoutMs === 'number' ? options.timeoutMs : undefined; + typeof options.timeoutMs === 'number' + ? options.timeoutMs + : typeof connection.timeoutMs === 'number' + ? connection.timeoutMs + : undefined; /** * While in development we use SWR-like behavior for the api client to @@ -110,6 +117,16 @@ export function createCreateClient({ process.env.CI === '1' || process.env.NEXT_PHASE === 'phase-production-build'; + if ( + isBuildStep && + snapshot === 'required' && + bundledEdgeConfig === null + ) { + throw new Error( + `@vercel/edge-config: Missing snapshot for ${connection.id}. Did you forget to set up the "edge-config snapshot" script or do you have multiple Edge Config versions present in your project?`, + ); + } + /** * Ensures that the provided function runs within a specified timeout. * If the timeout is reached before the function completes, it returns the fallback. diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index b12e6af0e..9a55e74da 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -461,7 +461,18 @@ export interface EdgeConfigClientOptions { /** * How long to wait for a fresh value before falling back to a stale value or throwing. * - * It is recommended to only use this in combination with a bundled Edge Config (see "edge-config prepare" script). + * It is recommended to only use this in combination with a bundled Edge Config (see "edge-config snapshot" script). */ timeoutMs?: number; + + /** + * When set to "required" the createClient will throw an error when no snapshot is found + * during the build process. + * + * Note that the client will only check for the snapshot and throw when the CI environment + * variable is set to "1", or when the NEXT_PHASE environment variable is set to "phase-production-build". + * + * This is done as the snapshot is usually not present in development. + */ + snapshot?: 'optional' | 'required'; } diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index dd11909de..d468b77e3 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -26,6 +26,7 @@ export type Connection = version: string; type: 'vercel'; snapshot: 'required' | 'optional'; + timeoutMs: number | undefined; } | { baseUrl: string; @@ -34,6 +35,7 @@ export type Connection = version: string; type: 'external'; snapshot: 'required' | 'optional'; + timeoutMs: number | undefined; }; /** diff --git a/packages/edge-config/src/utils/parse-connection-string.ts b/packages/edge-config/src/utils/parse-connection-string.ts index 62fd1eb82..b00a65226 100644 --- a/packages/edge-config/src/utils/parse-connection-string.ts +++ b/packages/edge-config/src/utils/parse-connection-string.ts @@ -24,6 +24,8 @@ function parseVercelConnectionStringFromUrl(text: string): Connection | null { const snapshot = url.searchParams.get('snapshot') === 'required' ? 'required' : 'optional'; + const timeoutMs = parseTimeoutMs(url.searchParams.get('timeoutMs')); + return { type: 'vercel', baseUrl: `https://edge-config.vercel.com/${id}`, @@ -31,12 +33,20 @@ function parseVercelConnectionStringFromUrl(text: string): Connection | null { version: '1', token, snapshot, + timeoutMs, }; } catch { return null; } } +function parseTimeoutMs(timeoutMs: string | null): number | undefined { + if (!timeoutMs) return undefined; + const parsedTimeoutMs = Number.parseInt(timeoutMs, 10); + if (Number.isNaN(parsedTimeoutMs)) return undefined; + return parsedTimeoutMs; +} + /** * Parses a connection string with the following format: * `edge-config:id=ecfg_abcd&token=xxx` @@ -54,6 +64,8 @@ function parseConnectionFromQueryParams(text: string): Connection | null { const snapshot = params.get('snapshot') === 'required' ? 'required' : 'optional'; + const timeoutMs = parseTimeoutMs(params.get('timeoutMs')); + return { type: 'vercel', baseUrl: `https://edge-config.vercel.com/${id}`, @@ -61,6 +73,7 @@ function parseConnectionFromQueryParams(text: string): Connection | null { version: '1', token, snapshot, + timeoutMs, }; } catch { // no-op @@ -114,6 +127,8 @@ function parseExternalConnectionStringFromUrl( const snapshot = url.searchParams.get('snapshot') === 'required' ? 'required' : 'optional'; + const timeoutMs = parseTimeoutMs(url.searchParams.get('timeoutMs')); + // remove all search params for use as baseURL url.search = ''; @@ -125,6 +140,7 @@ function parseExternalConnectionStringFromUrl( token, version, snapshot, + timeoutMs, }; } catch { return null; diff --git a/packages/edge-config/src/utils/read-bundled-edge-config.ts b/packages/edge-config/src/utils/read-bundled-edge-config.ts index f40b35bef..13ccb354e 100644 --- a/packages/edge-config/src/utils/read-bundled-edge-config.ts +++ b/packages/edge-config/src/utils/read-bundled-edge-config.ts @@ -4,14 +4,14 @@ // the config, even if the Edge Config service is degraded or unavailable. // // At build time of the actual app the stores.json file is overwritten -// using the "edge-config prepare" script. +// using the "edge-config snapshot" script. // // At build time of this package we also copy over a placeholder file, -// such that any app not using the "edge-config prepare" script has +// such that any app not using the "edge-config snapshot" script has // imports an empty object instead. // // By default we provide a "stores.json" file that contains "null", which -// allows us to determine whether the "edge-config prepare" script ran. +// allows us to determine whether the "edge-config snapshot" script ran. // If the value is "null" the script did not run. If the value is an empty // object or an object with keys the script definitely ran. // @@ -24,7 +24,7 @@ import type { BundledEdgeConfig } from '../types'; */ export function readBundledEdgeConfig(id: string): BundledEdgeConfig | null { try { - // "edge-config prepare" script did not run + // "edge-config snapshot" script did not run if (stores === null) return null; return (stores[id] as BundledEdgeConfig | undefined) ?? null; From c6b291673eef3485795ef6f222abb96efcc76b8f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 16 Dec 2025 08:27:31 +0200 Subject: [PATCH 69/72] fix cli --- packages/edge-config/src/cli.ts | 12 +++++++++++- .../edge-config/src/utils/parse-connection-string.ts | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index 3e3ce993e..32446b80b 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -20,7 +20,10 @@ import type { Connection, EmbeddedEdgeConfig, } from '../src/types'; -import { parseConnectionString } from '../src/utils/parse-connection-string'; +import { + parseConnectionString, + parseTimeoutMs, +} from '../src/utils/parse-connection-string'; // Get the directory where this CLI script is located const __filename = fileURLToPath(import.meta.url); @@ -46,12 +49,19 @@ function parseConnectionFromFlags(text: string): Connection | null { if (!id || !token) return null; + const snapshot = + params.get('snapshot') === 'required' ? 'required' : 'optional'; + + const timeoutMs = parseTimeoutMs(params.get('timeoutMs')); + return { type: 'vercel', baseUrl: `https://edge-config.vercel.com/${id}`, id, version: '1', token, + snapshot, + timeoutMs, }; } catch { // no-op diff --git a/packages/edge-config/src/utils/parse-connection-string.ts b/packages/edge-config/src/utils/parse-connection-string.ts index b00a65226..6b5206d79 100644 --- a/packages/edge-config/src/utils/parse-connection-string.ts +++ b/packages/edge-config/src/utils/parse-connection-string.ts @@ -40,7 +40,7 @@ function parseVercelConnectionStringFromUrl(text: string): Connection | null { } } -function parseTimeoutMs(timeoutMs: string | null): number | undefined { +export function parseTimeoutMs(timeoutMs: string | null): number | undefined { if (!timeoutMs) return undefined; const parsedTimeoutMs = Number.parseInt(timeoutMs, 10); if (Number.isNaN(parsedTimeoutMs)) return undefined; From 07b038006eff63c045c6c0d7e83adc05bc2c9af6 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 16 Dec 2025 10:26:10 +0200 Subject: [PATCH 70/72] tests --- packages/edge-config/src/index.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/edge-config/src/index.test.ts b/packages/edge-config/src/index.test.ts index bf20129aa..79efdc624 100644 --- a/packages/edge-config/src/index.test.ts +++ b/packages/edge-config/src/index.test.ts @@ -47,6 +47,8 @@ describe('parseConnectionString', () => { token: '00000000-0000-0000-0000-000000000000', type: 'vercel', version: '1', + snapshot: 'optional', + timeoutMs: undefined, }); }); @@ -61,6 +63,8 @@ describe('parseConnectionString', () => { version: '1', type: 'external', baseUrl: 'https://example.com/ecfg_cljia81u2q1gappdgptj881dwwtc', + snapshot: 'optional', + timeoutMs: undefined, }); }); @@ -75,6 +79,8 @@ describe('parseConnectionString', () => { baseUrl: 'https://example.com/', type: 'external', version: '1', + snapshot: 'optional', + timeoutMs: undefined, }); }); @@ -90,6 +96,8 @@ describe('parseConnectionString', () => { token: '00000000-0000-0000-0000-000000000000', type: 'vercel', version: '1', + snapshot: 'optional', + timeoutMs: undefined, }); }); @@ -429,6 +437,8 @@ describe('connectionStrings', () => { token: 'token-2', type: 'external', version: '1', + snapshot: 'optional', + timeoutMs: undefined, }); }); }); From 76161153f2fc43df73037a693bd4225a26c74fb3 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 16 Dec 2025 10:39:33 +0200 Subject: [PATCH 71/72] reorg tests --- packages/edge-config/src/index.test.ts | 135 +++++++++---------------- 1 file changed, 49 insertions(+), 86 deletions(-) diff --git a/packages/edge-config/src/index.test.ts b/packages/edge-config/src/index.test.ts index 79efdc624..9036e1a0c 100644 --- a/packages/edge-config/src/index.test.ts +++ b/packages/edge-config/src/index.test.ts @@ -1,8 +1,3 @@ -// This file is meant to ensure the common logic works in both enviornments. -// -// It runs tests in both envs: -// - @edge-runtime/jest-environment -// - node import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import * as pkg from './index'; @@ -20,27 +15,37 @@ describe('test conditions', () => { }); }); -// test both package.json exports (for node & edge) separately - describe('parseConnectionString', () => { - it('should return null when an invalid Connection String is given', () => { - expect(pkg.parseConnectionString('foo')).toBeNull(); - }); - - it('should return null when the given Connection String has no token', () => { - expect( - pkg.parseConnectionString( - 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', - ), - ).toBeNull(); + it.each([ + ['url with no id', 'https://edge-config.vercel.com/?token=abcd'], + [ + 'url with no token', + 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', + ], + ['edge-config protocol without id', 'edge-config:token=abcd&id='], + [ + 'edge-config protocol without params', + 'edge-config:ecfg_cljia81u2q1gappdgptj881dwwtc', + ], + [ + 'edge-config protocol without token param', + 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc', + ], + ])('should return null when an invalid Connection String is given (%s)', (_, connectionString) => { + expect(pkg.parseConnectionString(connectionString)).toBeNull(); }); - it('should return the id and token when a valid internal Connection String is given', () => { - expect( - pkg.parseConnectionString( - 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc?token=00000000-0000-0000-0000-000000000000', - ), - ).toEqual({ + it.each([ + [ + 'url', + 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc?token=00000000-0000-0000-0000-000000000000', + ], + [ + 'edge-config', + 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=00000000-0000-0000-0000-000000000000', + ], + ])('should return the id and token when a valid connection string is given (%s)', (_, connectionString) => { + expect(pkg.parseConnectionString(connectionString)).toEqual({ baseUrl: 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', @@ -52,71 +57,29 @@ describe('parseConnectionString', () => { }); }); - it('should return the id and token when a valid external Connection String is given using pathname', () => { - expect( - pkg.parseConnectionString( - 'https://example.com/ecfg_cljia81u2q1gappdgptj881dwwtc?token=00000000-0000-0000-0000-000000000000', - ), - ).toEqual({ - id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', - token: '00000000-0000-0000-0000-000000000000', - version: '1', - type: 'external', - baseUrl: 'https://example.com/ecfg_cljia81u2q1gappdgptj881dwwtc', - snapshot: 'optional', - timeoutMs: undefined, - }); - }); - - it('should return the id and token when a valid external Connection String is given using search params', () => { - expect( - pkg.parseConnectionString( - 'https://example.com/?id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=00000000-0000-0000-0000-000000000000', - ), - ).toEqual({ - id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', - token: '00000000-0000-0000-0000-000000000000', - baseUrl: 'https://example.com/', - type: 'external', - version: '1', - snapshot: 'optional', - timeoutMs: undefined, - }); - }); - - it('should return a valid connection for an `edgd-config:` connection string', () => { - expect( - pkg.parseConnectionString( - 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=00000000-0000-0000-0000-000000000000', - ), - ).toEqual({ - baseUrl: - 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', - id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', - token: '00000000-0000-0000-0000-000000000000', - type: 'vercel', - version: '1', - snapshot: 'optional', - timeoutMs: undefined, + describe('option#snapshot', () => { + it.each([ + [ + 'should parse snapshot=required', + 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=token-2&snapshot=required', + ], + [ + 'should parse snapshot=optional', + 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=token-2&snapshot=required', + ], + ])('%s', (_, connectionString) => { + expect(pkg.parseConnectionString(connectionString)).toEqual({ + baseUrl: + 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', + id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', + token: 'token-2', + type: 'vercel', + version: '1', + snapshot: 'required', + timeoutMs: undefined, + }); }); }); - - it('should return null for an invalid `edge-config:` connection string', () => { - expect(pkg.parseConnectionString('edge-config:token=abd&id=')).toEqual( - null, - ); - expect( - pkg.parseConnectionString( - 'edge-config:ecfg_cljia81u2q1gappdgptj881dwwtc', - ), - ).toEqual(null); - expect( - pkg.parseConnectionString( - 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc', - ), - ).toEqual(null); - expect(pkg.parseConnectionString('edge-config:invalid')).toEqual(null); - }); }); describe('when running without lambda layer or via edge function', () => { From 4068f6f8a240e965515b75cf007dcde44380eb93 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 16 Dec 2025 12:55:34 +0200 Subject: [PATCH 72/72] step --- .changeset/famous-games-sleep.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/famous-games-sleep.md b/.changeset/famous-games-sleep.md index 01c23bbb5..677872e06 100644 --- a/.changeset/famous-games-sleep.md +++ b/.changeset/famous-games-sleep.md @@ -2,9 +2,12 @@ "@vercel/edge-config": minor --- -**NEW** Edge Config Snapshotting +**NEW** Edge Config Snapshots -You can now bundle a snapshot of your Edge Config along with your deployment. This snapshot will be used to guarantee your build consistently uses the same version of each Edge Config and as a fallback in case the Edge Config service is unavailable once your project is deployed to protect against Edge Config service degradation or unavailability. +You can now bundle a snapshot of your Edge Config along with your deployment. +- snapshot is used as a fallback in case the Edge Config service is unavailable +- snapshot is consistently used during builds, ensuring your app uses a consistent version and reducing build time +- snapshot will be used in the future to immediately bootstrap the Edge Config SDK (soon) **How it works:** - Your app continues using the latest Edge Config version under normal conditions @@ -26,7 +29,7 @@ Add the `edge-config snapshot` command to your `prebuild` script: } ``` -The snapshot command reads your environment variables and bundles all connected Edge Configs. Use `--verbose` for detailed logs. Technically this works by writing the Edge Config stores to `node_modules/@vercel/edge-config/dist/stores.json`. Note that the bundled Edge Config stores count towards your build [function bundle size limit](https://vercel.com/docs/functions/limitations#bundle-size-limits). +The snapshot command reads your environment variables and bundles all connected Edge Configs. Use `--verbose` for detailed logs. Note that the bundled Edge Config stores count towards your build [function bundle size limit](https://vercel.com/docs/functions/limitations#bundle-size-limits). You can further configure your client to throw errors in case it can not find the Edge Config snapshot by editing the connection string stored in the `EDGE_CONFIG` environment variable and appending `&snapshot=required`. You can also specify `snapshot: "required"` when creating clients using `createClient`.