From ae7d8c9a915b8cb778c13ebf4d599602d791c88f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 16 Jun 2025 10:04:41 +0300 Subject: [PATCH 01/81] prototype --- packages/edge-config/src/index.ts | 103 +++++++++++++++--- packages/edge-config/src/utils/index.ts | 2 +- .../app/vercel/edge-config/app/edge/page.tsx | 6 +- .../app/vercel/edge-config/app/node/page.tsx | 6 +- 4 files changed, 94 insertions(+), 23 deletions(-) diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index f617b064a..fa71996a1 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -6,7 +6,7 @@ import { isEmptyKey, ERRORS, UnexpectedNetworkError, - hasOwnProperty, + hasOwn, parseConnectionString, pick, } from './utils'; @@ -85,6 +85,32 @@ const getFileSystemEdgeConfig = trace( }, ); +/** + * Reads an Edge Config from the local file system using an async import. + * This is used at runtime on serverless functions. + */ +const getBuildContainerEdgeConfig = trace( + async function getFileSystemEdgeConfig( + connection: Connection, + ): Promise { + // can't optimize non-vercel hosted edge configs + if (connection.type !== 'vercel') return null; + + // the folder won't exist in development, only when deployed + if (process.env.NODE_ENV === 'development') return null; + + try { + const edgeConfig = await import(`/tmp/edge-config/${connection.id}.json`); + return edgeConfig.default as EmbeddedEdgeConfig; + } catch { + return null; + } + }, + { + name: 'getBuildContainerEdgeConfig', + }, +); + /** * Will return an embedded Edge Config object from memory, * but only when the `privateEdgeConfigSymbol` is in global scope. @@ -207,6 +233,14 @@ function addConsistentReadHeader(headers: Headers): void { headers.set('x-edge-config-min-updated-at', `${Number.MAX_SAFE_INTEGER}`); } +/** + * Check if running in Vercel build environment + */ +const isVercelBuild = + process.env.VERCEL === '1' && + process.env.CI === '1' && + !process.env.VERCEL_URL; // VERCEL_URL is only available at runtime + /** * Reads the Edge Config from a local provider, if available, * to avoid Network requests. @@ -214,14 +248,39 @@ function addConsistentReadHeader(headers: Headers): void { async function getLocalEdgeConfig( connection: Connection, options?: EdgeConfigFunctionsOptions, + getInMemoryEdgeConfig?: ( + options?: EdgeConfigFunctionsOptions, + ) => Promise, ): Promise { if (options?.consistentRead) return null; - const edgeConfig = - (await getPrivateEdgeConfig(connection)) || - (await getFileSystemEdgeConfig(connection)); + // Try using the Edge Config from the build container if we are in a build. + // This guarantees the same version of an Edge Config is used throughout the build process. + if (isVercelBuild) { + const buildContainerEdgeConfig = + await getBuildContainerEdgeConfig(connection); + if (buildContainerEdgeConfig) return buildContainerEdgeConfig; + } + + // Try using the Edge Config from the in-memory cache at runtime. + const inMemoryEdgeConfig = await getInMemoryEdgeConfig?.(options); + if (inMemoryEdgeConfig) return inMemoryEdgeConfig; + + // Fall back to the private Edge Config if we don't have one in memory. + const privateEdgeConfig = await getPrivateEdgeConfig(connection); + if (privateEdgeConfig) return privateEdgeConfig; + + // Fall back to the file system Edge Config otherwise + const fileSystemEdgeConfig = await getFileSystemEdgeConfig(connection); + if (fileSystemEdgeConfig) return fileSystemEdgeConfig; + + // Fall back to the build container Edge Config as a last resort. + // This edge config might be quite outdated, but it's better than not resolving at all. + const buildContainerEdgeConfig = + await getBuildContainerEdgeConfig(connection); + if (buildContainerEdgeConfig) return buildContainerEdgeConfig; - return edgeConfig; + return null; } /** @@ -347,9 +406,11 @@ export const createClient = trace( key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise { - const localEdgeConfig = - (await getInMemoryEdgeConfig(localOptions)) || - (await getLocalEdgeConfig(connection, localOptions)); + const localEdgeConfig = await getLocalEdgeConfig( + connection, + localOptions, + getInMemoryEdgeConfig, + ); assertIsKey(key); if (isEmptyKey(key)) return undefined; @@ -397,15 +458,17 @@ export const createClient = trace( key, localOptions?: EdgeConfigFunctionsOptions, ): Promise { - const localEdgeConfig = - (await getInMemoryEdgeConfig(localOptions)) || - (await getLocalEdgeConfig(connection, localOptions)); + const localEdgeConfig = await getLocalEdgeConfig( + connection, + localOptions, + getInMemoryEdgeConfig, + ); assertIsKey(key); if (isEmptyKey(key)) return false; if (localEdgeConfig) { - return Promise.resolve(hasOwnProperty(localEdgeConfig.items, key)); + return Promise.resolve(hasOwn(localEdgeConfig.items, key)); } const localHeaders = new Headers(headers); @@ -438,9 +501,11 @@ export const createClient = trace( keys?: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, ): Promise { - const localEdgeConfig = - (await getInMemoryEdgeConfig(localOptions)) || - (await getLocalEdgeConfig(connection, localOptions)); + const localEdgeConfig = await getLocalEdgeConfig( + connection, + localOptions, + getInMemoryEdgeConfig, + ); if (localEdgeConfig) { if (keys === undefined) { @@ -497,9 +562,11 @@ export const createClient = trace( async function digest( localOptions?: EdgeConfigFunctionsOptions, ): Promise { - const localEdgeConfig = - (await getInMemoryEdgeConfig(localOptions)) || - (await getLocalEdgeConfig(connection, localOptions)); + const localEdgeConfig = await getLocalEdgeConfig( + connection, + localOptions, + getInMemoryEdgeConfig, + ); if (localEdgeConfig) { return Promise.resolve(localEdgeConfig.digest); diff --git a/packages/edge-config/src/utils/index.ts b/packages/edge-config/src/utils/index.ts index 20e384c17..0ffad0687 100644 --- a/packages/edge-config/src/utils/index.ts +++ b/packages/edge-config/src/utils/index.ts @@ -17,7 +17,7 @@ export class UnexpectedNetworkError extends Error { /** * Checks if an object has a property */ -export function hasOwnProperty( +export function hasOwn( obj: X, prop: Y, ): obj is X & Record { diff --git a/test/next/src/app/vercel/edge-config/app/edge/page.tsx b/test/next/src/app/vercel/edge-config/app/edge/page.tsx index 4578ac77a..3a13ec255 100644 --- a/test/next/src/app/vercel/edge-config/app/edge/page.tsx +++ b/test/next/src/app/vercel/edge-config/app/edge/page.tsx @@ -5,9 +5,11 @@ export const runtime = 'edge'; export default async function Page(): Promise { const value = await get('keyForTest'); - if (value !== 'valueForTest') + const expectedValue = 'valueForTest'; + + if (value !== expectedValue) throw new Error( - "Expected Edge Config Item 'keyForTest' to have value 'valueForTest'", + `Expected Edge Config Item 'keyForTest' to have value '${expectedValue}' but got ${JSON.stringify(value)}`, ); return
{JSON.stringify(value, null, 2)}
; diff --git a/test/next/src/app/vercel/edge-config/app/node/page.tsx b/test/next/src/app/vercel/edge-config/app/node/page.tsx index 1efb68ca9..586ea7bde 100644 --- a/test/next/src/app/vercel/edge-config/app/node/page.tsx +++ b/test/next/src/app/vercel/edge-config/app/node/page.tsx @@ -5,9 +5,11 @@ export const runtime = 'nodejs'; export default async function Page(): Promise { const value = await get('keyForTest'); - if (value !== 'valueForTest') + const expectedValue = 'valueForTest'; + + if (value !== expectedValue) throw new Error( - "Expected Edge Config Item 'keyForTest' to have value 'valueForTest'", + `Expected Edge Config Item 'keyForTest' to have value '${expectedValue}' but got ${JSON.stringify(value)}`, ); return
{JSON.stringify(value, null, 2)}
; From 71346394e6cf1948b7fd4d6603fc564cc0d29d19 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 16 Jun 2025 10:28:52 +0300 Subject: [PATCH 02/81] add cli command to prepare edge configs --- packages/edge-config/package.json | 3 ++ packages/edge-config/src/cli.ts | 45 +++++++++++++++++++++++++++++ packages/edge-config/tsup.config.js | 35 +++++++++++++++------- 3 files changed, 72 insertions(+), 11 deletions(-) create mode 100755 packages/edge-config/src/cli.ts diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 2c49d962d..745cad4f7 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -17,6 +17,9 @@ }, "main": "./dist/index.cjs", "module": "./dist/index.js", + "bin": { + "edge-config": "./dist/cli.cjs" + }, "files": [ "dist" ], diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts new file mode 100755 index 000000000..d0ed33bc8 --- /dev/null +++ b/packages/edge-config/src/cli.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import { writeFile, mkdir } from 'node:fs/promises'; +import type { Connection, EmbeddedEdgeConfig } from './types'; +import { parseConnectionString } from './utils'; + +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; + }, + [], + ); + + await mkdir('/tmp/edge-config', { recursive: true }); + + await Promise.all( + connections.map(async (connection) => { + const data = await fetch(connection.baseUrl, { + headers: { + authorization: `Bearer ${connection.token}`, + // consistentRead + // 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, + }, + }).then((res) => res.json() as Promise); + + await writeFile( + `/tmp/edge-config/${connection.id}.json`, + JSON.stringify(data), + ); + }), + ); +} + +main().catch((error) => { + console.error('@vercerl/edge-config: prepare failed', error); + process.exit(1); +}); diff --git a/packages/edge-config/tsup.config.js b/packages/edge-config/tsup.config.js index 0b5a5da08..6f2a022a1 100644 --- a/packages/edge-config/tsup.config.js +++ b/packages/edge-config/tsup.config.js @@ -1,14 +1,27 @@ import { defineConfig } from 'tsup'; // eslint-disable-next-line import/no-default-export -- [@vercel/style-guide@5 migration] -export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm', 'cjs'], - splitting: true, - sourcemap: true, - minify: false, - clean: true, - skipNodeModulesBundle: true, - dts: true, - external: ['node_modules'], -}); +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + splitting: true, + sourcemap: true, + minify: false, + clean: true, + skipNodeModulesBundle: true, + dts: true, + external: ['node_modules'], + }, + { + entry: ['src/cli.ts'], + format: ['cjs'], + splitting: true, + sourcemap: true, + minify: false, + clean: true, + skipNodeModulesBundle: true, + dts: true, + external: ['node_modules'], + }, +]); From b1edc83be4292917dc6eb18728e88c63a731106e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 16 Jun 2025 11:00:21 +0300 Subject: [PATCH 03/81] finalize cli --- packages/edge-config/src/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index fa71996a1..3ac95a46e 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -90,7 +90,7 @@ const getFileSystemEdgeConfig = trace( * This is used at runtime on serverless functions. */ const getBuildContainerEdgeConfig = trace( - async function getFileSystemEdgeConfig( + async function getBuildContainerEdgeConfig( connection: Connection, ): Promise { // can't optimize non-vercel hosted edge configs @@ -100,8 +100,10 @@ const getBuildContainerEdgeConfig = trace( if (process.env.NODE_ENV === 'development') return null; try { - const edgeConfig = await import(`/tmp/edge-config/${connection.id}.json`); - return edgeConfig.default as EmbeddedEdgeConfig; + const edgeConfig = (await import( + /* webpackIgnore. true */ `/tmp/edge-config/${connection.id}.json` + )) as { default: EmbeddedEdgeConfig }; + return edgeConfig.default; } catch { return null; } From 29180a773b7ef753dc82804202da6d7319348898 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 16 Jun 2025 20:39:47 +0300 Subject: [PATCH 04/81] fix eslint --- 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 d0ed33bc8..7abf14fef 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -40,6 +40,7 @@ async function main(): Promise { } main().catch((error) => { + // eslint-disable-next-line no-console -- This is a CLI tool console.error('@vercerl/edge-config: prepare failed', error); process.exit(1); }); From d2f2c8d5ee6d0957d39afb4a5012d4520c74d6e1 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 16 Jun 2025 20:52:52 +0300 Subject: [PATCH 05/81] add changeset --- .changeset/legal-walls-relate.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/legal-walls-relate.md diff --git a/.changeset/legal-walls-relate.md b/.changeset/legal-walls-relate.md new file mode 100644 index 000000000..df2ae0c4c --- /dev/null +++ b/.changeset/legal-walls-relate.md @@ -0,0 +1,5 @@ +--- +'@vercel/edge-config': minor +--- + +use consistent edge config version during builds From 4795cd8d57253a2bb590973ac590800f97351de1 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 12 Jul 2025 22:38:44 +0200 Subject: [PATCH 06/81] enable consistentRead for build time fetching --- 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 7abf14fef..01d93419a 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -27,7 +27,7 @@ async function main(): Promise { headers: { authorization: `Bearer ${connection.token}`, // consistentRead - // 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, + 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, }, }).then((res) => res.json() as Promise); From 05a15a4de7664403663a12e274fba8ceae7d4f8a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 25 Jul 2025 14:35:09 +0300 Subject: [PATCH 07/81] wip --- packages/edge-config/src/index-copy.ts | 684 ++++++++++++++++ packages/edge-config/src/index.ts | 740 +++++++----------- packages/edge-config/src/types.ts | 62 +- .../src/utils/add-consistent-read-header.ts | 7 + .../src/utils/consume-response-body.ts | 21 + .../utils/get-build-container-edge-config.ts | 41 + packages/edge-config/tsdoc.json | 17 + 7 files changed, 1103 insertions(+), 469 deletions(-) create mode 100644 packages/edge-config/src/index-copy.ts create mode 100644 packages/edge-config/src/utils/add-consistent-read-header.ts create mode 100644 packages/edge-config/src/utils/consume-response-body.ts create mode 100644 packages/edge-config/src/utils/get-build-container-edge-config.ts create mode 100644 packages/edge-config/tsdoc.json diff --git a/packages/edge-config/src/index-copy.ts b/packages/edge-config/src/index-copy.ts new file mode 100644 index 000000000..3ac95a46e --- /dev/null +++ b/packages/edge-config/src/index-copy.ts @@ -0,0 +1,684 @@ +import { readFile } from '@vercel/edge-config-fs'; +import { name as sdkName, version as sdkVersion } from '../package.json'; +import { + assertIsKey, + assertIsKeys, + isEmptyKey, + ERRORS, + UnexpectedNetworkError, + hasOwn, + parseConnectionString, + pick, +} from './utils'; +import type { + Connection, + EdgeConfigClient, + EdgeConfigItems, + EdgeConfigValue, + EmbeddedEdgeConfig, + EdgeConfigFunctionsOptions, +} from './types'; +import { fetchWithCachedResponse } from './utils/fetch-with-cached-response'; +import { trace } from './utils/tracing'; + +export { setTracerProvider } from './utils/tracing'; + +export { + parseConnectionString, + type EdgeConfigClient, + type EdgeConfigItems, + type EdgeConfigValue, + type EmbeddedEdgeConfig, +}; + +const jsonParseCache = new Map(); + +const readFileTraced = trace(readFile, { name: 'readFile' }); +const jsonParseTraced = trace(JSON.parse, { name: 'JSON.parse' }); + +const privateEdgeConfigSymbol = Symbol.for('privateEdgeConfig'); + +const cachedJsonParseTraced = trace( + (edgeConfigId: string, content: string) => { + const cached = jsonParseCache.get(edgeConfigId); + if (cached) return cached; + + const parsed = jsonParseTraced(content) as unknown; + + // freeze the object to avoid mutations of the return value of a "get" call + // from affecting the return value of future "get" calls + jsonParseCache.set(edgeConfigId, Object.freeze(parsed)); + return parsed; + }, + { name: 'cached JSON.parse' }, +); + +/** + * Reads an Edge Config from the local file system. + * This is used at runtime on serverless functions. + */ +const getFileSystemEdgeConfig = trace( + async function getFileSystemEdgeConfig( + connection: Connection, + ): Promise { + // can't optimize non-vercel hosted edge configs + if (connection.type !== 'vercel') return null; + // can't use fs optimizations outside of lambda + if (!process.env.AWS_LAMBDA_FUNCTION_NAME) return null; + + try { + const content = await readFileTraced( + `/opt/edge-config/${connection.id}.json`, + 'utf-8', + ); + + return cachedJsonParseTraced( + connection.id, + content, + ) as EmbeddedEdgeConfig; + } catch { + return null; + } + }, + { + name: 'getFileSystemEdgeConfig', + }, +); + +/** + * Reads an Edge Config from the local file system using an async import. + * This is used at runtime on serverless functions. + */ +const getBuildContainerEdgeConfig = trace( + async function getBuildContainerEdgeConfig( + connection: Connection, + ): Promise { + // can't optimize non-vercel hosted edge configs + if (connection.type !== 'vercel') return null; + + // the folder won't exist in development, only when deployed + if (process.env.NODE_ENV === 'development') return null; + + try { + const edgeConfig = (await import( + /* webpackIgnore. true */ `/tmp/edge-config/${connection.id}.json` + )) as { default: EmbeddedEdgeConfig }; + return edgeConfig.default; + } catch { + return null; + } + }, + { + name: 'getBuildContainerEdgeConfig', + }, +); + +/** + * Will return an embedded Edge Config object from memory, + * but only when the `privateEdgeConfigSymbol` is in global scope. + */ +const getPrivateEdgeConfig = trace( + async function getPrivateEdgeConfig( + connection: Connection, + ): Promise { + const privateEdgeConfig = Reflect.get( + globalThis, + privateEdgeConfigSymbol, + ) as + | { + get: (id: string) => Promise; + } + | undefined; + + if ( + typeof privateEdgeConfig === 'object' && + typeof privateEdgeConfig.get === 'function' + ) { + return privateEdgeConfig.get(connection.id); + } + + return null; + }, + { + name: 'getPrivateEdgeConfig', + }, +); + +/** + * Returns a function to retrieve the entire Edge Config. + * It'll keep the fetched Edge Config in memory, making subsequent calls fast, + * while revalidating in the background. + */ +function createGetInMemoryEdgeConfig( + shouldUseDevelopmentCache: boolean, + connection: Connection, + headers: Record, + fetchCache: EdgeConfigClientOptions['cache'], +): ( + localOptions?: EdgeConfigFunctionsOptions, +) => Promise { + // Functions as cache to keep track of the Edge Config. + let embeddedEdgeConfigPromise: Promise | null = + null; + + // Promise that points to the most recent request. + // It'll ensure that subsequent calls won't make another fetch call, + // while one is still on-going. + // Will overwrite `embeddedEdgeConfigPromise` only when resolved. + let latestRequest: Promise | null = null; + + return trace( + (localOptions) => { + if (localOptions?.consistentRead || !shouldUseDevelopmentCache) + return Promise.resolve(null); + + if (!latestRequest) { + latestRequest = fetchWithCachedResponse( + `${connection.baseUrl}/items?version=${connection.version}`, + { + headers: new Headers(headers), + cache: fetchCache, + }, + ).then(async (res) => { + const digest = res.headers.get('x-edge-config-digest'); + let body: EdgeConfigValue | undefined; + + // We ignore all errors here and just proceed. + if (!res.ok) { + await consumeResponseBody(res); + body = res.cachedResponseBody as EdgeConfigValue | undefined; + if (!body) return null; + } else { + body = (await res.json()) as EdgeConfigItems; + } + + return { digest, items: body } as EmbeddedEdgeConfig; + }); + + // Once the request is resolved, we set the proper config to the promise + // such that the next call will return the resolved value. + latestRequest.then( + (resolved) => { + embeddedEdgeConfigPromise = Promise.resolve(resolved); + latestRequest = null; + }, + // Attach a `.catch` handler to this promise so that if it does throw, + // we don't get an unhandled promise rejection event. We unset the + // `latestRequest` so that the next call will make a new request. + () => { + embeddedEdgeConfigPromise = null; + latestRequest = null; + }, + ); + } + + if (!embeddedEdgeConfigPromise) { + // If the `embeddedEdgeConfigPromise` is `null`, it means that there's + // no previous request, so we'll set the `latestRequest` to the current + // request. + embeddedEdgeConfigPromise = latestRequest; + } + + return embeddedEdgeConfigPromise; + }, + { + name: 'getInMemoryEdgeConfig', + }, + ); +} + +/** + * Uses `MAX_SAFE_INTEGER` as minimum updated at timestamp to force + * a request to the origin. + */ +function addConsistentReadHeader(headers: Headers): void { + headers.set('x-edge-config-min-updated-at', `${Number.MAX_SAFE_INTEGER}`); +} + +/** + * Check if running in Vercel build environment + */ +const isVercelBuild = + process.env.VERCEL === '1' && + process.env.CI === '1' && + !process.env.VERCEL_URL; // VERCEL_URL is only available at runtime + +/** + * Reads the Edge Config from a local provider, if available, + * to avoid Network requests. + */ +async function getLocalEdgeConfig( + connection: Connection, + options?: EdgeConfigFunctionsOptions, + getInMemoryEdgeConfig?: ( + options?: EdgeConfigFunctionsOptions, + ) => Promise, +): Promise { + if (options?.consistentRead) return null; + + // Try using the Edge Config from the build container if we are in a build. + // This guarantees the same version of an Edge Config is used throughout the build process. + if (isVercelBuild) { + const buildContainerEdgeConfig = + await getBuildContainerEdgeConfig(connection); + if (buildContainerEdgeConfig) return buildContainerEdgeConfig; + } + + // Try using the Edge Config from the in-memory cache at runtime. + const inMemoryEdgeConfig = await getInMemoryEdgeConfig?.(options); + if (inMemoryEdgeConfig) return inMemoryEdgeConfig; + + // Fall back to the private Edge Config if we don't have one in memory. + const privateEdgeConfig = await getPrivateEdgeConfig(connection); + if (privateEdgeConfig) return privateEdgeConfig; + + // Fall back to the file system Edge Config otherwise + const fileSystemEdgeConfig = await getFileSystemEdgeConfig(connection); + if (fileSystemEdgeConfig) return fileSystemEdgeConfig; + + // Fall back to the build container Edge Config as a last resort. + // This edge config might be quite outdated, but it's better than not resolving at all. + const buildContainerEdgeConfig = + await getBuildContainerEdgeConfig(connection); + if (buildContainerEdgeConfig) return buildContainerEdgeConfig; + + return null; +} + +/** + * This function reads the respone body + * + * Reading the response body serves two purposes + * + * 1) In Node.js it avoids memory leaks + * + * See https://github.com/nodejs/undici/blob/v5.21.2/README.md#garbage-collection + * See https://github.com/node-fetch/node-fetch/issues/83 + * + * 2) In Cloudflare it avoids running into a deadlock. They have a maximum number + * of concurrent fetches (which is documented). Concurrency counts until the + * body of a response is read. It is not uncommon to never read a response body + * (e.g. if you only care about the status code). This can lead to deadlock as + * fetches appear to never resolve. + * + * See https://developers.cloudflare.com/workers/platform/limits/#simultaneous-open-connections + */ +async function consumeResponseBody(res: Response): Promise { + await res.arrayBuffer(); +} + +interface EdgeConfigClientOptions { + /** + * The stale-if-error response directive indicates that the cache can reuse a + * stale response when an upstream server generates an error, or when the error + * is generated locally - for example due to a connection error. + * + * Any response with a status code of 500, 502, 503, or 504 is considered an error. + * + * Pass a negative number, 0, or false to turn disable stale-if-error semantics. + * + * The time is supplied in seconds. Defaults to one week (`604800`). + */ + staleIfError?: number | false; + /** + * In development, a stale-while-revalidate cache is employed as the default caching strategy. + * + * This cache aims to deliver speedy Edge Config reads during development, though it comes + * at the cost of delayed visibility for updates to Edge Config. Typically, you may need to + * refresh twice to observe these changes as the stale value is replaced. + * + * This cache is not used in preview or production deployments as superior optimisations are applied there. + */ + disableDevelopmentCache?: boolean; + + /** + * Sets a `cache` option on the `fetch` call made by Edge Config. + * + * Unlike Next.js, this defaults to `no-store`, as you most likely want to use Edge Config dynamically. + */ + cache?: 'no-store' | 'force-cache'; +} + +/** + * Create an Edge Config client. + * + * The client has multiple methods which allow you to read the Edge Config. + * + * If you need to programmatically write to an Edge Config, check out the [Update your Edge Config items](https://vercel.com/docs/storage/edge-config/vercel-api#update-your-edge-config-items) section. + * + * @param connectionString - A connection string. Usually you'd pass in `process.env.EDGE_CONFIG` here, which contains a connection string. + * @returns An Edge Config Client instance + */ +export const createClient = trace( + function createClient( + connectionString: string | undefined, + options: EdgeConfigClientOptions = { + staleIfError: 604800 /* one week */, + cache: 'no-store', + }, + ): EdgeConfigClient { + if (!connectionString) + throw new Error('@vercel/edge-config: No connection string provided'); + + const connection = parseConnectionString(connectionString); + + if (!connection) + throw new Error( + '@vercel/edge-config: Invalid connection string provided', + ); + + const edgeConfigId = connection.id; + const baseUrl = connection.baseUrl; + const version = connection.version; // version of the edge config read access api we talk to + const headers: Record = { + Authorization: `Bearer ${connection.token}`, + }; + + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- [@vercel/style-guide@5 migration] + if (typeof process !== 'undefined' && process.env.VERCEL_ENV) + headers['x-edge-config-vercel-env'] = process.env.VERCEL_ENV; + + if (typeof sdkName === 'string' && typeof sdkVersion === 'string') + headers['x-edge-config-sdk'] = `${sdkName}@${sdkVersion}`; + + if (typeof options.staleIfError === 'number' && options.staleIfError > 0) + headers['cache-control'] = `stale-if-error=${options.staleIfError}`; + + const fetchCache = options.cache || 'no-store'; + + /** + * While in development we use SWR-like behavior for the api client to + * reduce latency. + */ + const shouldUseDevelopmentCache = + !options.disableDevelopmentCache && + process.env.NODE_ENV === 'development' && + process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; + + const getInMemoryEdgeConfig = createGetInMemoryEdgeConfig( + shouldUseDevelopmentCache, + connection, + headers, + fetchCache, + ); + + const api: Omit = { + get: trace( + async function get( + key: string, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise { + const localEdgeConfig = await getLocalEdgeConfig( + connection, + localOptions, + getInMemoryEdgeConfig, + ); + + assertIsKey(key); + if (isEmptyKey(key)) return undefined; + + if (localEdgeConfig) { + // 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); + } + + const localHeaders = new Headers(headers); + if (localOptions?.consistentRead) + addConsistentReadHeader(localHeaders); + + return fetchWithCachedResponse( + `${baseUrl}/item/${key}?version=${version}`, + { + headers: localHeaders, + cache: fetchCache, + }, + ).then(async (res) => { + if (res.ok) return res.json(); + await consumeResponseBody(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 undefined; + // 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.cachedResponseBody !== undefined) + return res.cachedResponseBody as T; + throw new UnexpectedNetworkError(res); + }); + }, + { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, + ), + has: trace( + async function has( + key, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise { + const localEdgeConfig = await getLocalEdgeConfig( + connection, + localOptions, + getInMemoryEdgeConfig, + ); + + assertIsKey(key); + if (isEmptyKey(key)) return false; + + if (localEdgeConfig) { + return Promise.resolve(hasOwn(localEdgeConfig.items, key)); + } + + const localHeaders = new Headers(headers); + if (localOptions?.consistentRead) + addConsistentReadHeader(localHeaders); + + // this is a HEAD request anyhow, no need for fetchWithCachedResponse + return fetch(`${baseUrl}/item/${key}?version=${version}`, { + method: 'HEAD', + headers: localHeaders, + cache: fetchCache, + }).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); + }); + }, + { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, + ), + getAll: trace( + async function getAll( + keys?: (keyof T)[], + localOptions?: EdgeConfigFunctionsOptions, + ): Promise { + const localEdgeConfig = await getLocalEdgeConfig( + connection, + localOptions, + getInMemoryEdgeConfig, + ); + + if (localEdgeConfig) { + if (keys === undefined) { + return Promise.resolve(localEdgeConfig.items as T); + } + + assertIsKeys(keys); + return Promise.resolve(pick(localEdgeConfig.items, keys) as T); + } + + if (Array.isArray(keys)) assertIsKeys(keys); + + const search = Array.isArray(keys) + ? new URLSearchParams( + keys + .filter((key) => typeof key === 'string' && !isEmptyKey(key)) + .map((key) => ['key', key] as [string, string]), + ).toString() + : null; + + // empty search keys array was given, + // so skip the request and return an empty object + if (search === '') return Promise.resolve({} as T); + + const localHeaders = new Headers(headers); + if (localOptions?.consistentRead) + addConsistentReadHeader(localHeaders); + + return fetchWithCachedResponse( + `${baseUrl}/items?version=${version}${ + search === null ? '' : `&${search}` + }`, + { + headers: localHeaders, + cache: fetchCache, + }, + ).then(async (res) => { + if (res.ok) return res.json(); + await consumeResponseBody(res); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + // the /items endpoint never returns 404, so if we get a 404 + // it means the edge config itself did not exist + if (res.status === 404) + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + if (res.cachedResponseBody !== undefined) + return res.cachedResponseBody as T; + throw new UnexpectedNetworkError(res); + }); + }, + { name: 'getAll', isVerboseTrace: false, attributes: { edgeConfigId } }, + ), + digest: trace( + async function digest( + localOptions?: EdgeConfigFunctionsOptions, + ): Promise { + const localEdgeConfig = await getLocalEdgeConfig( + connection, + localOptions, + getInMemoryEdgeConfig, + ); + + if (localEdgeConfig) { + return Promise.resolve(localEdgeConfig.digest); + } + + const localHeaders = new Headers(headers); + if (localOptions?.consistentRead) + addConsistentReadHeader(localHeaders); + + return fetchWithCachedResponse( + `${baseUrl}/digest?version=${version}`, + { + headers: localHeaders, + cache: fetchCache, + }, + ).then(async (res) => { + if (res.ok) return res.json() as Promise; + await consumeResponseBody(res); + + if (res.cachedResponseBody !== undefined) + return res.cachedResponseBody as string; + throw new UnexpectedNetworkError(res); + }); + }, + { name: 'digest', isVerboseTrace: false, attributes: { edgeConfigId } }, + ), + }; + + return { ...api, connection }; + }, + { + name: 'createClient', + }, +); + +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 { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- [@vercel/style-guide@5 migration] + if (!defaultEdgeConfigClient) { + defaultEdgeConfigClient = createClient(process.env.EDGE_CONFIG); + } +} + +/** + * Reads a single item from the default Edge Config. + * + * This is a convenience method which reads the default Edge Config. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).get()`. + * + * @see {@link EdgeConfigClient.get} + * @param key - the key to read + * @returns the value stored under the given key, or undefined + */ +export const get: EdgeConfigClient['get'] = (...args) => { + init(); + return defaultEdgeConfigClient.get(...args); +}; + +/** + * Reads multiple or all values. + * + * This is a convenience method which reads the default Edge Config. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getAll()`. + * + * @see {@link EdgeConfigClient.getAll} + * @param keys - the keys to read + * @returns the value stored under the given key, or undefined + */ +export const getAll: EdgeConfigClient['getAll'] = (...args) => { + init(); + return defaultEdgeConfigClient.getAll(...args); +}; + +/** + * Check if a given key exists in the Edge Config. + * + * This is a convenience method which reads the default Edge Config. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).has()`. + * + * @see {@link EdgeConfigClient.has} + * @param key - the key to check + * @returns true if the given key exists in the Edge Config. + */ +export const has: EdgeConfigClient['has'] = (...args) => { + init(); + return defaultEdgeConfigClient.has(...args); +}; + +/** + * Get the digest of the Edge Config. + * + * This is a convenience method which reads the default Edge Config. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).digest()`. + * + * @see {@link EdgeConfigClient.digest} + * @returns The digest of the Edge Config. + */ +export const digest: EdgeConfigClient['digest'] = (...args) => { + init(); + return defaultEdgeConfigClient.digest(...args); +}; + +/** + * Safely clones a read-only Edge Config object and makes it mutable. + */ +export function clone(edgeConfigValue: T): T { + // Use JSON.parse and JSON.stringify instead of anything else due to + // the value possibly being a Proxy object. + return JSON.parse(JSON.stringify(edgeConfigValue)) as T; +} diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 3ac95a46e..a3a6d829c 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -1,25 +1,33 @@ -import { readFile } from '@vercel/edge-config-fs'; +// use build time cache if present +// +// at runtime, bootstrap initial state over network +// +// fresh → reuse +// stale (recent) → reuse, refresh in bg +// stale (old) → blocking fetch +// +// treat it all as per-key caches + import { name as sdkName, version as sdkVersion } from '../package.json'; import { assertIsKey, - assertIsKeys, isEmptyKey, ERRORS, UnexpectedNetworkError, - hasOwn, parseConnectionString, - pick, } from './utils'; import type { - Connection, EdgeConfigClient, EdgeConfigItems, EdgeConfigValue, EmbeddedEdgeConfig, EdgeConfigFunctionsOptions, + Connection, } from './types'; import { fetchWithCachedResponse } from './utils/fetch-with-cached-response'; import { trace } from './utils/tracing'; +import { consumeResponseBody } from './utils/consume-response-body'; +import { addConsistentReadHeader } from './utils/add-consistent-read-header'; export { setTracerProvider } from './utils/tracing'; @@ -31,295 +39,42 @@ export { type EmbeddedEdgeConfig, }; -const jsonParseCache = new Map(); - -const readFileTraced = trace(readFile, { name: 'readFile' }); -const jsonParseTraced = trace(JSON.parse, { name: 'JSON.parse' }); - -const privateEdgeConfigSymbol = Symbol.for('privateEdgeConfig'); - -const cachedJsonParseTraced = trace( - (edgeConfigId: string, content: string) => { - const cached = jsonParseCache.get(edgeConfigId); - if (cached) return cached; - - const parsed = jsonParseTraced(content) as unknown; - - // freeze the object to avoid mutations of the return value of a "get" call - // from affecting the return value of future "get" calls - jsonParseCache.set(edgeConfigId, Object.freeze(parsed)); - return parsed; - }, - { name: 'cached JSON.parse' }, -); - -/** - * Reads an Edge Config from the local file system. - * This is used at runtime on serverless functions. - */ -const getFileSystemEdgeConfig = trace( - async function getFileSystemEdgeConfig( - connection: Connection, - ): Promise { - // can't optimize non-vercel hosted edge configs - if (connection.type !== 'vercel') return null; - // can't use fs optimizations outside of lambda - if (!process.env.AWS_LAMBDA_FUNCTION_NAME) return null; - - try { - const content = await readFileTraced( - `/opt/edge-config/${connection.id}.json`, - 'utf-8', - ); - - return cachedJsonParseTraced( - connection.id, - content, - ) as EmbeddedEdgeConfig; - } catch { - return null; - } - }, - { - name: 'getFileSystemEdgeConfig', - }, -); - -/** - * Reads an Edge Config from the local file system using an async import. - * This is used at runtime on serverless functions. - */ -const getBuildContainerEdgeConfig = trace( - async function getBuildContainerEdgeConfig( - connection: Connection, - ): Promise { - // can't optimize non-vercel hosted edge configs - if (connection.type !== 'vercel') return null; - - // the folder won't exist in development, only when deployed - if (process.env.NODE_ENV === 'development') return null; - - try { - const edgeConfig = (await import( - /* webpackIgnore. true */ `/tmp/edge-config/${connection.id}.json` - )) as { default: EmbeddedEdgeConfig }; - return edgeConfig.default; - } catch { - return null; - } - }, - { - name: 'getBuildContainerEdgeConfig', - }, -); - -/** - * Will return an embedded Edge Config object from memory, - * but only when the `privateEdgeConfigSymbol` is in global scope. - */ -const getPrivateEdgeConfig = trace( - async function getPrivateEdgeConfig( - connection: Connection, - ): Promise { - const privateEdgeConfig = Reflect.get( - globalThis, - privateEdgeConfigSymbol, - ) as - | { - get: (id: string) => Promise; - } - | undefined; - - if ( - typeof privateEdgeConfig === 'object' && - typeof privateEdgeConfig.get === 'function' - ) { - return privateEdgeConfig.get(connection.id); - } - - return null; - }, - { - name: 'getPrivateEdgeConfig', - }, -); - -/** - * Returns a function to retrieve the entire Edge Config. - * It'll keep the fetched Edge Config in memory, making subsequent calls fast, - * while revalidating in the background. - */ -function createGetInMemoryEdgeConfig( - shouldUseDevelopmentCache: boolean, - connection: Connection, - headers: Record, - fetchCache: EdgeConfigClientOptions['cache'], -): ( - localOptions?: EdgeConfigFunctionsOptions, -) => Promise { - // Functions as cache to keep track of the Edge Config. - let embeddedEdgeConfigPromise: Promise | null = - null; - - // Promise that points to the most recent request. - // It'll ensure that subsequent calls won't make another fetch call, - // while one is still on-going. - // Will overwrite `embeddedEdgeConfigPromise` only when resolved. - let latestRequest: Promise | null = null; - - return trace( - (localOptions) => { - if (localOptions?.consistentRead || !shouldUseDevelopmentCache) - return Promise.resolve(null); - - if (!latestRequest) { - latestRequest = fetchWithCachedResponse( - `${connection.baseUrl}/items?version=${connection.version}`, - { - headers: new Headers(headers), - cache: fetchCache, - }, - ).then(async (res) => { - const digest = res.headers.get('x-edge-config-digest'); - let body: EdgeConfigValue | undefined; - - // We ignore all errors here and just proceed. - if (!res.ok) { - await consumeResponseBody(res); - body = res.cachedResponseBody as EdgeConfigValue | undefined; - if (!body) return null; - } else { - body = (await res.json()) as EdgeConfigItems; - } - - return { digest, items: body } as EmbeddedEdgeConfig; - }); - - // Once the request is resolved, we set the proper config to the promise - // such that the next call will return the resolved value. - latestRequest.then( - (resolved) => { - embeddedEdgeConfigPromise = Promise.resolve(resolved); - latestRequest = null; - }, - // Attach a `.catch` handler to this promise so that if it does throw, - // we don't get an unhandled promise rejection event. We unset the - // `latestRequest` so that the next call will make a new request. - () => { - embeddedEdgeConfigPromise = null; - latestRequest = null; - }, - ); - } - - if (!embeddedEdgeConfigPromise) { - // If the `embeddedEdgeConfigPromise` is `null`, it means that there's - // no previous request, so we'll set the `latestRequest` to the current - // request. - embeddedEdgeConfigPromise = latestRequest; - } - - return embeddedEdgeConfigPromise; - }, - { - name: 'getInMemoryEdgeConfig', - }, - ); -} - -/** - * Uses `MAX_SAFE_INTEGER` as minimum updated at timestamp to force - * a request to the origin. - */ -function addConsistentReadHeader(headers: Headers): void { - headers.set('x-edge-config-min-updated-at', `${Number.MAX_SAFE_INTEGER}`); -} - -/** - * Check if running in Vercel build environment - */ -const isVercelBuild = - process.env.VERCEL === '1' && - process.env.CI === '1' && - !process.env.VERCEL_URL; // VERCEL_URL is only available at runtime - -/** - * Reads the Edge Config from a local provider, if available, - * to avoid Network requests. - */ -async function getLocalEdgeConfig( - connection: Connection, - options?: EdgeConfigFunctionsOptions, - getInMemoryEdgeConfig?: ( - options?: EdgeConfigFunctionsOptions, - ) => Promise, -): Promise { - if (options?.consistentRead) return null; - - // Try using the Edge Config from the build container if we are in a build. - // This guarantees the same version of an Edge Config is used throughout the build process. - if (isVercelBuild) { - const buildContainerEdgeConfig = - await getBuildContainerEdgeConfig(connection); - if (buildContainerEdgeConfig) return buildContainerEdgeConfig; - } - - // Try using the Edge Config from the in-memory cache at runtime. - const inMemoryEdgeConfig = await getInMemoryEdgeConfig?.(options); - if (inMemoryEdgeConfig) return inMemoryEdgeConfig; - - // Fall back to the private Edge Config if we don't have one in memory. - const privateEdgeConfig = await getPrivateEdgeConfig(connection); - if (privateEdgeConfig) return privateEdgeConfig; - - // Fall back to the file system Edge Config otherwise - const fileSystemEdgeConfig = await getFileSystemEdgeConfig(connection); - if (fileSystemEdgeConfig) return fileSystemEdgeConfig; - - // Fall back to the build container Edge Config as a last resort. - // This edge config might be quite outdated, but it's better than not resolving at all. - const buildContainerEdgeConfig = - await getBuildContainerEdgeConfig(connection); - if (buildContainerEdgeConfig) return buildContainerEdgeConfig; - - return null; -} - -/** - * This function reads the respone body - * - * Reading the response body serves two purposes - * - * 1) In Node.js it avoids memory leaks - * - * See https://github.com/nodejs/undici/blob/v5.21.2/README.md#garbage-collection - * See https://github.com/node-fetch/node-fetch/issues/83 - * - * 2) In Cloudflare it avoids running into a deadlock. They have a maximum number - * of concurrent fetches (which is documented). Concurrency counts until the - * body of a response is read. It is not uncommon to never read a response body - * (e.g. if you only care about the status code). This can lead to deadlock as - * fetches appear to never resolve. - * - * See https://developers.cloudflare.com/workers/platform/limits/#simultaneous-open-connections - */ -async function consumeResponseBody(res: Response): Promise { - await res.arrayBuffer(); -} - interface EdgeConfigClientOptions { /** - * The stale-if-error response directive indicates that the cache can reuse a - * stale response when an upstream server generates an error, or when the error - * is generated locally - for example due to a connection error. + * Configure for how long the SDK will return a stale value in case a fresh value could not be fetched. + * + * @default Infinity + */ + staleIfError?: number | false; + + /** + * Configure the threshold for how long the SDK allows stale values to be + * served after they become outdated. The SDK will switch from refreshing + * in the background to performing a blocking fetch when this threshold is + * exceeded. + * + * The threshold configures the difference, in seconds, between when an update + * was made until the SDK will force fetch the latest value. + * + * Background refresh example: + * If you set this value to 10 seconds, then reads within 10 + * seconds after an update was made will be served from the in-memory cache, + * while a background refresh will be performed. Once the background refresh + * completes any further reads will be served from the updated in-memory cache, + * and thus also return the latest value. * - * Any response with a status code of 500, 502, 503, or 504 is considered an error. + * Blocking read example: + * If an Edge Config is updated and there are no reads in the 10 seconds after + * the update was made then there will be no background refresh. When the next + * read happens more than 10 seconds later it will be a blocking read which + * reads from the origin. This takes slightly longer but guarantees that the + * SDK will never serve a value that is stale for more than 10 seconds. * - * Pass a negative number, 0, or false to turn disable stale-if-error semantics. * - * The time is supplied in seconds. Defaults to one week (`604800`). + * @default 10 */ - staleIfError?: number | false; + staleThreshold?: number; + /** * In development, a stale-while-revalidate cache is employed as the default caching strategy. * @@ -339,6 +94,206 @@ interface EdgeConfigClientOptions { cache?: 'no-store' | 'force-cache'; } +class Controller { + private edgeConfig: EmbeddedEdgeConfig | null = null; + private connection: Connection; + private status: 'pristine' | 'refreshing' | 'stale' = 'pristine'; + private options: EdgeConfigClientOptions; + private shouldUseDevelopmentCache: boolean; + + private pendingEdgeConfigPromise: Promise | null = + null; + + constructor( + connection: Connection, + options: EdgeConfigClientOptions, + shouldUseDevelopmentCache: boolean, + ) { + this.connection = connection; + this.options = options; + this.shouldUseDevelopmentCache = shouldUseDevelopmentCache; + } + + public async get( + key: string, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ value: T | undefined; digest: string }> { + return fetchWithCachedResponse( + `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions), + cache: this.getFetchCache(), + }, + ).then<{ value: T | undefined; digest: string }>(async (res) => { + const digest = res.headers.get('x-edge-config-digest'); + + if (!digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + + if (res.ok) { + const value = (await res.json()) as T; + return { value, digest }; + } + + await consumeResponseBody(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 { value: undefined, digest }; + // 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.cachedResponseBody !== undefined) + // return res.cachedResponseBody as T; + throw new UnexpectedNetworkError(res); + }); + } + + public async has( + key: string, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ exists: boolean; digest: string }> { + return fetch( + `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, + { + method: 'HEAD', + headers: this.getHeaders(localOptions), + cache: this.getFetchCache(), + }, + ).then<{ exists: boolean; digest: string }>((res) => { + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + const digest = res.headers.get('x-edge-config-digest'); + + if (!digest) { + // 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 { digest, exists: res.status !== 404 }; + throw new UnexpectedNetworkError(res); + }); + } + + public async digest( + localOptions?: Pick, + ): Promise { + return fetchWithCachedResponse( + `${this.connection.baseUrl}/digest?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions), + cache: this.getFetchCache(), + }, + ).then(async (res) => { + if (res.ok) return res.json() as Promise; + await consumeResponseBody(res); + + // if (res.cachedResponseBody !== undefined) + // return res.cachedResponseBody as string; + throw new UnexpectedNetworkError(res); + }); + } + + public async getMultiple( + keys: (keyof T)[], + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ value: T; digest: string }> { + if (!Array.isArray(keys)) { + throw new Error('@vercel/edge-config: keys must be an array'); + } + + // Return early if there are no keys to be read. + // This is only possible if the digest is not required, or if we have a + // cached digest (not implemented yet). + if (!localOptions?.metadata && keys.length === 0) { + return { value: {} as T, digest: '' }; + } + + const search = new URLSearchParams( + keys + .filter((key) => typeof key === 'string' && !isEmptyKey(key)) + .map((key) => ['key', key] as [string, string]), + ).toString(); + + return fetchWithCachedResponse( + `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, + { + headers: this.getHeaders(localOptions), + cache: this.getFetchCache(), + }, + ).then<{ value: T; digest: string }>(async (res) => { + if (res.ok) { + const digest = res.headers.get('x-edge-config-digest'); + if (!digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + const value = (await res.json()) as T; + return { value, digest }; + } + await consumeResponseBody(res); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + // the /items endpoint never returns 404, so if we get a 404 + // it means the edge config itself did not exist + if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + // if (res.cachedResponseBody !== undefined) + // return res.cachedResponseBody as T; + throw new UnexpectedNetworkError(res); + }); + } + + public async getAll( + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ value: T; digest: string }> { + return fetchWithCachedResponse( + `${this.connection.baseUrl}/items?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions), + cache: this.getFetchCache(), + }, + ).then<{ value: T; digest: string }>(async (res) => { + if (res.ok) { + const digest = res.headers.get('x-edge-config-digest'); + if (!digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + const value = (await res.json()) as T; + return { value, digest }; + } + await consumeResponseBody(res); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + // the /items endpoint never returns 404, so if we get a 404 + // it means the edge config itself did not exist + if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + // if (res.cachedResponseBody !== undefined) + // return res.cachedResponseBody as T; + throw new UnexpectedNetworkError(res); + }); + } + + private getFetchCache(): 'no-store' | 'force-cache' { + return this.options.cache || 'no-store'; + } + + private getHeaders( + localOptions: EdgeConfigFunctionsOptions | undefined, + ): Headers { + const headers: Record = { + Authorization: `Bearer ${this.connection.token}`, + }; + const localHeaders = new Headers(headers); + if (localOptions?.consistentRead) addConsistentReadHeader(localHeaders); + + return localHeaders; + } +} + /** * Create an Edge Config client. * @@ -354,6 +309,7 @@ export const createClient = trace( connectionString: string | undefined, options: EdgeConfigClientOptions = { staleIfError: 604800 /* one week */, + staleThreshold: 60 /* 1 minute */, cache: 'no-store', }, ): EdgeConfigClient { @@ -367,7 +323,6 @@ export const createClient = trace( '@vercel/edge-config: Invalid connection string provided', ); - const edgeConfigId = connection.id; const baseUrl = connection.baseUrl; const version = connection.version; // version of the edge config read access api we talk to const headers: Record = { @@ -395,168 +350,64 @@ export const createClient = trace( process.env.NODE_ENV === 'development' && process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; - const getInMemoryEdgeConfig = createGetInMemoryEdgeConfig( - shouldUseDevelopmentCache, + const controller = new Controller( connection, - headers, - fetchCache, + options, + shouldUseDevelopmentCache, ); - const api: Omit = { + const edgeConfigId = connection.id; + + const methods: Pick< + EdgeConfigClient, + 'get' | 'has' | 'getMultiple' | 'getAll' | 'digest' + > = { get: trace( async function get( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = await getLocalEdgeConfig( - connection, - localOptions, - getInMemoryEdgeConfig, - ); - + ): Promise { assertIsKey(key); - if (isEmptyKey(key)) return undefined; - - if (localEdgeConfig) { - // 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); + if (isEmptyKey(key)) { + throw new Error('@vercel/edge-config: Can not read empty key'); } - - const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) - addConsistentReadHeader(localHeaders); - - return fetchWithCachedResponse( - `${baseUrl}/item/${key}?version=${version}`, - { - headers: localHeaders, - cache: fetchCache, - }, - ).then(async (res) => { - if (res.ok) return res.json(); - await consumeResponseBody(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 undefined; - // 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.cachedResponseBody !== undefined) - return res.cachedResponseBody as T; - throw new UnexpectedNetworkError(res); - }); + const data = await controller.get(key, localOptions); + return localOptions?.metadata ? data : data.value; }, { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, ), has: trace( - async function has( - key, - localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = await getLocalEdgeConfig( - connection, - localOptions, - getInMemoryEdgeConfig, - ); - + async function has(key, localOptions?: EdgeConfigFunctionsOptions) { assertIsKey(key); - if (isEmptyKey(key)) return false; - - if (localEdgeConfig) { - return Promise.resolve(hasOwn(localEdgeConfig.items, key)); + if (isEmptyKey(key)) { + throw new Error('@vercel/edge-config: Can not read empty key'); } - const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) - addConsistentReadHeader(localHeaders); - - // this is a HEAD request anyhow, no need for fetchWithCachedResponse - return fetch(`${baseUrl}/item/${key}?version=${version}`, { - method: 'HEAD', - headers: localHeaders, - cache: fetchCache, - }).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); - }); + const data = await controller.has(key, localOptions); + return localOptions?.metadata ? data : data.exists; }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, ), + getMultiple: trace( + async function getMultiple( + keys: (keyof T)[], + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ value: T; digest: string } | T> { + const data = await controller.getMultiple(keys, localOptions); + return localOptions?.metadata ? data : data.value; + }, + { + name: 'getMultiple', + isVerboseTrace: false, + attributes: { edgeConfigId }, + }, + ), getAll: trace( async function getAll( - keys?: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = await getLocalEdgeConfig( - connection, - localOptions, - getInMemoryEdgeConfig, - ); - - if (localEdgeConfig) { - if (keys === undefined) { - return Promise.resolve(localEdgeConfig.items as T); - } - - assertIsKeys(keys); - return Promise.resolve(pick(localEdgeConfig.items, keys) as T); - } - - if (Array.isArray(keys)) assertIsKeys(keys); - - const search = Array.isArray(keys) - ? new URLSearchParams( - keys - .filter((key) => typeof key === 'string' && !isEmptyKey(key)) - .map((key) => ['key', key] as [string, string]), - ).toString() - : null; - - // empty search keys array was given, - // so skip the request and return an empty object - if (search === '') return Promise.resolve({} as T); - - const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) - addConsistentReadHeader(localHeaders); - - return fetchWithCachedResponse( - `${baseUrl}/items?version=${version}${ - search === null ? '' : `&${search}` - }`, - { - headers: localHeaders, - cache: fetchCache, - }, - ).then(async (res) => { - if (res.ok) return res.json(); - await consumeResponseBody(res); - - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - // the /items endpoint never returns 404, so if we get a 404 - // it means the edge config itself did not exist - if (res.status === 404) - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - if (res.cachedResponseBody !== undefined) - return res.cachedResponseBody as T; - throw new UnexpectedNetworkError(res); - }); + ): Promise<{ value: T; digest: string } | T> { + const data = await controller.getAll(localOptions); + return localOptions?.metadata ? data : data.value; }, { name: 'getAll', isVerboseTrace: false, attributes: { edgeConfigId } }, ), @@ -564,16 +415,6 @@ export const createClient = trace( async function digest( localOptions?: EdgeConfigFunctionsOptions, ): Promise { - const localEdgeConfig = await getLocalEdgeConfig( - connection, - localOptions, - getInMemoryEdgeConfig, - ); - - if (localEdgeConfig) { - return Promise.resolve(localEdgeConfig.digest); - } - const localHeaders = new Headers(headers); if (localOptions?.consistentRead) addConsistentReadHeader(localHeaders); @@ -588,8 +429,8 @@ export const createClient = trace( if (res.ok) return res.json() as Promise; await consumeResponseBody(res); - if (res.cachedResponseBody !== undefined) - return res.cachedResponseBody as string; + // if (res.cachedResponseBody !== undefined) + // return res.cachedResponseBody as string; throw new UnexpectedNetworkError(res); }); }, @@ -597,7 +438,7 @@ export const createClient = trace( ), }; - return { ...api, connection }; + return { ...methods, connection }; }, { name: 'createClient', @@ -630,21 +471,6 @@ export const get: EdgeConfigClient['get'] = (...args) => { return defaultEdgeConfigClient.get(...args); }; -/** - * Reads multiple or all values. - * - * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getAll()`. - * - * @see {@link EdgeConfigClient.getAll} - * @param keys - the keys to read - * @returns the value stored under the given key, or undefined - */ -export const getAll: EdgeConfigClient['getAll'] = (...args) => { - init(); - return defaultEdgeConfigClient.getAll(...args); -}; - /** * Check if a given key exists in the Edge Config. * diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index d6ce01c1f..6356aeec0 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -38,29 +38,62 @@ export interface EdgeConfigClient { * @param key - the key to read * @returns the value stored under the given key, or undefined */ - get: ( - key: string, - options?: EdgeConfigFunctionsOptions, - ) => Promise; + get: { + ( + key: string, + options: EdgeConfigFunctionsOptions & { metadata: true }, + ): Promise<{ value: T | undefined; digest: string }>; + ( + key: string, + options?: EdgeConfigFunctionsOptions, + ): Promise; + }; /** - * Reads multiple or all values. + * Reads multiple values. * - * Allows you to read all or only selected keys of an Edge Config at once. + * Allows you to read multiple keys of an Edge Config at once. * * @param keys - the keys to read - * @returns Returns all entries when called with no arguments or only entries matching the given keys otherwise. + * @returns Returns entries matching the given keys. */ - getAll: ( - keys?: (keyof T)[], - options?: EdgeConfigFunctionsOptions, - ) => Promise; + getMultiple: { + ( + keys: (keyof T)[], + options: EdgeConfigFunctionsOptions & { metadata: true }, + ): Promise<{ value: T; digest: string }>; + ( + keys: (keyof T)[], + options?: EdgeConfigFunctionsOptions, + ): Promise; + }; + + /** + * Reads all values. + * + * Allows you to read all keys of an Edge Config at once. + * + * @returns Returns all entries. + */ + getAll: { + ( + options: EdgeConfigFunctionsOptions & { metadata: true }, + ): Promise<{ value: T; digest: string }>; + (options?: EdgeConfigFunctionsOptions): Promise; + }; + /** * Check if a given key exists in the Edge Config. * * @param key - the key to check * @returns true if the given key exists in the Edge Config. */ - has: (key: string, options?: EdgeConfigFunctionsOptions) => Promise; + has: { + ( + key: string, + options: EdgeConfigFunctionsOptions & { metadata: true }, + ): Promise<{ exists: boolean; digest: string }>; + (key: string, options?: EdgeConfigFunctionsOptions): Promise; + }; /** * Get the digest of the Edge Config. * @@ -91,4 +124,9 @@ export interface EdgeConfigFunctionsOptions { * need to ensure you generate with the latest content. */ consistentRead?: boolean; + + /** + * Whether to return metadata about the Edge Config, like the digest. + */ + metadata?: boolean; } diff --git a/packages/edge-config/src/utils/add-consistent-read-header.ts b/packages/edge-config/src/utils/add-consistent-read-header.ts new file mode 100644 index 000000000..65708243a --- /dev/null +++ b/packages/edge-config/src/utils/add-consistent-read-header.ts @@ -0,0 +1,7 @@ +/** + * Uses `MAX_SAFE_INTEGER` as minimum updated at timestamp to force + * a request to the origin. + */ +export function addConsistentReadHeader(headers: Headers): void { + headers.set('x-edge-config-min-updated-at', `${Number.MAX_SAFE_INTEGER}`); +} diff --git a/packages/edge-config/src/utils/consume-response-body.ts b/packages/edge-config/src/utils/consume-response-body.ts new file mode 100644 index 000000000..51e273e02 --- /dev/null +++ b/packages/edge-config/src/utils/consume-response-body.ts @@ -0,0 +1,21 @@ +/** + * This function reads the respone body + * + * Reading the response body serves two purposes + * + * 1) In Node.js it avoids memory leaks + * + * See https://github.com/nodejs/undici/blob/v5.21.2/README.md#garbage-collection + * See https://github.com/node-fetch/node-fetch/issues/83 + * + * 2) In Cloudflare it avoids running into a deadlock. They have a maximum number + * of concurrent fetches (which is documented). Concurrency counts until the + * body of a response is read. It is not uncommon to never read a response body + * (e.g. if you only care about the status code). This can lead to deadlock as + * fetches appear to never resolve. + * + * See https://developers.cloudflare.com/workers/platform/limits/#simultaneous-open-connections + */ +export async function consumeResponseBody(res: Response): Promise { + await res.arrayBuffer(); +} diff --git a/packages/edge-config/src/utils/get-build-container-edge-config.ts b/packages/edge-config/src/utils/get-build-container-edge-config.ts new file mode 100644 index 000000000..e1d2da01a --- /dev/null +++ b/packages/edge-config/src/utils/get-build-container-edge-config.ts @@ -0,0 +1,41 @@ +import type { Connection, EmbeddedEdgeConfig } from '../types'; +import { trace } from './tracing'; + +/** + * Reads an Edge Config from the local file system using an async import. + * This is used at runtime on serverless functions. + */ +export const getBuildContainerEdgeConfig = trace( + async function getBuildContainerEdgeConfig( + connection: Connection, + ): Promise { + // can't optimize non-vercel hosted edge configs + if (connection.type !== 'vercel') return null; + + // the folder won't exist in development, only when deployed + if (process.env.NODE_ENV === 'development') return null; + + /** + * Check if running in Vercel build environment + */ + const isVercelBuild = + process.env.VERCEL === '1' && + process.env.CI === '1' && + !process.env.VERCEL_URL; // VERCEL_URL is only available at runtime + + // can only be used during builds + if (!isVercelBuild) return null; + + try { + const edgeConfig = (await import( + /* webpackIgnore: true */ `/tmp/edge-config/${connection.id}.json` + )) as { default: EmbeddedEdgeConfig }; + return edgeConfig.default; + } catch { + return null; + } + }, + { + name: 'getBuildContainerEdgeConfig', + }, +); diff --git a/packages/edge-config/tsdoc.json b/packages/edge-config/tsdoc.json new file mode 100644 index 000000000..7f5c05422 --- /dev/null +++ b/packages/edge-config/tsdoc.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "tagDefinitions": [ + { + "tagName": "@default", + "syntaxKind": "block", + "allowMultiple": false + } + ], + "supportForTags": { + "@default": true, + "@returns": true, + "@param": true, + "@see": true, + "@link": true + } +} From a5fe7792b806cccc1b1ddb30885078eef23846ca Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 25 Jul 2025 14:37:10 +0300 Subject: [PATCH 08/81] fix types --- packages/edge-config/src/index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index a3a6d829c..7f02935a4 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -377,7 +377,10 @@ export const createClient = trace( { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, ), has: trace( - async function has(key, localOptions?: EdgeConfigFunctionsOptions) { + async function has( + key: string, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise { assertIsKey(key); if (isEmptyKey(key)) { throw new Error('@vercel/edge-config: Can not read empty key'); @@ -387,7 +390,7 @@ export const createClient = trace( return localOptions?.metadata ? data : data.exists; }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, - ), + ) as EdgeConfigClient['has'], getMultiple: trace( async function getMultiple( keys: (keyof T)[], @@ -481,10 +484,10 @@ export const get: EdgeConfigClient['get'] = (...args) => { * @param key - the key to check * @returns true if the given key exists in the Edge Config. */ -export const has: EdgeConfigClient['has'] = (...args) => { +export const has = ((...args: Parameters) => { init(); return defaultEdgeConfigClient.has(...args); -}; +}) as EdgeConfigClient['has']; /** * Get the digest of the Edge Config. From 2656808ef3814185cfd08866ebcd8f6d1ad6df8d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 25 Jul 2025 18:36:21 +0300 Subject: [PATCH 09/81] wip --- packages/edge-config/package.json | 4 +- packages/edge-config/src/index.common.test.ts | 2 +- packages/edge-config/src/index.edge.test.ts | 2 +- packages/edge-config/src/index.node.test.ts | 2 +- packages/edge-config/src/index.ts | 23 +- ...esponse.test.ts => enhanced-fetch.test.ts} | 55 +-- ...h-cached-response.ts => enhanced-fetch.ts} | 8 +- pnpm-lock.yaml | 390 +++++++----------- 8 files changed, 180 insertions(+), 306 deletions(-) rename packages/edge-config/src/utils/{fetch-with-cached-response.test.ts => enhanced-fetch.test.ts} (81%) rename packages/edge-config/src/utils/{fetch-with-cached-response.ts => enhanced-fetch.ts} (95%) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 745cad4f7..b9c2159c2 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -44,7 +44,9 @@ "testEnvironment": "node" }, "dependencies": { - "@vercel/edge-config-fs": "workspace:*" + "@vercel/edge-config-fs": "workspace:*", + "http-cache-semantics": "4.2.0", + "shorthash": "0.0.2" }, "devDependencies": { "@changesets/cli": "2.28.1", diff --git a/packages/edge-config/src/index.common.test.ts b/packages/edge-config/src/index.common.test.ts index 19559f282..8e7f2038d 100644 --- a/packages/edge-config/src/index.common.test.ts +++ b/packages/edge-config/src/index.common.test.ts @@ -6,7 +6,7 @@ import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import type { EdgeConfigClient } from './types'; -import { cache } from './utils/fetch-with-cached-response'; +import { cache } from './utils/enhanced-fetch'; import * as pkg from './index'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; diff --git a/packages/edge-config/src/index.edge.test.ts b/packages/edge-config/src/index.edge.test.ts index 4d0ebbaa2..1a2fb5d07 100644 --- a/packages/edge-config/src/index.edge.test.ts +++ b/packages/edge-config/src/index.edge.test.ts @@ -1,6 +1,6 @@ import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; -import { cache } from './utils/fetch-with-cached-response'; +import { cache } from './utils/enhanced-fetch'; import { get, has, digest, getAll, createClient } from './index'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; diff --git a/packages/edge-config/src/index.node.test.ts b/packages/edge-config/src/index.node.test.ts index 58bccb279..930c0045f 100644 --- a/packages/edge-config/src/index.node.test.ts +++ b/packages/edge-config/src/index.node.test.ts @@ -2,7 +2,7 @@ import { readFile } from '@vercel/edge-config-fs'; import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import type { EmbeddedEdgeConfig } from './types'; -import { cache } from './utils/fetch-with-cached-response'; +import { cache } from './utils/enhanced-fetch'; import { get, has, digest, createClient, getAll } from './index'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 7f02935a4..da592e0c4 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -24,7 +24,7 @@ import type { EdgeConfigFunctionsOptions, Connection, } from './types'; -import { fetchWithCachedResponse } from './utils/fetch-with-cached-response'; +import { enhancedFetch } from './utils/enhanced-fetch'; import { trace } from './utils/tracing'; import { consumeResponseBody } from './utils/consume-response-body'; import { addConsistentReadHeader } from './utils/add-consistent-read-header'; @@ -118,7 +118,7 @@ class Controller { key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T | undefined; digest: string }> { - return fetchWithCachedResponse( + return enhancedFetch( `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, { headers: this.getHeaders(localOptions), @@ -148,8 +148,6 @@ class Controller { // the edge config itself does not exist throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); } - // if (res.cachedResponseBody !== undefined) - // return res.cachedResponseBody as T; throw new UnexpectedNetworkError(res); }); } @@ -183,7 +181,7 @@ class Controller { public async digest( localOptions?: Pick, ): Promise { - return fetchWithCachedResponse( + return enhancedFetch( `${this.connection.baseUrl}/digest?version=${this.connection.version}`, { headers: this.getHeaders(localOptions), @@ -220,7 +218,7 @@ class Controller { .map((key) => ['key', key] as [string, string]), ).toString(); - return fetchWithCachedResponse( + return enhancedFetch( `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, { headers: this.getHeaders(localOptions), @@ -250,7 +248,7 @@ class Controller { public async getAll( localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; digest: string }> { - return fetchWithCachedResponse( + return enhancedFetch( `${this.connection.baseUrl}/items?version=${this.connection.version}`, { headers: this.getHeaders(localOptions), @@ -422,13 +420,10 @@ export const createClient = trace( if (localOptions?.consistentRead) addConsistentReadHeader(localHeaders); - return fetchWithCachedResponse( - `${baseUrl}/digest?version=${version}`, - { - headers: localHeaders, - cache: fetchCache, - }, - ).then(async (res) => { + return enhancedFetch(`${baseUrl}/digest?version=${version}`, { + headers: localHeaders, + cache: fetchCache, + }).then(async (res) => { if (res.ok) return res.json() as Promise; await consumeResponseBody(res); diff --git a/packages/edge-config/src/utils/fetch-with-cached-response.test.ts b/packages/edge-config/src/utils/enhanced-fetch.test.ts similarity index 81% rename from packages/edge-config/src/utils/fetch-with-cached-response.test.ts rename to packages/edge-config/src/utils/enhanced-fetch.test.ts index 3366380e9..0860d5b96 100644 --- a/packages/edge-config/src/utils/fetch-with-cached-response.test.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.test.ts @@ -1,5 +1,5 @@ import fetchMock from 'jest-fetch-mock'; -import { fetchWithCachedResponse, cache } from './fetch-with-cached-response'; +import { enhancedFetch, cache } from './enhanced-fetch'; jest.useFakeTimers(); @@ -9,7 +9,7 @@ describe('cache', () => { }); }); -describe('fetchWithCachedResponse', () => { +describe('enhancedFetch', () => { beforeEach(() => { fetchMock.resetMocks(); cache.clear(); @@ -21,7 +21,7 @@ describe('fetchWithCachedResponse', () => { }); // First request - const data1 = await fetchWithCachedResponse('https://example.com/api/data'); + const data1 = await enhancedFetch('https://example.com/api/data'); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', {}); @@ -42,7 +42,7 @@ describe('fetchWithCachedResponse', () => { 'content-type': 'application/json', }, }); - const data2 = await fetchWithCachedResponse('https://example.com/api/data'); + const data2 = await enhancedFetch('https://example.com/api/data'); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', { @@ -65,12 +65,9 @@ describe('fetchWithCachedResponse', () => { }); // First request - const data1 = await fetchWithCachedResponse( - 'https://example.com/api/data', - { - headers: new Headers({ authorization: 'bearer A' }), - }, - ); + const data1 = await enhancedFetch('https://example.com/api/data', { + headers: new Headers({ authorization: 'bearer A' }), + }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', { @@ -85,13 +82,10 @@ describe('fetchWithCachedResponse', () => { fetchMock.mockResponseOnce(JSON.stringify({ name: 'Bob' }), { headers: { ETag: 'abc123', 'content-type': 'application/json' }, }); - const data2 = await fetchWithCachedResponse( - 'https://example.com/api/data', - { - // using a different authorization header here - headers: new Headers({ authorization: 'bearer B' }), - }, - ); + const data2 = await enhancedFetch('https://example.com/api/data', { + // using a different authorization header here + headers: new Headers({ authorization: 'bearer B' }), + }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', { @@ -111,12 +105,9 @@ describe('fetchWithCachedResponse', () => { status: 304, headers: { ETag: 'abc123', 'content-type': 'application/json' }, }); - const data3 = await fetchWithCachedResponse( - 'https://example.com/api/data', - { - headers: new Headers({ authorization: 'bearer A' }), - }, - ); + const data3 = await enhancedFetch('https://example.com/api/data', { + headers: new Headers({ authorization: 'bearer A' }), + }); expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', { @@ -139,7 +130,7 @@ describe('fetchWithCachedResponse', () => { }); // First request - const data1 = await fetchWithCachedResponse('https://example.com/api/data'); + const data1 = await enhancedFetch('https://example.com/api/data'); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', {}); @@ -156,10 +147,9 @@ describe('fetchWithCachedResponse', () => { // Second request (should come from cache) fetchMock.mockResponseOnce('', { status: 502 }); - const data2 = await fetchWithCachedResponse( - 'https://example.com/api/data', - { headers: new Headers({ 'Cache-Control': 'stale-if-error=10' }) }, - ); + const data2 = await enhancedFetch('https://example.com/api/data', { + headers: new Headers({ 'Cache-Control': 'stale-if-error=10' }), + }); jest.advanceTimersByTime(3000); @@ -184,7 +174,7 @@ describe('fetchWithCachedResponse', () => { }); // First request - const data1 = await fetchWithCachedResponse('https://example.com/api/data'); + const data1 = await enhancedFetch('https://example.com/api/data'); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', {}); @@ -201,10 +191,9 @@ describe('fetchWithCachedResponse', () => { // Second request (should come from cache) fetchMock.mockAbortOnce(); - const data2 = await fetchWithCachedResponse( - 'https://example.com/api/data', - { headers: new Headers({ 'Cache-Control': 'stale-if-error=10' }) }, - ); + const data2 = await enhancedFetch('https://example.com/api/data', { + headers: new Headers({ 'Cache-Control': 'stale-if-error=10' }), + }); jest.advanceTimersByTime(3000); diff --git a/packages/edge-config/src/utils/fetch-with-cached-response.ts b/packages/edge-config/src/utils/enhanced-fetch.ts similarity index 95% rename from packages/edge-config/src/utils/fetch-with-cached-response.ts rename to packages/edge-config/src/utils/enhanced-fetch.ts index 136562e65..832acd807 100644 --- a/packages/edge-config/src/utils/fetch-with-cached-response.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -12,6 +12,7 @@ type FetchOptions = Omit & { headers?: Headers }; interface ResponseWithCachedResponse extends Response { cachedResponseBody?: unknown; + cachedResponseHeaders?: Headers; } /** @@ -97,8 +98,8 @@ function extractStaleIfError(cacheControlHeader: string | null): number | null { * This is similar to fetch, but it also implements ETag semantics, and * it implmenets stale-if-error semantics. */ -export const fetchWithCachedResponse = trace( - async function fetchWithCachedResponse( +export const enhancedFetch = trace( + async function enhancedFetch( url: string, options: FetchOptions = {}, ): Promise { @@ -126,6 +127,7 @@ export const fetchWithCachedResponse = trace( if (res.status === 304) { res.cachedResponseBody = JSON.parse(cachedResponse); + res.cachedResponseHeaders = new Headers(res.headers); return res; } @@ -156,7 +158,7 @@ export const fetchWithCachedResponse = trace( return res; }, { - name: 'fetchWithCachedResponse', + name: 'enhancedFetch', attributesSuccess(result) { return { status: result.status, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82d09b737..b24b78ed1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 0.3.7 ts-jest: specifier: 29.2.6 - version: 29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) + version: 29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) turbo: specifier: 2.4.4 version: 2.4.4 @@ -92,7 +92,7 @@ importers: version: 29.7.0(bufferutil@4.0.8) ts-jest: specifier: 29.2.6 - version: 29.2.6(@babel/core@7.23.2)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.2))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) + version: 29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) tsconfig: specifier: workspace:* version: link:../../tooling/tsconfig @@ -108,6 +108,12 @@ importers: '@vercel/edge-config-fs': specifier: workspace:* version: link:../edge-config-fs + http-cache-semantics: + specifier: 4.2.0 + version: 4.2.0 + shorthash: + specifier: 0.0.2 + version: 0.0.2 devDependencies: '@changesets/cli': specifier: 2.28.1 @@ -144,7 +150,7 @@ importers: version: 3.5.2 ts-jest: specifier: 29.2.6 - version: 29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) + version: 29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) tsconfig: specifier: workspace:* version: link:../../tooling/tsconfig @@ -371,7 +377,7 @@ importers: version: 2.1.3 next: specifier: ^15.2.0 - version: 15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) postcss: specifier: ^8.5.3 version: 8.5.3 @@ -452,10 +458,6 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@babel/code-frame@7.22.13': - resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.23.4': resolution: {integrity: sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==} engines: {node: '>=6.9.0'} @@ -476,10 +478,6 @@ packages: resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==} engines: {node: '>=6.9.0'} - '@babel/core@7.23.2': - resolution: {integrity: sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==} - engines: {node: '>=6.9.0'} - '@babel/core@7.23.9': resolution: {integrity: sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==} engines: {node: '>=6.9.0'} @@ -495,10 +493,6 @@ packages: '@babel/core': ^7.11.0 eslint: ^7.5.0 || ^8.0.0 - '@babel/generator@7.22.10': - resolution: {integrity: sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.23.6': resolution: {integrity: sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==} engines: {node: '>=6.9.0'} @@ -559,10 +553,6 @@ packages: resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.22.5': - resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.23.4': resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} engines: {node: '>=6.9.0'} @@ -595,10 +585,6 @@ packages: resolution: {integrity: sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==} engines: {node: '>=6.9.0'} - '@babel/highlight@7.22.13': - resolution: {integrity: sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==} - engines: {node: '>=6.9.0'} - '@babel/highlight@7.23.4': resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==} engines: {node: '>=6.9.0'} @@ -710,10 +696,6 @@ packages: resolution: {integrity: sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==} engines: {node: '>=6.9.0'} - '@babel/types@7.22.11': - resolution: {integrity: sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==} - engines: {node: '>=6.9.0'} - '@babel/types@7.23.9': resolution: {integrity: sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==} engines: {node: '>=6.9.0'} @@ -1624,15 +1606,9 @@ packages: resolution: {integrity: sha512-QWLl2P+rsCJeofkDNIT3WFmb6NrRud1SUYW8dIhXK/46XFV8Q/g7Bsvib0Askb0reRLe+WYPeeE+l5cH7SlkuQ==} engines: {node: '>=18'} - '@sinonjs/commons@2.0.0': - resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} - '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - '@sinonjs/fake-timers@10.0.2': - resolution: {integrity: sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==} - '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} @@ -3351,8 +3327,8 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - http-cache-semantics@4.1.1: - resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} @@ -4087,10 +4063,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - micromatch@4.0.7: - resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} - engines: {node: '>=8.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -4198,6 +4170,7 @@ packages: node-domexception@2.0.1: resolution: {integrity: sha512-M85rnSC7WQ7wnfQTARPT4LrK7nwCHLdDFOCcItZMhTQjyCebJH8GciKqYJNgaOFZs9nFmTmd/VMyi3OW5jA47w==} engines: {node: '>=16'} + deprecated: Use your platform's native DOMException instead node-fetch@2.6.7: resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} @@ -4464,10 +4437,6 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} - pirates@4.0.5: - resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==} - engines: {node: '>= 6'} - pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} @@ -4889,6 +4858,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shorthash@0.0.2: + resolution: {integrity: sha512-L/QRElsfTaCCzA7AJPXuin6/jgWjgmTfjdaXucQ5PauPypmqAZ7t4GueaCv+Jti0M8S2Iv1C/ryD+aWY/KUGCA==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -5662,12 +5634,6 @@ snapshots: dependencies: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 - optional: true - - '@babel/code-frame@7.22.13': - dependencies: - '@babel/highlight': 7.22.13 - chalk: 2.4.2 '@babel/code-frame@7.23.4': dependencies: @@ -5684,32 +5650,10 @@ snapshots: '@babel/helper-validator-identifier': 7.25.9 js-tokens: 4.0.0 picocolors: 1.1.1 - optional: true '@babel/compat-data@7.23.5': {} - '@babel/compat-data@7.26.8': - optional: true - - '@babel/core@7.23.2': - dependencies: - '@ampproject/remapping': 2.2.1 - '@babel/code-frame': 7.23.5 - '@babel/generator': 7.23.6 - '@babel/helper-compilation-targets': 7.23.6 - '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.2) - '@babel/helpers': 7.23.9 - '@babel/parser': 7.23.9 - '@babel/template': 7.23.9 - '@babel/traverse': 7.23.9 - '@babel/types': 7.23.9 - convert-source-map: 2.0.0 - debug: 4.4.0 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@babel/compat-data@7.26.8': {} '@babel/core@7.23.9': dependencies: @@ -5750,7 +5694,6 @@ snapshots: semver: 6.3.1 transitivePeerDependencies: - supports-color - optional: true '@babel/eslint-parser@7.23.10(@babel/core@7.23.9)(eslint@8.56.0)': dependencies: @@ -5760,13 +5703,6 @@ snapshots: eslint-visitor-keys: 2.1.0 semver: 6.3.1 - '@babel/generator@7.22.10': - dependencies: - '@babel/types': 7.22.11 - '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 2.5.2 - '@babel/generator@7.23.6': dependencies: '@babel/types': 7.23.9 @@ -5781,7 +5717,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 - optional: true '@babel/helper-compilation-targets@7.23.6': dependencies: @@ -5798,7 +5733,6 @@ snapshots: browserslist: 4.24.4 lru-cache: 5.1.1 semver: 6.3.1 - optional: true '@babel/helper-environment-visitor@7.22.20': {} @@ -5821,16 +5755,6 @@ snapshots: '@babel/types': 7.26.9 transitivePeerDependencies: - supports-color - optional: true - - '@babel/helper-module-transforms@7.23.3(@babel/core@7.23.2)': - dependencies: - '@babel/core': 7.23.2 - '@babel/helper-environment-visitor': 7.22.20 - '@babel/helper-module-imports': 7.22.15 - '@babel/helper-simple-access': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/helper-validator-identifier': 7.22.20 '@babel/helper-module-transforms@7.23.3(@babel/core@7.23.9)': dependencies: @@ -5849,7 +5773,6 @@ snapshots: '@babel/traverse': 7.26.9 transitivePeerDependencies: - supports-color - optional: true '@babel/helper-plugin-utils@7.20.2': {} @@ -5861,22 +5784,17 @@ snapshots: dependencies: '@babel/types': 7.23.9 - '@babel/helper-string-parser@7.22.5': {} - '@babel/helper-string-parser@7.23.4': {} - '@babel/helper-string-parser@7.25.9': - optional: true + '@babel/helper-string-parser@7.25.9': {} '@babel/helper-validator-identifier@7.22.20': {} - '@babel/helper-validator-identifier@7.25.9': - optional: true + '@babel/helper-validator-identifier@7.25.9': {} '@babel/helper-validator-option@7.23.5': {} - '@babel/helper-validator-option@7.25.9': - optional: true + '@babel/helper-validator-option@7.25.9': {} '@babel/helpers@7.23.9': dependencies: @@ -5890,13 +5808,6 @@ snapshots: dependencies: '@babel/template': 7.26.9 '@babel/types': 7.26.9 - optional: true - - '@babel/highlight@7.22.13': - dependencies: - '@babel/helper-validator-identifier': 7.22.20 - chalk: 2.4.2 - js-tokens: 4.0.0 '@babel/highlight@7.23.4': dependencies: @@ -5911,148 +5822,147 @@ snapshots: '@babel/parser@7.26.9': dependencies: '@babel/types': 7.26.9 - optional: true - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.23.2)': + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.2)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.2)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.2)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.2)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.23.2)': + '@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.23.2)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.2)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.2)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.2)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.2)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.2)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.2)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.9)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.20.2 + optional: true '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-typescript@7.21.4(@babel/core@7.23.2)': + '@babel/plugin-syntax-typescript@7.21.4(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 '@babel/runtime@7.25.6': @@ -6074,7 +5984,6 @@ snapshots: '@babel/code-frame': 7.26.2 '@babel/parser': 7.26.9 '@babel/types': 7.26.9 - optional: true '@babel/traverse@7.23.9': dependencies: @@ -6102,13 +6011,6 @@ snapshots: globals: 11.12.0 transitivePeerDependencies: - supports-color - optional: true - - '@babel/types@7.22.11': - dependencies: - '@babel/helper-string-parser': 7.22.5 - '@babel/helper-validator-identifier': 7.22.20 - to-fast-properties: 2.0.0 '@babel/types@7.23.9': dependencies: @@ -6120,7 +6022,6 @@ snapshots: dependencies: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - optional: true '@bcoe/v8-coverage@0.2.3': {} @@ -6654,7 +6555,7 @@ snapshots: jest-util: 29.7.0 jest-validate: 29.7.0 jest-watcher: 29.7.0 - micromatch: 4.0.7 + micromatch: 4.0.8 pretty-format: 29.7.0 slash: 3.0.0 strip-ansi: 6.0.1 @@ -6689,7 +6590,7 @@ snapshots: jest-util: 29.7.0 jest-validate: 29.7.0 jest-watcher: 29.7.0 - micromatch: 4.0.7 + micromatch: 4.0.8 pretty-format: 29.7.0 slash: 3.0.0 strip-ansi: 6.0.1 @@ -6735,7 +6636,7 @@ snapshots: '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.0.2 + '@sinonjs/fake-timers': 10.3.0 '@types/node': 22.13.5 jest-message-util: 29.7.0 jest-mock: 29.7.0 @@ -6757,7 +6658,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.25 '@types/node': 22.13.5 chalk: 4.1.2 collect-v8-coverage: 1.0.1 @@ -6805,9 +6706,9 @@ snapshots: '@jest/transform@29.7.0': dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.26.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.25 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -6817,7 +6718,7 @@ snapshots: jest-regex-util: 29.6.3 jest-util: 29.7.0 micromatch: 4.0.8 - pirates: 4.0.5 + pirates: 4.0.6 slash: 3.0.0 write-file-atomic: 4.0.2 transitivePeerDependencies: @@ -6849,7 +6750,6 @@ snapshots: '@jridgewell/set-array': 1.2.1 '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 - optional: true '@jridgewell/resolve-uri@3.1.0': {} @@ -7053,18 +6953,10 @@ snapshots: '@sindresorhus/is@7.0.1': {} - '@sinonjs/commons@2.0.0': - dependencies: - type-detect: 4.0.8 - '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 - '@sinonjs/fake-timers@10.0.2': - dependencies: - '@sinonjs/commons': 2.0.0 - '@sinonjs/fake-timers@10.3.0': dependencies: '@sinonjs/commons': 3.0.1 @@ -7100,24 +6992,24 @@ snapshots: '@types/babel__core@7.20.0': dependencies: - '@babel/parser': 7.23.9 - '@babel/types': 7.23.9 + '@babel/parser': 7.26.9 + '@babel/types': 7.26.9 '@types/babel__generator': 7.6.4 '@types/babel__template': 7.4.1 '@types/babel__traverse': 7.18.5 '@types/babel__generator@7.6.4': dependencies: - '@babel/types': 7.23.9 + '@babel/types': 7.26.9 '@types/babel__template@7.4.1': dependencies: - '@babel/parser': 7.23.9 - '@babel/types': 7.23.9 + '@babel/parser': 7.26.9 + '@babel/types': 7.26.9 '@types/babel__traverse@7.18.5': dependencies: - '@babel/types': 7.23.9 + '@babel/types': 7.26.9 '@types/estree@1.0.6': {} @@ -7200,10 +7092,10 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.0 - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 8.25.0(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/type-utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) @@ -7415,7 +7307,7 @@ snapshots: '@babel/core': 7.23.9 '@babel/eslint-parser': 7.23.10(@babel/core@7.23.9)(eslint@8.56.0) '@rushstack/eslint-patch': 1.7.2 - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) eslint-config-prettier: 9.1.0(eslint@8.56.0) eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1) @@ -7716,18 +7608,19 @@ snapshots: axobject-query@4.1.0: {} - babel-jest@29.7.0(@babel/core@7.23.2): + babel-jest@29.7.0(@babel/core@7.23.9): dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@jest/transform': 29.7.0 '@types/babel__core': 7.20.0 babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.23.2) + babel-preset-jest: 29.6.3(@babel/core@7.23.9) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 transitivePeerDependencies: - supports-color + optional: true babel-jest@29.7.0(@babel/core@7.26.0): dependencies: @@ -7741,7 +7634,6 @@ snapshots: slash: 3.0.0 transitivePeerDependencies: - supports-color - optional: true babel-plugin-istanbul@6.1.1: dependencies: @@ -7755,26 +7647,27 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: - '@babel/template': 7.23.9 - '@babel/types': 7.23.9 + '@babel/template': 7.26.9 + '@babel/types': 7.26.9 '@types/babel__core': 7.20.0 '@types/babel__traverse': 7.18.5 - babel-preset-current-node-syntax@1.0.1(@babel/core@7.23.2): - dependencies: - '@babel/core': 7.23.2 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.23.2) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.23.2) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.23.2) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.23.2) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.23.2) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.23.2) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.23.2) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.23.2) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.23.2) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.23.2) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.2) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.2) + babel-preset-current-node-syntax@1.0.1(@babel/core@7.23.9): + dependencies: + '@babel/core': 7.23.9 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.23.9) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.23.9) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.23.9) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.23.9) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.23.9) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.9) + optional: true babel-preset-current-node-syntax@1.0.1(@babel/core@7.26.0): dependencies: @@ -7791,20 +7684,19 @@ snapshots: '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.26.0) '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.26.0) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.26.0) - optional: true - babel-preset-jest@29.6.3(@babel/core@7.23.2): + babel-preset-jest@29.6.3(@babel/core@7.23.9): dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.2) + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.9) + optional: true babel-preset-jest@29.6.3(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.0.1(@babel/core@7.26.0) - optional: true balanced-match@1.0.2: {} @@ -7879,7 +7771,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.0.4 get-stream: 9.0.1 - http-cache-semantics: 4.1.1 + http-cache-semantics: 4.2.0 keyv: 4.5.4 mimic-response: 4.0.0 normalize-url: 8.0.1 @@ -8598,7 +8490,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.15.0 eslint: 8.56.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 @@ -8636,7 +8528,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0): + eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -8663,7 +8555,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) hasown: 2.0.1 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -8714,7 +8606,7 @@ snapshots: '@typescript-eslint/utils': 5.62.0(eslint@8.56.0)(typescript@5.7.3) eslint: 8.56.0 optionalDependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) transitivePeerDependencies: - supports-color @@ -9357,7 +9249,7 @@ snapshots: html-escaper@2.0.2: {} - http-cache-semantics@4.1.1: {} + http-cache-semantics@4.2.0: {} http-proxy-agent@5.0.0: dependencies: @@ -9692,8 +9584,8 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: - '@babel/core': 7.23.2 - '@babel/parser': 7.23.9 + '@babel/core': 7.26.0 + '@babel/parser': 7.26.9 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.0 semver: 6.3.1 @@ -9702,8 +9594,8 @@ snapshots: istanbul-lib-instrument@6.0.0: dependencies: - '@babel/core': 7.23.9 - '@babel/parser': 7.23.9 + '@babel/core': 7.26.0 + '@babel/parser': 7.26.9 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.0 semver: 7.7.1 @@ -9838,10 +9730,10 @@ snapshots: jest-config@29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)): dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.26.0 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.23.2) + babel-jest: 29.7.0(@babel/core@7.26.0) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -9869,10 +9761,10 @@ snapshots: jest-config@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)): dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.26.0 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.23.2) + babel-jest: 29.7.0(@babel/core@7.26.0) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -9900,10 +9792,10 @@ snapshots: jest-config@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)): dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.26.0 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.23.2) + babel-jest: 29.7.0(@babel/core@7.26.0) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -10011,7 +9903,7 @@ snapshots: jest-message-util@29.7.0: dependencies: - '@babel/code-frame': 7.22.13 + '@babel/code-frame': 7.26.2 '@jest/types': 29.6.3 '@types/stack-utils': 2.0.1 chalk: 4.1.2 @@ -10113,15 +10005,15 @@ snapshots: jest-snapshot@29.7.0: dependencies: - '@babel/core': 7.23.2 - '@babel/generator': 7.22.10 - '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.23.2) - '@babel/plugin-syntax-typescript': 7.21.4(@babel/core@7.23.2) - '@babel/types': 7.22.11 + '@babel/core': 7.26.0 + '@babel/generator': 7.26.9 + '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.26.0) + '@babel/plugin-syntax-typescript': 7.21.4(@babel/core@7.26.0) + '@babel/types': 7.26.9 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.2) + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.26.0) chalk: 4.1.2 expect: 29.7.0 graceful-fs: 4.2.11 @@ -10261,8 +10153,7 @@ snapshots: jsesc@3.0.2: {} - jsesc@3.1.0: - optional: true + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -10424,11 +10315,6 @@ snapshots: merge2@1.4.1: {} - micromatch@4.0.7: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -10490,7 +10376,7 @@ snapshots: natural-compare@1.4.0: {} - next@15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.2.0 '@swc/counter': 0.1.3 @@ -10500,7 +10386,7 @@ snapshots: postcss: 8.4.31 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.23.9)(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) optionalDependencies: '@next/swc-darwin-arm64': 15.2.0 '@next/swc-darwin-x64': 15.2.0 @@ -10782,8 +10668,6 @@ snapshots: pify@4.0.1: {} - pirates@4.0.5: {} - pirates@4.0.6: {} pkg-dir@4.2.0: @@ -11229,6 +11113,8 @@ snapshots: shebang-regex@3.0.0: {} + shorthash@0.0.2: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -11476,12 +11362,12 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(@babel/core@7.23.9)(react@19.0.0): + styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.0.0): dependencies: client-only: 0.0.1 react: 19.0.0 optionalDependencies: - '@babel/core': 7.23.9 + '@babel/core': 7.26.0 sucrase@3.35.0: dependencies: @@ -11637,7 +11523,7 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.26.0) esbuild: 0.24.2 - ts-jest@29.2.6(@babel/core@7.23.2)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.2))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): + ts-jest@29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -11651,13 +11537,12 @@ snapshots: typescript: 5.7.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.9 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.23.2) - esbuild: 0.25.0 + babel-jest: 29.7.0(@babel/core@7.23.9) - ts-jest@29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): + ts-jest@29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -11675,6 +11560,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.26.0) + esbuild: 0.25.0 ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3): dependencies: @@ -12140,7 +12026,7 @@ snapshots: yargs@17.7.2: dependencies: cliui: 8.0.1 - escalade: 3.1.1 + escalade: 3.2.0 get-caller-file: 2.0.5 require-directory: 2.1.1 string-width: 4.2.3 From 3f96eb9cb71fac66e16535c516d1bebe08bba672 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 28 Jul 2025 08:23:03 +0300 Subject: [PATCH 10/81] add controller --- packages/edge-config/src/controller.test.ts | 94 +++++ packages/edge-config/src/controller.ts | 403 ++++++++++++++++++++ 2 files changed, 497 insertions(+) create mode 100644 packages/edge-config/src/controller.test.ts create mode 100644 packages/edge-config/src/controller.ts diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts new file mode 100644 index 000000000..7bed71b63 --- /dev/null +++ b/packages/edge-config/src/controller.test.ts @@ -0,0 +1,94 @@ +import fetchMock from 'jest-fetch-mock'; +import { Controller, setTimestampOfLatestUpdate } from './controller'; +import type { Connection } from './types'; + +const connection: Connection = { + baseUrl: 'https://edge-config.vercel.com', + id: 'ecfg_FAKE_EDGE_CONFIG_ID', + token: 'fake-edge-config-token', + version: '1', + type: 'vercel', +}; + +// eslint-disable-next-line jest/require-top-level-describe -- [@vercel/style-guide@5 migration] +beforeEach(() => { + fetchMock.resetMocks(); +}); + +describe('controller', () => { + it('should work', async () => { + const controller = new Controller(connection, {}, false); + + setTimestampOfLatestUpdate(1000); + + fetchMock.mockResponse(JSON.stringify('value1'), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + }, + }); + + // blocking fetch first + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + source: 'network-blocking', + }); + + // cache HIT after + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + source: 'cached-fresh', + }); + + // should not fetch again + expect(fetchMock).toHaveBeenCalledTimes(1); + + // should refresh in background and serve stale value + setTimestampOfLatestUpdate(7000); + fetchMock.mockResponse(JSON.stringify('value2'), { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '7000', + }, + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + source: 'cached-stale', + }); + + // should fetch again in background + expect(fetchMock).toHaveBeenCalledTimes(2); + + // run event loop once + await Promise.resolve(); + + // should now serve the stale value + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value2', + digest: 'digest2', + source: 'cached-fresh', + }); + + // exceeds stale threshold should lead to cache MISS and blocking fetch + setTimestampOfLatestUpdate(17001); + fetchMock.mockResponse(JSON.stringify('value3'), { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '17001', + }, + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value3', + digest: 'digest3', + source: 'network-blocking', + }); + + // needs to fetch again + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts new file mode 100644 index 000000000..b91e75daf --- /dev/null +++ b/packages/edge-config/src/controller.ts @@ -0,0 +1,403 @@ +import { time } from 'node:console'; +import type { + EdgeConfigValue, + EmbeddedEdgeConfig, + EdgeConfigFunctionsOptions, + Connection, + EdgeConfigClientOptions, + Source, +} from './types'; +import { ERRORS, isEmptyKey, UnexpectedNetworkError } from './utils'; +import { consumeResponseBody } from './utils/consume-response-body'; +import { addConsistentReadHeader } from './utils/add-consistent-read-header'; + +const DEFAULT_STALE_THRESHOLD = 10_000; // 10 seconds + +let timestampOfLatestUpdate: number | undefined; + +export function setTimestampOfLatestUpdate(timestamp: number): void { + timestampOfLatestUpdate = timestamp; +} + +export class Controller { + private edgeConfigCache: (EmbeddedEdgeConfig & { updatedAt: number }) | null = + null; + private itemCache = new Map< + string, + // an undefined value signals the key does not exist + { value: EdgeConfigValue | undefined; updatedAt: number; digest: string } + >(); + + private connection: Connection; + private shouldUseDevelopmentCache: boolean; + private staleThreshold: number; + private cacheMode: 'no-store' | 'force-cache'; + + /** + * A map of keys to pending promises + */ + private pendingItemFetches = new Map< + string, + { + minUpdatedAt: number; + promise: Promise<{ + value: EdgeConfigValue | undefined; + digest: string; + source: Source; + }>; + } + >(); + + private pendingEdgeConfigPromise: Promise | null = + null; + + constructor( + connection: Connection, + options: EdgeConfigClientOptions, + shouldUseDevelopmentCache: boolean, + ) { + this.connection = connection; + this.shouldUseDevelopmentCache = shouldUseDevelopmentCache; + this.staleThreshold = options.staleThreshold ?? DEFAULT_STALE_THRESHOLD; + this.cacheMode = options.cache || 'no-store'; + } + + public async get( + key: string, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ value: T | undefined; digest: string; source: Source }> { + // check full config cache + // check item cache + // + // pick newer version on HIT + + // otherwise + // blocking fetch if MISS + // background fetch if STALE + + // const [state, effects] = reduce(state) + // await processEffects(effects) + + // only use the cache if we have a timestamp of the latest update + if (timestampOfLatestUpdate) { + const cachedItem = this.itemCache.get(key); + const cachedConfig = this.edgeConfigCache; + let cached: { + value: T | undefined; + updatedAt: number; + digest: string; + } | null = null; + if (cachedItem && cachedConfig) { + cached = + cachedItem.updatedAt > cachedConfig.updatedAt + ? { + value: cachedItem.value as T | undefined, + updatedAt: cachedItem.updatedAt, + digest: cachedItem.digest, + } + : { + digest: cachedConfig.digest, + value: cachedConfig.items[key] as T | undefined, + updatedAt: cachedConfig.updatedAt, + }; + } else if (cachedItem && !cachedConfig) { + cached = { + value: cachedItem.value as T | undefined, + updatedAt: cachedItem.updatedAt, + digest: cachedItem.digest, + }; + } else if (!cachedItem && cachedConfig) { + cached = { + value: cachedConfig.items[key] as T | undefined, + updatedAt: cachedConfig.updatedAt, + digest: cachedConfig.digest, + }; + } + + if (cached) { + if (timestampOfLatestUpdate === cached.updatedAt) { + return { + value: cached.value, + digest: cached.digest, + source: 'cached-fresh', + }; + } + if (timestampOfLatestUpdate > cached.updatedAt) { + // we're outdated, but check if we can serve the STALE value + if ( + cached.updatedAt >= + timestampOfLatestUpdate - this.staleThreshold + ) { + // background refresh + // reuse existing promise if there is one + const pendingPromise = this.pendingItemFetches.get(key); + if ( + pendingPromise && + pendingPromise.minUpdatedAt >= timestampOfLatestUpdate && + // ensure the pending promise can not end up being stale + pendingPromise.minUpdatedAt + this.staleThreshold >= + timestampOfLatestUpdate + ) { + // do nothing + } else { + // TODO cancel existing pending fetch with an AbortController if + // there is one? does this lead to problems if it is being awaited + // by a blocking read? + // + // TODO use waitUntil? + void this.fetchItem( + key, + timestampOfLatestUpdate, + localOptions, + ).catch(() => null); + } + + return { + value: cached.value, + digest: cached.digest, + source: 'cached-stale', + }; + } + + // we're outdated, but we can't serve the STALE value + // so we need to fetch the latest value in a BLOCKING way and then + // update the cache afterwards + // + // this is the same behavior as if we had no cache it at all, + // so we just fall through + } + } + + // reuse existing promise if there is one + const pendingPromise = this.pendingItemFetches.get(key); + if ( + pendingPromise && + pendingPromise.minUpdatedAt >= timestampOfLatestUpdate && + // ensure the pending promise can not end up being stale + pendingPromise.minUpdatedAt + this.staleThreshold >= + timestampOfLatestUpdate + ) { + // TODO should we check once the promise resolves whether it ended up + // being stale and do a blocking refetch in that case? + return pendingPromise.promise as Promise<{ + value: T | undefined; + digest: string; + source: Source; + }>; + } + } + + // otherwise, create a new promise + + const promise = this.fetchItem( + key, + timestampOfLatestUpdate, + localOptions, + ); + + return promise; + } + + private fetchItem( + key: string, + minUpdatedAt: number | undefined, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T | undefined; + digest: string; + source: Source; + }> { + const promise = fetch( + `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions), + cache: this.cacheMode, + }, + ).then<{ value: T | undefined; digest: string; source: Source }>( + async (res) => { + // TODO deal with filling the cache + const digest = res.headers.get('x-edge-config-digest'); + + if (!digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + + if (res.ok) { + const value = (await res.json()) as T; + // TODO this header is not present on responses of the real API currently, + // but we mock it in tests already + const updatedAt = Number(res.headers.get('x-edge-config-updated-at')); + this.itemCache.set(key, { value, updatedAt, digest }); + return { value, digest, source: 'network-blocking' }; + } + + await consumeResponseBody(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 (digest) { + const updatedAt = Number( + res.headers.get('x-edge-config-updated-at'), + ); + + this.itemCache.set(key, { value: undefined, updatedAt, digest }); + return { value: undefined, digest, source: 'network-blocking' }; + } + // 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); + } + throw new UnexpectedNetworkError(res); + }, + ); + + // save the pending promise and the minimum updatedAt + if (minUpdatedAt) { + this.pendingItemFetches.set(key, { minUpdatedAt, promise }); + } + + return promise; + } + + public async has( + key: string, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ exists: boolean; digest: string; source: Source }> { + return fetch( + `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, + { + method: 'HEAD', + headers: this.getHeaders(localOptions), + cache: this.cacheMode, + }, + ).then<{ exists: boolean; digest: string; source: Source }>((res) => { + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + const digest = res.headers.get('x-edge-config-digest'); + + if (!digest) { + // 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 { + digest, + exists: res.status !== 404, + source: 'network-blocking', + }; + throw new UnexpectedNetworkError(res); + }); + } + + public async digest( + localOptions?: Pick, + ): Promise { + return fetch( + `${this.connection.baseUrl}/digest?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions), + cache: this.cacheMode, + }, + ).then(async (res) => { + if (res.ok) return res.json() as Promise; + await consumeResponseBody(res); + + // if (res.cachedResponseBody !== undefined) + // return res.cachedResponseBody as string; + throw new UnexpectedNetworkError(res); + }); + } + + public async getMultiple( + keys: (keyof T)[], + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ value: T; digest: string }> { + if (!Array.isArray(keys)) { + throw new Error('@vercel/edge-config: keys must be an array'); + } + + // Return early if there are no keys to be read. + // This is only possible if the digest is not required, or if we have a + // cached digest (not implemented yet). + if (!localOptions?.metadata && keys.length === 0) { + return { value: {} as T, digest: '' }; + } + + const search = new URLSearchParams( + keys + .filter((key) => typeof key === 'string' && !isEmptyKey(key)) + .map((key) => ['key', key] as [string, string]), + ).toString(); + + return fetch( + `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, + { + headers: this.getHeaders(localOptions), + cache: this.cacheMode, + }, + ).then<{ value: T; digest: string }>(async (res) => { + if (res.ok) { + const digest = res.headers.get('x-edge-config-digest'); + if (!digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + const value = (await res.json()) as T; + return { value, digest }; + } + await consumeResponseBody(res); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + // the /items endpoint never returns 404, so if we get a 404 + // it means the edge config itself did not exist + if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + // if (res.cachedResponseBody !== undefined) + // return res.cachedResponseBody as T; + throw new UnexpectedNetworkError(res); + }); + } + + public async getAll( + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ value: T; digest: string }> { + return fetch( + `${this.connection.baseUrl}/items?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions), + cache: this.cacheMode, + }, + ).then<{ value: T; digest: string }>(async (res) => { + if (res.ok) { + const digest = res.headers.get('x-edge-config-digest'); + if (!digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + const value = (await res.json()) as T; + return { value, digest }; + } + await consumeResponseBody(res); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + // the /items endpoint never returns 404, so if we get a 404 + // it means the edge config itself did not exist + if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + // if (res.cachedResponseBody !== undefined) + // return res.cachedResponseBody as T; + throw new UnexpectedNetworkError(res); + }); + } + + private getHeaders( + localOptions: EdgeConfigFunctionsOptions | undefined, + ): Headers { + const headers: Record = { + Authorization: `Bearer ${this.connection.token}`, + }; + const localHeaders = new Headers(headers); + if (localOptions?.consistentRead) addConsistentReadHeader(localHeaders); + + return localHeaders; + } +} From 41ae184bef521e30ca6d719db214fa4095be6f47 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 28 Jul 2025 08:24:42 +0300 Subject: [PATCH 11/81] remove unused deps --- packages/edge-config/package.json | 6 +- pnpm-lock.yaml | 169 +++--------------------------- 2 files changed, 14 insertions(+), 161 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index b9c2159c2..d57d6a27d 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -43,11 +43,7 @@ ], "testEnvironment": "node" }, - "dependencies": { - "@vercel/edge-config-fs": "workspace:*", - "http-cache-semantics": "4.2.0", - "shorthash": "0.0.2" - }, + "dependencies": {}, "devDependencies": { "@changesets/cli": "2.28.1", "@edge-runtime/jest-environment": "2.3.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b24b78ed1..aedd3bc51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 0.3.7 ts-jest: specifier: 29.2.6 - version: 29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) + version: 29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) turbo: specifier: 2.4.4 version: 2.4.4 @@ -105,15 +105,6 @@ importers: '@opentelemetry/api': specifier: ^1.7.0 version: 1.7.0 - '@vercel/edge-config-fs': - specifier: workspace:* - version: link:../edge-config-fs - http-cache-semantics: - specifier: 4.2.0 - version: 4.2.0 - shorthash: - specifier: 0.0.2 - version: 0.0.2 devDependencies: '@changesets/cli': specifier: 2.28.1 @@ -377,7 +368,7 @@ importers: version: 2.1.3 next: specifier: ^15.2.0 - version: 15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) postcss: specifier: ^8.5.3 version: 8.5.3 @@ -4858,9 +4849,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shorthash@0.0.2: - resolution: {integrity: sha512-L/QRElsfTaCCzA7AJPXuin6/jgWjgmTfjdaXucQ5PauPypmqAZ7t4GueaCv+Jti0M8S2Iv1C/ryD+aWY/KUGCA==} - side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -5823,56 +5811,26 @@ snapshots: dependencies: '@babel/types': 7.26.9 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5883,78 +5841,36 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -7092,10 +7008,10 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.0 - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/parser': 8.25.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/type-utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) @@ -7307,7 +7223,7 @@ snapshots: '@babel/core': 7.23.9 '@babel/eslint-parser': 7.23.10(@babel/core@7.23.9)(eslint@8.56.0) '@rushstack/eslint-patch': 1.7.2 - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) eslint-config-prettier: 9.1.0(eslint@8.56.0) eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1) @@ -7608,20 +7524,6 @@ snapshots: axobject-query@4.1.0: {} - babel-jest@29.7.0(@babel/core@7.23.9): - dependencies: - '@babel/core': 7.23.9 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.0 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.23.9) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - optional: true - babel-jest@29.7.0(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -7652,23 +7554,6 @@ snapshots: '@types/babel__core': 7.20.0 '@types/babel__traverse': 7.18.5 - babel-preset-current-node-syntax@1.0.1(@babel/core@7.23.9): - dependencies: - '@babel/core': 7.23.9 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.23.9) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.23.9) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.23.9) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.23.9) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.23.9) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.9) - optional: true - babel-preset-current-node-syntax@1.0.1(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -7685,13 +7570,6 @@ snapshots: '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.26.0) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.26.0) - babel-preset-jest@29.6.3(@babel/core@7.23.9): - dependencies: - '@babel/core': 7.23.9 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.9) - optional: true - babel-preset-jest@29.6.3(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -8490,7 +8368,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.15.0 eslint: 8.56.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 @@ -8528,7 +8406,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0): + eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -8555,7 +8433,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) hasown: 2.0.1 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -8606,7 +8484,7 @@ snapshots: '@typescript-eslint/utils': 5.62.0(eslint@8.56.0)(typescript@5.7.3) eslint: 8.56.0 optionalDependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) transitivePeerDependencies: - supports-color @@ -10376,7 +10254,7 @@ snapshots: natural-compare@1.4.0: {} - next@15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.2.0 '@swc/counter': 0.1.3 @@ -10386,7 +10264,7 @@ snapshots: postcss: 8.4.31 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.23.9)(react@19.0.0) optionalDependencies: '@next/swc-darwin-arm64': 15.2.0 '@next/swc-darwin-x64': 15.2.0 @@ -11113,8 +10991,6 @@ snapshots: shebang-regex@3.0.0: {} - shorthash@0.0.2: {} - side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -11362,12 +11238,12 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.0.0): + styled-jsx@5.1.6(@babel/core@7.23.9)(react@19.0.0): dependencies: client-only: 0.0.1 react: 19.0.0 optionalDependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.23.9 sucrase@3.35.0: dependencies: @@ -11523,25 +11399,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.26.0) esbuild: 0.24.2 - ts-jest@29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): - dependencies: - bs-logger: 0.2.6 - ejs: 3.1.10 - fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) - jest-util: 29.7.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.1 - typescript: 5.7.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.23.9 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.23.9) - ts-jest@29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 From 7eb32840ba3178ea6ace040779a2dbf163fa8eba Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 28 Jul 2025 08:24:56 +0300 Subject: [PATCH 12/81] add types --- packages/edge-config/src/types.ts | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 6356aeec0..e0a5f3352 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -130,3 +130,64 @@ export interface EdgeConfigFunctionsOptions { */ metadata?: boolean; } + +export interface EdgeConfigClientOptions { + /** + * Configure for how long the SDK will return a stale value in case a fresh value could not be fetched. + * + * @default Infinity + */ + staleIfError?: number | false; + + /** + * Configure the threshold for how long the SDK allows stale values to be + * served after they become outdated. The SDK will switch from refreshing + * in the background to performing a blocking fetch when this threshold is + * exceeded. + * + * The threshold configures the difference, in seconds, between when an update + * was made until the SDK will force fetch the latest value. + * + * Background refresh example: + * If you set this value to 10 seconds, then reads within 10 + * seconds after an update was made will be served from the in-memory cache, + * while a background refresh will be performed. Once the background refresh + * completes any further reads will be served from the updated in-memory cache, + * and thus also return the latest value. + * + * Blocking read example: + * If an Edge Config is updated and there are no reads in the 10 seconds after + * the update was made then there will be no background refresh. When the next + * read happens more than 10 seconds later it will be a blocking read which + * reads from the origin. This takes slightly longer but guarantees that the + * SDK will never serve a value that is stale for more than 10 seconds. + * + * + * @default 10 + */ + staleThreshold?: number; + + /** + * In development, a stale-while-revalidate cache is employed as the default caching strategy. + * + * This cache aims to deliver speedy Edge Config reads during development, though it comes + * at the cost of delayed visibility for updates to Edge Config. Typically, you may need to + * refresh twice to observe these changes as the stale value is replaced. + * + * This cache is not used in preview or production deployments as superior optimisations are applied there. + */ + disableDevelopmentCache?: boolean; + + /** + * Sets a `cache` option on the `fetch` call made by Edge Config. + * + * Unlike Next.js, this defaults to `no-store`, as you most likely want to use Edge Config dynamically. + */ + cache?: 'no-store' | 'force-cache'; +} + +export type Source = + | 'cached-fresh' // value is cached and deemed fresh + | 'cached-stale' // value is cached but we know it's outdated + | 'network-blocking' // value was fetched over network as the staleThreshold was exceeded + | 'network-consistent'; // value was fetched over the network as a consistent read was requested From efebad5c6996f3e8b94c12badb8d4decd701d973 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 28 Jul 2025 08:25:13 +0300 Subject: [PATCH 13/81] prepare --- packages/edge-config/src/index.ts | 270 +----------------------------- 1 file changed, 4 insertions(+), 266 deletions(-) diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index da592e0c4..04253fb32 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -1,13 +1,3 @@ -// use build time cache if present -// -// at runtime, bootstrap initial state over network -// -// fresh → reuse -// stale (recent) → reuse, refresh in bg -// stale (old) → blocking fetch -// -// treat it all as per-key caches - import { name as sdkName, version as sdkVersion } from '../package.json'; import { assertIsKey, @@ -22,12 +12,13 @@ import type { EdgeConfigValue, EmbeddedEdgeConfig, EdgeConfigFunctionsOptions, - Connection, + EdgeConfigClientOptions, } from './types'; -import { enhancedFetch } from './utils/enhanced-fetch'; +// import { fetch } from './utils/enhanced-fetch'; import { trace } from './utils/tracing'; import { consumeResponseBody } from './utils/consume-response-body'; import { addConsistentReadHeader } from './utils/add-consistent-read-header'; +import { Controller } from './controller'; export { setTracerProvider } from './utils/tracing'; @@ -39,259 +30,6 @@ export { type EmbeddedEdgeConfig, }; -interface EdgeConfigClientOptions { - /** - * Configure for how long the SDK will return a stale value in case a fresh value could not be fetched. - * - * @default Infinity - */ - staleIfError?: number | false; - - /** - * Configure the threshold for how long the SDK allows stale values to be - * served after they become outdated. The SDK will switch from refreshing - * in the background to performing a blocking fetch when this threshold is - * exceeded. - * - * The threshold configures the difference, in seconds, between when an update - * was made until the SDK will force fetch the latest value. - * - * Background refresh example: - * If you set this value to 10 seconds, then reads within 10 - * seconds after an update was made will be served from the in-memory cache, - * while a background refresh will be performed. Once the background refresh - * completes any further reads will be served from the updated in-memory cache, - * and thus also return the latest value. - * - * Blocking read example: - * If an Edge Config is updated and there are no reads in the 10 seconds after - * the update was made then there will be no background refresh. When the next - * read happens more than 10 seconds later it will be a blocking read which - * reads from the origin. This takes slightly longer but guarantees that the - * SDK will never serve a value that is stale for more than 10 seconds. - * - * - * @default 10 - */ - staleThreshold?: number; - - /** - * In development, a stale-while-revalidate cache is employed as the default caching strategy. - * - * This cache aims to deliver speedy Edge Config reads during development, though it comes - * at the cost of delayed visibility for updates to Edge Config. Typically, you may need to - * refresh twice to observe these changes as the stale value is replaced. - * - * This cache is not used in preview or production deployments as superior optimisations are applied there. - */ - disableDevelopmentCache?: boolean; - - /** - * Sets a `cache` option on the `fetch` call made by Edge Config. - * - * Unlike Next.js, this defaults to `no-store`, as you most likely want to use Edge Config dynamically. - */ - cache?: 'no-store' | 'force-cache'; -} - -class Controller { - private edgeConfig: EmbeddedEdgeConfig | null = null; - private connection: Connection; - private status: 'pristine' | 'refreshing' | 'stale' = 'pristine'; - private options: EdgeConfigClientOptions; - private shouldUseDevelopmentCache: boolean; - - private pendingEdgeConfigPromise: Promise | null = - null; - - constructor( - connection: Connection, - options: EdgeConfigClientOptions, - shouldUseDevelopmentCache: boolean, - ) { - this.connection = connection; - this.options = options; - this.shouldUseDevelopmentCache = shouldUseDevelopmentCache; - } - - public async get( - key: string, - localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T | undefined; digest: string }> { - return enhancedFetch( - `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, - { - headers: this.getHeaders(localOptions), - cache: this.getFetchCache(), - }, - ).then<{ value: T | undefined; digest: string }>(async (res) => { - const digest = res.headers.get('x-edge-config-digest'); - - if (!digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - - if (res.ok) { - const value = (await res.json()) as T; - return { value, digest }; - } - - await consumeResponseBody(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 { value: undefined, digest }; - // 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); - } - throw new UnexpectedNetworkError(res); - }); - } - - public async has( - key: string, - localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ exists: boolean; digest: string }> { - return fetch( - `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, - { - method: 'HEAD', - headers: this.getHeaders(localOptions), - cache: this.getFetchCache(), - }, - ).then<{ exists: boolean; digest: string }>((res) => { - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - const digest = res.headers.get('x-edge-config-digest'); - - if (!digest) { - // 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 { digest, exists: res.status !== 404 }; - throw new UnexpectedNetworkError(res); - }); - } - - public async digest( - localOptions?: Pick, - ): Promise { - return enhancedFetch( - `${this.connection.baseUrl}/digest?version=${this.connection.version}`, - { - headers: this.getHeaders(localOptions), - cache: this.getFetchCache(), - }, - ).then(async (res) => { - if (res.ok) return res.json() as Promise; - await consumeResponseBody(res); - - // if (res.cachedResponseBody !== undefined) - // return res.cachedResponseBody as string; - throw new UnexpectedNetworkError(res); - }); - } - - public async getMultiple( - keys: (keyof T)[], - localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T; digest: string }> { - if (!Array.isArray(keys)) { - throw new Error('@vercel/edge-config: keys must be an array'); - } - - // Return early if there are no keys to be read. - // This is only possible if the digest is not required, or if we have a - // cached digest (not implemented yet). - if (!localOptions?.metadata && keys.length === 0) { - return { value: {} as T, digest: '' }; - } - - const search = new URLSearchParams( - keys - .filter((key) => typeof key === 'string' && !isEmptyKey(key)) - .map((key) => ['key', key] as [string, string]), - ).toString(); - - return enhancedFetch( - `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, - { - headers: this.getHeaders(localOptions), - cache: this.getFetchCache(), - }, - ).then<{ value: T; digest: string }>(async (res) => { - if (res.ok) { - const digest = res.headers.get('x-edge-config-digest'); - if (!digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - const value = (await res.json()) as T; - return { value, digest }; - } - await consumeResponseBody(res); - - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - // the /items endpoint never returns 404, so if we get a 404 - // it means the edge config itself did not exist - if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - // if (res.cachedResponseBody !== undefined) - // return res.cachedResponseBody as T; - throw new UnexpectedNetworkError(res); - }); - } - - public async getAll( - localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T; digest: string }> { - return enhancedFetch( - `${this.connection.baseUrl}/items?version=${this.connection.version}`, - { - headers: this.getHeaders(localOptions), - cache: this.getFetchCache(), - }, - ).then<{ value: T; digest: string }>(async (res) => { - if (res.ok) { - const digest = res.headers.get('x-edge-config-digest'); - if (!digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - const value = (await res.json()) as T; - return { value, digest }; - } - await consumeResponseBody(res); - - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - // the /items endpoint never returns 404, so if we get a 404 - // it means the edge config itself did not exist - if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - // if (res.cachedResponseBody !== undefined) - // return res.cachedResponseBody as T; - throw new UnexpectedNetworkError(res); - }); - } - - private getFetchCache(): 'no-store' | 'force-cache' { - return this.options.cache || 'no-store'; - } - - private getHeaders( - localOptions: EdgeConfigFunctionsOptions | undefined, - ): Headers { - const headers: Record = { - Authorization: `Bearer ${this.connection.token}`, - }; - const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) addConsistentReadHeader(localHeaders); - - return localHeaders; - } -} - /** * Create an Edge Config client. * @@ -420,7 +158,7 @@ export const createClient = trace( if (localOptions?.consistentRead) addConsistentReadHeader(localHeaders); - return enhancedFetch(`${baseUrl}/digest?version=${version}`, { + return fetch(`${baseUrl}/digest?version=${version}`, { headers: localHeaders, cache: fetchCache, }).then(async (res) => { From 0e098ff4635e6e3c41d82ddf77050cd18840c830 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 28 Jul 2025 08:25:34 +0300 Subject: [PATCH 14/81] enhanced-fetch --- .../edge-config/src/utils/enhanced-fetch.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/enhanced-fetch.ts index 832acd807..6e262e68a 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -3,7 +3,7 @@ import { trace } from './tracing'; interface CachedResponseEntry { etag: string; response: string; - headers: Record; + headers: Headers; status: number; time: number; } @@ -102,7 +102,7 @@ export const enhancedFetch = trace( async function enhancedFetch( url: string, options: FetchOptions = {}, - ): Promise { + ): Promise { const { headers: customHeaders = new Headers(), ...customOptions } = options; const authHeader = customHeaders.get('Authorization'); @@ -111,7 +111,11 @@ export const enhancedFetch = trace( const cachedResponseEntry = cache.get(cacheKey); if (cachedResponseEntry) { - const { etag, response: cachedResponse } = cachedResponseEntry; + const { + etag, + response: cachedResponse, + headers: cachedResponseHeaders, + } = cachedResponseEntry; const headers = new Headers(customHeaders); headers.set('If-None-Match', etag); @@ -126,30 +130,35 @@ export const enhancedFetch = trace( ); if (res.status === 304) { + console.log('resolving from cache'); res.cachedResponseBody = JSON.parse(cachedResponse); - res.cachedResponseHeaders = new Headers(res.headers); + res.cachedResponseHeaders = cachedResponseHeaders; return res; } const newETag = res.headers.get('ETag'); - if (res.ok && newETag) + if (res.ok && newETag) { + console.log('filling cache'); cache.set(cacheKey, { etag: newETag, response: await res.clone().text(), - headers: Object.fromEntries(res.headers.entries()), + headers: new Headers(res.headers), status: res.status, time: Date.now(), }); + } return res; } + console.log('resolving from network'); + const res = await fetch(url, options); const etag = res.headers.get('ETag'); if (res.ok && etag) { cache.set(cacheKey, { etag, response: await res.clone().text(), - headers: Object.fromEntries(res.headers.entries()), + headers: new Headers(res.headers), status: res.status, time: Date.now(), }); @@ -158,7 +167,7 @@ export const enhancedFetch = trace( return res; }, { - name: 'enhancedFetch', + name: 'fetchWithCachedResponse', attributesSuccess(result) { return { status: result.status, From 8be32dda96f12909f9fd5ab70c0625179965e41f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 28 Jul 2025 08:38:30 +0300 Subject: [PATCH 15/81] clean up --- packages/edge-config/src/controller.ts | 40 ++++++++++++-------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index b91e75daf..4a5af5652 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -1,4 +1,3 @@ -import { time } from 'node:console'; import type { EdgeConfigValue, EmbeddedEdgeConfig, @@ -19,6 +18,13 @@ export function setTimestampOfLatestUpdate(timestamp: number): void { timestampOfLatestUpdate = timestamp; } +function parseTs(updatedAt: string | null): number | null { + if (!updatedAt) return null; + const parsed = Number.parseInt(updatedAt, 10); + if (Number.isNaN(parsed)) return null; + return parsed; +} + export class Controller { private edgeConfigCache: (EmbeddedEdgeConfig & { updatedAt: number }) | null = null; @@ -188,14 +194,7 @@ export class Controller { } // otherwise, create a new promise - - const promise = this.fetchItem( - key, - timestampOfLatestUpdate, - localOptions, - ); - - return promise; + return this.fetchItem(key, timestampOfLatestUpdate, localOptions); } private fetchItem( @@ -215,19 +214,22 @@ export class Controller { }, ).then<{ value: T | undefined; digest: string; source: Source }>( async (res) => { - // TODO deal with filling the cache const digest = res.headers.get('x-edge-config-digest'); - - if (!digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } + const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); if (res.ok) { const value = (await res.json()) as T; // TODO this header is not present on responses of the real API currently, // but we mock it in tests already - const updatedAt = Number(res.headers.get('x-edge-config-updated-at')); - this.itemCache.set(key, { value, updatedAt, digest }); + + // set the cache if the loaded value is newer than the cached one + if (updatedAt) { + const existing = this.itemCache.get(key); + if (!existing || existing.updatedAt < updatedAt) { + this.itemCache.set(key, { value, updatedAt, digest }); + } + } return { value, digest, source: 'network-blocking' }; } @@ -237,11 +239,7 @@ export class Controller { 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 (digest) { - const updatedAt = Number( - res.headers.get('x-edge-config-updated-at'), - ); - + if (digest && updatedAt) { this.itemCache.set(key, { value: undefined, updatedAt, digest }); return { value: undefined, digest, source: 'network-blocking' }; } From 04daec8c84fc93a99a2dfba5ab42df69617272af Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 28 Jul 2025 23:12:52 +0300 Subject: [PATCH 16/81] rename source --- packages/edge-config/src/controller.test.ts | 10 +- packages/edge-config/src/controller.ts | 125 +++++++++++++------- packages/edge-config/src/types.ts | 8 +- 3 files changed, 89 insertions(+), 54 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 7bed71b63..8592b4090 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -32,14 +32,14 @@ describe('controller', () => { await expect(controller.get('key1')).resolves.toEqual({ value: 'value1', digest: 'digest1', - source: 'network-blocking', + source: 'MISS', }); // cache HIT after await expect(controller.get('key1')).resolves.toEqual({ value: 'value1', digest: 'digest1', - source: 'cached-fresh', + source: 'HIT', }); // should not fetch again @@ -57,7 +57,7 @@ describe('controller', () => { await expect(controller.get('key1')).resolves.toEqual({ value: 'value1', digest: 'digest1', - source: 'cached-stale', + source: 'STALE', }); // should fetch again in background @@ -70,7 +70,7 @@ describe('controller', () => { await expect(controller.get('key1')).resolves.toEqual({ value: 'value2', digest: 'digest2', - source: 'cached-fresh', + source: 'HIT', }); // exceeds stale threshold should lead to cache MISS and blocking fetch @@ -85,7 +85,7 @@ describe('controller', () => { await expect(controller.get('key1')).resolves.toEqual({ value: 'value3', digest: 'digest3', - source: 'network-blocking', + source: 'MISS', }); // needs to fetch again diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 4a5af5652..995f90862 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -18,6 +18,14 @@ export function setTimestampOfLatestUpdate(timestamp: number): void { timestampOfLatestUpdate = timestamp; } +function canReusePendingFetch( + pending: { minUpdatedAt: number } | undefined, + requiredMinUpdatedAt: number, +): boolean { + if (!pending) return false; + return pending.minUpdatedAt >= requiredMinUpdatedAt; +} + function parseTs(updatedAt: string | null): number | null { if (!updatedAt) return null; const parsed = Number.parseInt(updatedAt, 10); @@ -54,8 +62,12 @@ export class Controller { } >(); - private pendingEdgeConfigPromise: Promise | null = - null; + private pendingEdgeConfigPromise: + | { + minUpdatedAt: number; + promise: Promise; + } + | undefined = undefined; constructor( connection: Connection, @@ -78,11 +90,8 @@ export class Controller { // pick newer version on HIT // otherwise - // blocking fetch if MISS // background fetch if STALE - - // const [state, effects] = reduce(state) - // await processEffects(effects) + // blocking fetch if MISS // only use the cache if we have a timestamp of the latest update if (timestampOfLatestUpdate) { @@ -121,50 +130,71 @@ export class Controller { } if (cached) { + // HIT if (timestampOfLatestUpdate === cached.updatedAt) { return { value: cached.value, digest: cached.digest, - source: 'cached-fresh', + source: 'HIT', }; } - if (timestampOfLatestUpdate > cached.updatedAt) { - // we're outdated, but check if we can serve the STALE value - if ( - cached.updatedAt >= - timestampOfLatestUpdate - this.staleThreshold - ) { - // background refresh - // reuse existing promise if there is one - const pendingPromise = this.pendingItemFetches.get(key); - if ( - pendingPromise && - pendingPromise.minUpdatedAt >= timestampOfLatestUpdate && - // ensure the pending promise can not end up being stale - pendingPromise.minUpdatedAt + this.staleThreshold >= - timestampOfLatestUpdate - ) { - // do nothing - } else { - // TODO cancel existing pending fetch with an AbortController if - // there is one? does this lead to problems if it is being awaited - // by a blocking read? - // - // TODO use waitUntil? - void this.fetchItem( - key, - timestampOfLatestUpdate, - localOptions, - ).catch(() => null); - } - return { - value: cached.value, - digest: cached.digest, - source: 'cached-stale', - }; + // STALE + // we're outdated, but check if we can serve the STALE value + if ( + timestampOfLatestUpdate > cached.updatedAt && + cached.updatedAt >= timestampOfLatestUpdate - this.staleThreshold + ) { + // background refresh + // reuse existing promise if there is one + const pendingItemPromise = this.pendingItemFetches.get(key); + const pendingEdgeConfigPromise = this.pendingEdgeConfigPromise; + + const canReusePendingItemFetch = canReusePendingFetch( + pendingItemPromise, + timestampOfLatestUpdate, + ); + const canReusePendingEdgeConfigFetch = canReusePendingFetch( + pendingEdgeConfigPromise, + timestampOfLatestUpdate, + ); + + if (!canReusePendingItemFetch && !canReusePendingEdgeConfigFetch) { + // trigger a new fetch if we can't reuse the pending fetches + void this.fetchItem( + key, + timestampOfLatestUpdate, + localOptions, + ).catch(() => null); } + // if ( + // pendingItemPromise && + // pendingItemPromise.minUpdatedAt >= timestampOfLatestUpdate && + // // ensure the pending promise can not end up being stale + // pendingItemPromise.minUpdatedAt + this.staleThreshold >= + // timestampOfLatestUpdate + // ) { + // // do nothing + // } else { + // // TODO cancel existing pending fetch with an AbortController if + // // there is one? does this lead to problems if it is being awaited + // // by a blocking read? + // // + // // TODO use waitUntil? + // void this.fetchItem( + // key, + // timestampOfLatestUpdate, + // localOptions, + // ).catch(() => null); + // } + + return { + value: cached.value, + digest: cached.digest, + source: 'STALE', + }; + // we're outdated, but we can't serve the STALE value // so we need to fetch the latest value in a BLOCKING way and then // update the cache afterwards @@ -174,6 +204,7 @@ export class Controller { } } + // MISS, with pending fetch // reuse existing promise if there is one const pendingPromise = this.pendingItemFetches.get(key); if ( @@ -193,6 +224,7 @@ export class Controller { } } + // MISS, without pending fetch // otherwise, create a new promise return this.fetchItem(key, timestampOfLatestUpdate, localOptions); } @@ -230,7 +262,7 @@ export class Controller { this.itemCache.set(key, { value, updatedAt, digest }); } } - return { value, digest, source: 'network-blocking' }; + return { value, digest, source: 'MISS' }; } await consumeResponseBody(res); @@ -240,8 +272,11 @@ export class Controller { // if the x-edge-config-digest header is present, it means // the edge config exists, but the item does not if (digest && updatedAt) { - this.itemCache.set(key, { value: undefined, updatedAt, digest }); - return { value: undefined, digest, source: 'network-blocking' }; + const existing = this.itemCache.get(key); + if (!existing || existing.updatedAt < updatedAt) { + this.itemCache.set(key, { value: undefined, updatedAt, digest }); + } + return { value: undefined, digest, source: 'MISS' }; } // if the x-edge-config-digest header is not present, it means // the edge config itself does not exist @@ -284,7 +319,7 @@ export class Controller { return { digest, exists: res.status !== 404, - source: 'network-blocking', + source: 'MISS', }; throw new UnexpectedNetworkError(res); }); diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index e0a5f3352..180611c61 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -187,7 +187,7 @@ export interface EdgeConfigClientOptions { } export type Source = - | 'cached-fresh' // value is cached and deemed fresh - | 'cached-stale' // value is cached but we know it's outdated - | 'network-blocking' // value was fetched over network as the staleThreshold was exceeded - | 'network-consistent'; // value was fetched over the network as a consistent read was requested + | 'HIT' // value is cached and deemed fresh + | 'STALE' // value is cached but we know it's outdated + | 'MISS' // value was fetched over network as the staleThreshold was exceeded + | 'BYPASS'; // value was fetched over the network as a consistent read was requested From 48077bbb48282605473bfc8b4c660707fcb0d927 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 28 Jul 2025 23:40:38 +0300 Subject: [PATCH 17/81] add test --- packages/edge-config/src/controller.test.ts | 47 +++++++++++++++++++++ packages/edge-config/tsconfig.json | 3 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 8592b4090..ce01790c3 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -91,4 +91,51 @@ describe('controller', () => { // needs to fetch again expect(fetchMock).toHaveBeenCalledTimes(3); }); + + it('should dedupe within a version', async () => { + const controller = new Controller(connection, {}, false); + + setTimestampOfLatestUpdate(1000); + + const { promise, resolve } = Promise.withResolvers(); + + fetchMock.mockResolvedValueOnce(promise); + + // blocking fetches first, which should get deduped + const read1 = controller.get('key1'); + const read2 = controller.get('key1'); + + resolve( + new Response(JSON.stringify('value1'), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + }, + }), + ); + + await expect(read1).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + source: 'MISS', + }); + + // reuses the pending fetch promise + await expect(read2).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + source: 'MISS', + }); + + // hits the cache + const read3 = controller.get('key1'); + await expect(read3).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + source: 'HIT', + }); + + // + expect(fetchMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/edge-config/tsconfig.json b/packages/edge-config/tsconfig.json index 61cf73f43..58e75b0a2 100644 --- a/packages/edge-config/tsconfig.json +++ b/packages/edge-config/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "tsconfig/base.json", "compilerOptions": { - "resolveJsonModule": true + "resolveJsonModule": true, + "target": "ES2024" }, "include": ["src"] } From 622790d7943de206d51a1827f50ceeafa9db58f8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 29 Jul 2025 10:37:25 +0300 Subject: [PATCH 18/81] use dev cache --- packages/edge-config/.node-version | 1 + packages/edge-config/src/controller.test.ts | 44 ++++++++++++ packages/edge-config/src/controller.ts | 75 +++++++++++++++++++-- 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 packages/edge-config/.node-version diff --git a/packages/edge-config/.node-version b/packages/edge-config/.node-version new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/packages/edge-config/.node-version @@ -0,0 +1 @@ +22 diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index ce01790c3..e58a4c653 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -139,3 +139,47 @@ describe('controller', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); + +describe('development cache', () => { + it('should work', async () => { + const controller = new Controller(connection, {}, true); + + fetchMock.mockResponseOnce(JSON.stringify('value1'), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + }, + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + source: 'MISS', + }); + + fetchMock.mockResponse(JSON.stringify('value2'), { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '1001', + }, + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + source: 'HIT', + }); + + await Promise.resolve(); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value2', + digest: 'digest2', + source: 'HIT', + }); + + await Promise.resolve(); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 995f90862..39c78631e 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -34,12 +34,18 @@ function parseTs(updatedAt: string | null): number | null { } export class Controller { - private edgeConfigCache: (EmbeddedEdgeConfig & { updatedAt: number }) | null = - null; + private edgeConfigCache: + | (EmbeddedEdgeConfig & { updatedAt: number; responseTimestamp: number }) + | null = null; private itemCache = new Map< string, // an undefined value signals the key does not exist - { value: EdgeConfigValue | undefined; updatedAt: number; digest: string } + { + value: EdgeConfigValue | undefined; + updatedAt: number; + digest: string; + responseTimestamp: number; + } >(); private connection: Connection; @@ -84,6 +90,55 @@ export class Controller { key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T | undefined; digest: string; source: Source }> { + if (this.shouldUseDevelopmentCache) { + const cache = this.itemCache.get(key); + if (cache) { + // background refresh, but only if cached value is older than 1 second + // const pending = this.pendingItemFetches.get(key); + // if (pending) { + // return pending.promise.then( + // (v) => { + // if ( + // this.pendingItemFetches.get(key)?.promise === pending.promise + // ) { + // this.pendingItemFetches.delete(key); + // } + // return v; + // }, + // (err) => { + // if ( + // this.pendingItemFetches.get(key)?.promise === pending.promise + // ) { + // this.pendingItemFetches.delete(key); + // } + // throw err; + // }, + // ) as Promise<{ + // value: T | undefined; + // digest: string; + // source: Source; + // }>; + // } + + this.pendingItemFetches.set(key, { + promise: this.fetchItem( + key, + timestampOfLatestUpdate, + localOptions, + ), + minUpdatedAt: Date.now(), + }); + + return { + value: cache.value as T | undefined, + digest: cache.digest, + source: 'HIT', + }; + } + + return this.fetchItem(key, timestampOfLatestUpdate, localOptions); + } + // check full config cache // check item cache // @@ -259,7 +314,12 @@ export class Controller { if (updatedAt) { const existing = this.itemCache.get(key); if (!existing || existing.updatedAt < updatedAt) { - this.itemCache.set(key, { value, updatedAt, digest }); + this.itemCache.set(key, { + value, + updatedAt, + digest, + responseTimestamp: Date.now(), + }); } } return { value, digest, source: 'MISS' }; @@ -274,7 +334,12 @@ export class Controller { if (digest && updatedAt) { const existing = this.itemCache.get(key); if (!existing || existing.updatedAt < updatedAt) { - this.itemCache.set(key, { value: undefined, updatedAt, digest }); + this.itemCache.set(key, { + value: undefined, + updatedAt, + digest, + responseTimestamp: Date.now(), + }); } return { value: undefined, digest, source: 'MISS' }; } From a7d768541eef0bdf51c0dfb3e8ec7fa9c10ca852 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 29 Jul 2025 15:32:23 +0300 Subject: [PATCH 19/81] remove shouldUseDevelopmentCache boolean --- packages/edge-config/src/controller.test.ts | 49 +++++++++++++-- packages/edge-config/src/controller.ts | 67 ++++----------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index e58a4c653..2a49ecf38 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -17,7 +17,7 @@ beforeEach(() => { describe('controller', () => { it('should work', async () => { - const controller = new Controller(connection, {}, false); + const controller = new Controller(connection, {}); setTimestampOfLatestUpdate(1000); @@ -93,7 +93,7 @@ describe('controller', () => { }); it('should dedupe within a version', async () => { - const controller = new Controller(connection, {}, false); + const controller = new Controller(connection, {}); setTimestampOfLatestUpdate(1000); @@ -141,8 +141,49 @@ describe('controller', () => { }); describe('development cache', () => { - it('should work', async () => { - const controller = new Controller(connection, {}, true); + it('should fetch on every read', async () => { + setTimestampOfLatestUpdate(undefined); + const controller = new Controller(connection, {}); + + fetchMock.mockResponseOnce(JSON.stringify('value1'), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + }, + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + source: 'MISS', + }); + + fetchMock.mockResponse(JSON.stringify('value2'), { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '1000', + }, + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value2', + digest: 'digest2', + source: 'MISS', + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value2', + digest: 'digest2', + source: 'MISS', + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + // eslint-disable-next-line jest/no-disabled-tests -- not implemented yet + it.skip('should work', async () => { + setTimestampOfLatestUpdate(undefined); + const controller = new Controller(connection, {}); fetchMock.mockResponseOnce(JSON.stringify('value1'), { headers: { diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 39c78631e..b4eb920cc 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -14,7 +14,9 @@ const DEFAULT_STALE_THRESHOLD = 10_000; // 10 seconds let timestampOfLatestUpdate: number | undefined; -export function setTimestampOfLatestUpdate(timestamp: number): void { +export function setTimestampOfLatestUpdate( + timestamp: number | undefined, +): void { timestampOfLatestUpdate = timestamp; } @@ -48,8 +50,15 @@ export class Controller { } >(); + /** + * While in development we use SWR-like behavior for the api client to + * reduce latency. + */ + private isDevEnvironment = + process.env.NODE_ENV === 'development' && + process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; + private connection: Connection; - private shouldUseDevelopmentCache: boolean; private staleThreshold: number; private cacheMode: 'no-store' | 'force-cache'; @@ -75,13 +84,8 @@ export class Controller { } | undefined = undefined; - constructor( - connection: Connection, - options: EdgeConfigClientOptions, - shouldUseDevelopmentCache: boolean, - ) { + constructor(connection: Connection, options: EdgeConfigClientOptions) { this.connection = connection; - this.shouldUseDevelopmentCache = shouldUseDevelopmentCache; this.staleThreshold = options.staleThreshold ?? DEFAULT_STALE_THRESHOLD; this.cacheMode = options.cache || 'no-store'; } @@ -90,52 +94,7 @@ export class Controller { key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T | undefined; digest: string; source: Source }> { - if (this.shouldUseDevelopmentCache) { - const cache = this.itemCache.get(key); - if (cache) { - // background refresh, but only if cached value is older than 1 second - // const pending = this.pendingItemFetches.get(key); - // if (pending) { - // return pending.promise.then( - // (v) => { - // if ( - // this.pendingItemFetches.get(key)?.promise === pending.promise - // ) { - // this.pendingItemFetches.delete(key); - // } - // return v; - // }, - // (err) => { - // if ( - // this.pendingItemFetches.get(key)?.promise === pending.promise - // ) { - // this.pendingItemFetches.delete(key); - // } - // throw err; - // }, - // ) as Promise<{ - // value: T | undefined; - // digest: string; - // source: Source; - // }>; - // } - - this.pendingItemFetches.set(key, { - promise: this.fetchItem( - key, - timestampOfLatestUpdate, - localOptions, - ), - minUpdatedAt: Date.now(), - }); - - return { - value: cache.value as T | undefined, - digest: cache.digest, - source: 'HIT', - }; - } - + if (this.isDevEnvironment || !timestampOfLatestUpdate) { return this.fetchItem(key, timestampOfLatestUpdate, localOptions); } From 406a1d71350687aeb22fc46792aa9cb7ccbc4151 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 29 Jul 2025 20:39:39 +0300 Subject: [PATCH 20/81] start enhanced-fetch --- packages/edge-config/package.json | 1 - packages/edge-config/src/controller.test.ts | 2 + packages/edge-config/src/controller.ts | 5 +- .../src/utils/enhanced-fetch.test.ts | 296 ++++--------- .../edge-config/src/utils/enhanced-fetch.ts | 406 +++++++++++------- pnpm-lock.yaml | 155 ++++++- 6 files changed, 482 insertions(+), 383 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index d57d6a27d..1b0e967ec 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -43,7 +43,6 @@ ], "testEnvironment": "node" }, - "dependencies": {}, "devDependencies": { "@changesets/cli": "2.28.1", "@edge-runtime/jest-environment": "2.3.10", diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 2a49ecf38..07d3cc924 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -25,6 +25,7 @@ describe('controller', () => { headers: { 'x-edge-config-digest': 'digest1', 'x-edge-config-updated-at': '1000', + etag: '"digest1"', }, }); @@ -51,6 +52,7 @@ describe('controller', () => { headers: { 'x-edge-config-digest': 'digest2', 'x-edge-config-updated-at': '7000', + etag: '"digest2"', }, }); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index b4eb920cc..e4affe6a8 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -9,6 +9,9 @@ import type { import { ERRORS, isEmptyKey, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; import { addConsistentReadHeader } from './utils/add-consistent-read-header'; +import { createEnhancedFetch } from './utils/enhanced-fetch'; + +const enhancedFetch = createEnhancedFetch(); const DEFAULT_STALE_THRESHOLD = 10_000; // 10 seconds @@ -252,7 +255,7 @@ export class Controller { digest: string; source: Source; }> { - const promise = fetch( + const promise = enhancedFetch( `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, { headers: this.getHeaders(localOptions), diff --git a/packages/edge-config/src/utils/enhanced-fetch.test.ts b/packages/edge-config/src/utils/enhanced-fetch.test.ts index 0860d5b96..b5b056cac 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.test.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.test.ts @@ -1,214 +1,108 @@ import fetchMock from 'jest-fetch-mock'; -import { enhancedFetch, cache } from './enhanced-fetch'; - -jest.useFakeTimers(); - -describe('cache', () => { - it('should be an object', () => { - expect(typeof cache).toEqual('object'); - }); -}); +import { createEnhancedFetch } from './enhanced-fetch'; describe('enhancedFetch', () => { + let enhancedFetch: ReturnType; + beforeEach(() => { + enhancedFetch = createEnhancedFetch(); fetchMock.resetMocks(); - cache.clear(); - }); - - it('should cache responses and return them from cache', async () => { - fetchMock.mockResponseOnce(JSON.stringify({ name: 'John' }), { - headers: { ETag: 'abc123', 'content-type': 'application/json' }, - }); - - // First request - const data1 = await enhancedFetch('https://example.com/api/data'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', {}); - expect(data1.headers).toEqual( - new Headers({ - ETag: 'abc123', - 'content-type': 'application/json', - }), - ); - await expect(data1.json()).resolves.toEqual({ name: 'John' }); - expect(data1.cachedResponseBody).toBeUndefined(); - - // Second request (should come from cache) - fetchMock.mockResponseOnce('', { - status: 304, - headers: { - ETag: 'abc123', - 'content-type': 'application/json', - }, - }); - const data2 = await enhancedFetch('https://example.com/api/data'); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', { - headers: new Headers({ 'If-None-Match': 'abc123' }), - }); - expect(data2.headers).toEqual( - new Headers({ ETag: 'abc123', 'content-type': 'application/json' }), - ); - - expect(data2).toHaveProperty('status', 304); - expect(data2.cachedResponseBody).toEqual({ name: 'John' }); - }); - - it('should differentiate caches by authorization header', async () => { - fetchMock.mockResponseOnce(JSON.stringify({ name: 'John' }), { - headers: { - ETag: 'abc123', - 'content-type': 'application/json', - }, - }); - - // First request - const data1 = await enhancedFetch('https://example.com/api/data', { - headers: new Headers({ authorization: 'bearer A' }), - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', { - headers: new Headers({ authorization: 'bearer A' }), - }); - expect(data1.headers).toEqual( - new Headers({ ETag: 'abc123', 'content-type': 'application/json' }), - ); - await expect(data1.json()).resolves.toEqual({ name: 'John' }); - - // Second request uses a different authorization header => do not use cache - fetchMock.mockResponseOnce(JSON.stringify({ name: 'Bob' }), { - headers: { ETag: 'abc123', 'content-type': 'application/json' }, - }); - const data2 = await enhancedFetch('https://example.com/api/data', { - // using a different authorization header here - headers: new Headers({ authorization: 'bearer B' }), - }); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', { - headers: new Headers({ authorization: 'bearer B' }), - }); - expect(data2.headers).toEqual( - new Headers({ ETag: 'abc123', 'content-type': 'application/json' }), - ); - expect(data2).toHaveProperty('status', 200); - expect(data2.cachedResponseBody).toBeUndefined(); - await expect(data2.json()).resolves.toEqual({ - name: 'Bob', - }); - - // Third request uses same auth header as first request => use cache - fetchMock.mockResponseOnce('', { - status: 304, - headers: { ETag: 'abc123', 'content-type': 'application/json' }, - }); - const data3 = await enhancedFetch('https://example.com/api/data', { - headers: new Headers({ authorization: 'bearer A' }), - }); - - expect(fetchMock).toHaveBeenCalledTimes(3); - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', { - headers: new Headers({ - 'If-None-Match': 'abc123', - authorization: 'bearer A', - }), - }); - expect(data3.headers).toEqual( - new Headers({ ETag: 'abc123', 'content-type': 'application/json' }), - ); - - expect(data3).toHaveProperty('status', 304); - expect(data3.cachedResponseBody).toEqual({ name: 'John' }); }); - it('should respect stale-if-error on 500s', async () => { - fetchMock.mockResponseOnce(JSON.stringify({ name: 'John' }), { - headers: { ETag: 'abc123', 'content-type': 'application/json' }, + describe('fetch deduplication', () => { + it('should return a function', () => { + expect(typeof enhancedFetch).toEqual('function'); + }); + + it('should deduplicate pending requests', async () => { + const { resolve, promise } = Promise.withResolvers(); + fetchMock.mockResolvedValue(promise); + const invocation1Promise = enhancedFetch('https://example.com/api/data'); + const invocation2Promise = enhancedFetch('https://example.com/api/data'); + const invocation3Promise = enhancedFetch('https://example.com/api/data'); + resolve(new Response(JSON.stringify({ name: 'John' }))); + const [res1, res2, res3] = await Promise.all([ + invocation1Promise, + invocation2Promise, + invocation3Promise, + ]); + expect(res1).toStrictEqual(res2); + expect(res1).toStrictEqual(res3); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should not deduplicate after a pending request finished', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ name: 'A' })); + fetchMock.mockResponseOnce(JSON.stringify({ name: 'B' })); + // by awaiting res1 we essentially allow the fetch cache to clear + const [res1] = await enhancedFetch('https://example.com/api/data'); + const [res2] = await enhancedFetch('https://example.com/api/data'); + expect(res1).not.toStrictEqual(res2); + expect(fetchMock).toHaveBeenCalledTimes(2); + await expect(res1.json()).resolves.toEqual({ name: 'A' }); + await expect(res2.json()).resolves.toEqual({ name: 'B' }); + }); + + it('should not deduplicate requests with different auth headers', async () => { + const invocation1 = Promise.withResolvers(); + const invocation2 = Promise.withResolvers(); + fetchMock.mockResolvedValueOnce(invocation1.promise); + fetchMock.mockResolvedValueOnce(invocation2.promise); + const invocation1Promise = enhancedFetch('https://example.com/api/data'); + const invocation2Promise = enhancedFetch('https://example.com/api/data', { + headers: new Headers({ Authorization: 'Bearer 1' }), + }); + invocation1.resolve(new Response(JSON.stringify({ name: 'A' }))); + invocation2.resolve(new Response(JSON.stringify({ name: 'B' }))); + const [res1, res2] = await Promise.all([ + invocation1Promise.then(([r]) => r), + invocation2Promise.then(([r]) => r), + ]); + expect(res1).not.toStrictEqual(res2); + expect(fetchMock).toHaveBeenCalledTimes(2); + await expect(res1.json()).resolves.toEqual({ name: 'A' }); + await expect(res2.json()).resolves.toEqual({ name: 'B' }); + }); + + it('should not deduplicate requests with different urls', async () => { + const invocation1 = Promise.withResolvers(); + const invocation2 = Promise.withResolvers(); + fetchMock.mockResolvedValueOnce(invocation1.promise); + fetchMock.mockResolvedValueOnce(invocation2.promise); + const invocation1Promise = enhancedFetch('https://example.com/a'); + const invocation2Promise = enhancedFetch('https://example.com/b'); + invocation1.resolve(new Response(JSON.stringify({ name: 'A' }))); + invocation2.resolve(new Response(JSON.stringify({ name: 'B' }))); + const [res1, res2] = await Promise.all([ + invocation1Promise.then(([r]) => r), + invocation2Promise.then(([r]) => r), + ]); + expect(res1).not.toStrictEqual(res2); + expect(fetchMock).toHaveBeenCalledTimes(2); + await expect(res1.json()).resolves.toEqual({ name: 'A' }); + await expect(res2.json()).resolves.toEqual({ name: 'B' }); }); - - // First request - const data1 = await enhancedFetch('https://example.com/api/data'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', {}); - expect(data1.headers).toEqual( - new Headers({ - ETag: 'abc123', - 'content-type': 'application/json', - }), - ); - await expect(data1.json()).resolves.toEqual({ name: 'John' }); - expect(data1.cachedResponseBody).toBeUndefined(); - - jest.advanceTimersByTime(5000); - - // Second request (should come from cache) - fetchMock.mockResponseOnce('', { status: 502 }); - const data2 = await enhancedFetch('https://example.com/api/data', { - headers: new Headers({ 'Cache-Control': 'stale-if-error=10' }), - }); - - jest.advanceTimersByTime(3000); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(data2.headers).toEqual( - new Headers({ - 'content-type': 'application/json', - // Age is present when a cached response was served as per HTTP spec - // And in this case a stale-if-error cached response is being served - Age: '5', - etag: 'abc123', - }), - ); - - expect(data2).toHaveProperty('status', 200); - await expect(data2.json()).resolves.toEqual({ name: 'John' }); }); - it('should respect stale-if-error on network faults', async () => { - fetchMock.mockResponseOnce(JSON.stringify({ name: 'John' }), { - headers: { ETag: 'abc123', 'content-type': 'application/json' }, - }); - - // First request - const data1 = await enhancedFetch('https://example.com/api/data'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/data', {}); - expect(data1.headers).toEqual( - new Headers({ - ETag: 'abc123', - 'content-type': 'application/json', - }), - ); - await expect(data1.json()).resolves.toEqual({ name: 'John' }); - expect(data1.cachedResponseBody).toBeUndefined(); - - jest.advanceTimersByTime(5000); - - // Second request (should come from cache) - fetchMock.mockAbortOnce(); - const data2 = await enhancedFetch('https://example.com/api/data', { - headers: new Headers({ 'Cache-Control': 'stale-if-error=10' }), + describe('etag and if-none-match', () => { + it('should return from the http cache if the response is not modified', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ name: 'A' }), { + headers: { + ETag: '123', + }, + }); + fetchMock.mockResponseOnce('', { + status: 304, + headers: { + ETag: '123', + }, + }); + const [res1] = await enhancedFetch('https://example.com/api/data'); + const [, cachedRes2] = await enhancedFetch( + 'https://example.com/api/data', + ); + expect(res1).toStrictEqual(cachedRes2); + expect(fetchMock).toHaveBeenCalledTimes(1); }); - - jest.advanceTimersByTime(3000); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(data2.headers).toEqual( - new Headers({ - 'content-type': 'application/json', - // Age is present when a cached response was served as per HTTP spec - // And in this case a stale-if-error cached response is being served - Age: '5', - etag: 'abc123', - }), - ); - - expect(data2).toHaveProperty('status', 200); - await expect(data2.json()).resolves.toEqual({ name: 'John' }); }); }); diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/enhanced-fetch.ts index 6e262e68a..ef379899c 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -1,177 +1,249 @@ -import { trace } from './tracing'; - -interface CachedResponseEntry { - etag: string; - response: string; - headers: Headers; - status: number; - time: number; -} - -type FetchOptions = Omit & { headers?: Headers }; - -interface ResponseWithCachedResponse extends Response { - cachedResponseBody?: unknown; - cachedResponseHeaders?: Headers; -} +// import { trace } from './tracing'; /** - * Creates a new response based on a cache entry - */ -function createResponse( - cachedResponseEntry: CachedResponseEntry, -): ResponseWithCachedResponse { - return new Response(cachedResponseEntry.response, { - headers: { - ...cachedResponseEntry.headers, - Age: String( - // age header may not be 0 when serving stale content, must be >= 1 - Math.max(1, Math.floor((Date.now() - cachedResponseEntry.time) / 1000)), - ), - }, - status: cachedResponseEntry.status, - }); -} - -/** - * Used for bad responses like 500s + * Generate a key for the dedupe cache + * + * We currently take only the url and authorization header into account. */ -function createHandleStaleIfError( - cachedResponseEntry: CachedResponseEntry, - staleIfError: number | null, -) { - return function handleStaleIfError( - response: ResponseWithCachedResponse, - ): ResponseWithCachedResponse { - switch (response.status) { - case 500: - case 502: - case 503: - case 504: - return typeof staleIfError === 'number' && - cachedResponseEntry.time < Date.now() + staleIfError * 1000 - ? createResponse(cachedResponseEntry) - : response; - default: - return response; - } - }; -} +function getDedupeCacheKey(url: string, init?: RequestInit): string { + const h = + init?.headers instanceof Headers + ? init.headers + : new Headers(init?.headers); -/** - * Used on network errors which end up throwing - */ -function createHandleStaleIfErrorException( - cachedResponseEntry: CachedResponseEntry, - staleIfError: number | null, -) { - return function handleStaleIfError( - reason: unknown, - ): ResponseWithCachedResponse { - if ( - typeof staleIfError === 'number' && - cachedResponseEntry.time < Date.now() + staleIfError * 1000 - ) { - return createResponse(cachedResponseEntry); - } - throw reason; - }; + return JSON.stringify({ url, authorization: h.get('Authorization') }); } -/** - * A cache of request urls & auth headers and the resulting responses. - * - * This cache does not use Response instances as the cache value as reusing - * responses across requests leads to issues in Cloudflare Workers. - */ -export const cache = new Map(); - -function extractStaleIfError(cacheControlHeader: string | null): number | null { - if (!cacheControlHeader) return null; - const matched = /stale-if-error=(?\d+)/i.exec( - cacheControlHeader, - ); - return matched?.groups ? Number(matched.groups.staleIfError) : null; +function getHttpCacheKey(url: string, init?: RequestInit): string { + const h = + init?.headers instanceof Headers + ? init.headers + : new Headers(init?.headers); + return JSON.stringify({ url, authorization: h.get('Authorization') }); } -/** - * This is similar to fetch, but it also implements ETag semantics, and - * it implmenets stale-if-error semantics. - */ -export const enhancedFetch = trace( - async function enhancedFetch( - url: string, - options: FetchOptions = {}, - ): Promise { - const { headers: customHeaders = new Headers(), ...customOptions } = - options; - const authHeader = customHeaders.get('Authorization'); - const cacheKey = `${url},${authHeader || ''}`; - - const cachedResponseEntry = cache.get(cacheKey); - - if (cachedResponseEntry) { - const { - etag, - response: cachedResponse, - headers: cachedResponseHeaders, - } = cachedResponseEntry; - const headers = new Headers(customHeaders); - headers.set('If-None-Match', etag); - - const staleIfError = extractStaleIfError(headers.get('Cache-Control')); - - const res: ResponseWithCachedResponse = await fetch(url, { - ...customOptions, - headers, - }).then( - createHandleStaleIfError(cachedResponseEntry, staleIfError), - createHandleStaleIfErrorException(cachedResponseEntry, staleIfError), - ); - - if (res.status === 304) { - console.log('resolving from cache'); - res.cachedResponseBody = JSON.parse(cachedResponse); - res.cachedResponseHeaders = cachedResponseHeaders; +export function createEnhancedFetch(): ( + url: string, + options?: RequestInit, +) => Promise<[Response, Response | null]> { + const pendingRequests = new Map>(); + const httpCache = new Map(); + + function fillHttpCache(httpCacheKey: string, res: Response): void { + httpCache.set(httpCacheKey, res.clone()); + } + + /** + * Gets a cached response for a given request url and response + * + * When we receive a 304 with matching etag we return the cached response. + */ + function getHttpCache( + httpCacheKey: string, + response: Response, + ): Response | null { + if (response.status !== 304) return null; + return null; + } + + return function enhancedFetch(url, options) { + const dedupeCacheKey = getDedupeCacheKey(url, options); + const httpCacheKey = getHttpCacheKey(url, options); + const pendingRequest = pendingRequests.get(dedupeCacheKey); + + /** + * Attaches the cached response + */ + const attach = (r: Response): [Response, Response | null] => [ + r, + getHttpCache(httpCacheKey, r), + ]; + + if (pendingRequest) return pendingRequest.then(attach); + + const promise = fetch(url, options) + .then((res) => { + fillHttpCache(httpCacheKey, res); return res; - } - - const newETag = res.headers.get('ETag'); - if (res.ok && newETag) { - console.log('filling cache'); - cache.set(cacheKey, { - etag: newETag, - response: await res.clone().text(), - headers: new Headers(res.headers), - status: res.status, - time: Date.now(), - }); - } - return res; - } - - console.log('resolving from network'); - - const res = await fetch(url, options); - const etag = res.headers.get('ETag'); - if (res.ok && etag) { - cache.set(cacheKey, { - etag, - response: await res.clone().text(), - headers: new Headers(res.headers), - status: res.status, - time: Date.now(), + }) + .finally(() => { + pendingRequests.delete(dedupeCacheKey); }); - } - - return res; - }, - { - name: 'fetchWithCachedResponse', - attributesSuccess(result) { - return { - status: result.status, - }; - }, - }, -); + pendingRequests.set(dedupeCacheKey, promise); + return promise.then(attach); + }; +} + +// interface CachedResponseEntry { +// etag: string; +// response: string; +// headers: Headers; +// status: number; +// time: number; +// } + +// type FetchOptions = Omit & { headers?: Headers }; + +// interface ResponseWithCachedResponse extends Response { +// cachedResponseBody?: unknown; +// cachedResponseHeaders?: Headers; +// } + +// /** +// * Creates a new response based on a cache entry +// */ +// function createResponse( +// cachedResponseEntry: CachedResponseEntry, +// ): ResponseWithCachedResponse { +// return new Response(cachedResponseEntry.response, { +// headers: { +// ...cachedResponseEntry.headers, +// Age: String( +// // age header may not be 0 when serving stale content, must be >= 1 +// Math.max(1, Math.floor((Date.now() - cachedResponseEntry.time) / 1000)), +// ), +// }, +// status: cachedResponseEntry.status, +// }); +// } + +// /** +// * Used for bad responses like 500s +// */ +// function createHandleStaleIfError( +// cachedResponseEntry: CachedResponseEntry, +// staleIfError: number | null, +// ) { +// return function handleStaleIfError( +// response: ResponseWithCachedResponse, +// ): ResponseWithCachedResponse { +// switch (response.status) { +// case 500: +// case 502: +// case 503: +// case 504: +// return typeof staleIfError === 'number' && +// cachedResponseEntry.time < Date.now() + staleIfError * 1000 +// ? createResponse(cachedResponseEntry) +// : response; +// default: +// return response; +// } +// }; +// } + +// /** +// * Used on network errors which end up throwing +// */ +// function createHandleStaleIfErrorException( +// cachedResponseEntry: CachedResponseEntry, +// staleIfError: number | null, +// ) { +// return function handleStaleIfError( +// reason: unknown, +// ): ResponseWithCachedResponse { +// if ( +// typeof staleIfError === 'number' && +// cachedResponseEntry.time < Date.now() + staleIfError * 1000 +// ) { +// return createResponse(cachedResponseEntry); +// } +// throw reason; +// }; +// } + +// /** +// * A cache of request urls & auth headers and the resulting responses. +// * +// * This cache does not use Response instances as the cache value as reusing +// * responses across requests leads to issues in Cloudflare Workers. +// */ +// export const cache = new Map(); + +// function extractStaleIfError(cacheControlHeader: string | null): number | null { +// if (!cacheControlHeader) return null; +// const matched = /stale-if-error=(?\d+)/i.exec( +// cacheControlHeader, +// ); +// return matched?.groups ? Number(matched.groups.staleIfError) : null; +// } + +// /** +// * This is similar to fetch, but it also implements ETag semantics, and +// * it implmenets stale-if-error semantics. +// */ +// export const enhancedFetch = trace( +// async function enhancedFetch( +// url: string, +// options: FetchOptions = {}, +// ): Promise { +// const { headers: customHeaders = new Headers(), ...customOptions } = +// options; +// const authHeader = customHeaders.get('Authorization'); +// const cacheKey = `${url},${authHeader || ''}`; + +// const cachedResponseEntry = cache.get(cacheKey); + +// if (cachedResponseEntry) { +// const { +// etag, +// response: cachedResponse, +// headers: cachedResponseHeaders, +// } = cachedResponseEntry; +// const headers = new Headers(customHeaders); +// headers.set('If-None-Match', etag); + +// const staleIfError = extractStaleIfError(headers.get('Cache-Control')); + +// const res: ResponseWithCachedResponse = await fetch(url, { +// ...customOptions, +// headers, +// }).then( +// createHandleStaleIfError(cachedResponseEntry, staleIfError), +// createHandleStaleIfErrorException(cachedResponseEntry, staleIfError), +// ); + +// if (res.status === 304) { +// console.log('resolving from cache'); +// res.cachedResponseBody = JSON.parse(cachedResponse); +// res.cachedResponseHeaders = cachedResponseHeaders; +// return res; +// } + +// const newETag = res.headers.get('ETag'); +// if (res.ok && newETag) { +// console.log('filling cache'); +// cache.set(cacheKey, { +// etag: newETag, +// response: await res.clone().text(), +// headers: new Headers(res.headers), +// status: res.status, +// time: Date.now(), +// }); +// } +// return res; +// } + +// const res = await fetch(url, options); +// const etag = res.headers.get('ETag'); +// if (res.ok && etag) { +// cache.set(cacheKey, { +// etag, +// response: await res.clone().text(), +// headers: new Headers(res.headers), +// status: res.status, +// time: Date.now(), +// }); +// } + +// return res; +// }, +// { +// name: 'fetchWithCachedResponse', +// attributesSuccess(result) { +// return { +// status: result.status, +// }; +// }, +// }, +// ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aedd3bc51..67dd9aa3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 0.3.7 ts-jest: specifier: 29.2.6 - version: 29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) + version: 29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) turbo: specifier: 2.4.4 version: 2.4.4 @@ -368,7 +368,7 @@ importers: version: 2.1.3 next: specifier: ^15.2.0 - version: 15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) postcss: specifier: ^8.5.3 version: 8.5.3 @@ -5811,26 +5811,56 @@ snapshots: dependencies: '@babel/types': 7.26.9 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5841,36 +5871,78 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -7008,10 +7080,10 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.0 - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 8.25.0(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/type-utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) @@ -7223,7 +7295,7 @@ snapshots: '@babel/core': 7.23.9 '@babel/eslint-parser': 7.23.10(@babel/core@7.23.9)(eslint@8.56.0) '@rushstack/eslint-patch': 1.7.2 - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) eslint-config-prettier: 9.1.0(eslint@8.56.0) eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1) @@ -7524,6 +7596,20 @@ snapshots: axobject-query@4.1.0: {} + babel-jest@29.7.0(@babel/core@7.23.9): + dependencies: + '@babel/core': 7.23.9 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.0 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.23.9) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + optional: true + babel-jest@29.7.0(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -7554,6 +7640,23 @@ snapshots: '@types/babel__core': 7.20.0 '@types/babel__traverse': 7.18.5 + babel-preset-current-node-syntax@1.0.1(@babel/core@7.23.9): + dependencies: + '@babel/core': 7.23.9 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.23.9) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.23.9) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.23.9) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.23.9) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.23.9) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.9) + optional: true + babel-preset-current-node-syntax@1.0.1(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -7570,6 +7673,13 @@ snapshots: '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.26.0) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.26.0) + babel-preset-jest@29.6.3(@babel/core@7.23.9): + dependencies: + '@babel/core': 7.23.9 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.9) + optional: true + babel-preset-jest@29.6.3(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -8368,7 +8478,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.15.0 eslint: 8.56.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 @@ -8406,7 +8516,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0): + eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -8433,7 +8543,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) hasown: 2.0.1 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -8484,7 +8594,7 @@ snapshots: '@typescript-eslint/utils': 5.62.0(eslint@8.56.0)(typescript@5.7.3) eslint: 8.56.0 optionalDependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) transitivePeerDependencies: - supports-color @@ -10254,7 +10364,7 @@ snapshots: natural-compare@1.4.0: {} - next@15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.2.0 '@swc/counter': 0.1.3 @@ -10264,7 +10374,7 @@ snapshots: postcss: 8.4.31 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.23.9)(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) optionalDependencies: '@next/swc-darwin-arm64': 15.2.0 '@next/swc-darwin-x64': 15.2.0 @@ -11238,12 +11348,12 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(@babel/core@7.23.9)(react@19.0.0): + styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.0.0): dependencies: client-only: 0.0.1 react: 19.0.0 optionalDependencies: - '@babel/core': 7.23.9 + '@babel/core': 7.26.0 sucrase@3.35.0: dependencies: @@ -11399,6 +11509,25 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.26.0) esbuild: 0.24.2 + ts-jest@29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): + dependencies: + bs-logger: 0.2.6 + ejs: 3.1.10 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.1 + typescript: 5.7.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.23.9 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.23.9) + ts-jest@29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 From 225c6fc259712eecc78465ae5e5e104d7c7f4857 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 29 Jul 2025 20:49:27 +0300 Subject: [PATCH 21/81] prepare more --- .../src/utils/enhanced-fetch.test.ts | 17 +++++++-------- .../edge-config/src/utils/enhanced-fetch.ts | 21 ++++++++++++++----- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/edge-config/src/utils/enhanced-fetch.test.ts b/packages/edge-config/src/utils/enhanced-fetch.test.ts index b5b056cac..dee2319ae 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.test.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.test.ts @@ -87,22 +87,21 @@ describe('enhancedFetch', () => { describe('etag and if-none-match', () => { it('should return from the http cache if the response is not modified', async () => { fetchMock.mockResponseOnce(JSON.stringify({ name: 'A' }), { - headers: { - ETag: '123', - }, + headers: { ETag: '"123"' }, }); fetchMock.mockResponseOnce('', { status: 304, - headers: { - ETag: '123', - }, + headers: { ETag: '"123"' }, }); const [res1] = await enhancedFetch('https://example.com/api/data'); - const [, cachedRes2] = await enhancedFetch( + const [res2, cachedRes2] = await enhancedFetch( 'https://example.com/api/data', ); - expect(res1).toStrictEqual(cachedRes2); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(res1).toHaveProperty('status', 200); + expect(res2).toHaveProperty('status', 304); + expect(cachedRes2).toHaveProperty('status', 200); + // expect(res1).toStrictEqual(cachedRes2); + // expect(fetchMock).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/enhanced-fetch.ts index ef379899c..db32a1b3a 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -29,8 +29,14 @@ export function createEnhancedFetch(): ( const pendingRequests = new Map>(); const httpCache = new Map(); - function fillHttpCache(httpCacheKey: string, res: Response): void { - httpCache.set(httpCacheKey, res.clone()); + function writeHttpCache( + url: string, + options: RequestInit | undefined, + httpCacheKey: string, + response: Response, + ): void { + // TODO store this under the specific etag + if (response.status === 200) httpCache.set(httpCacheKey, response.clone()); } /** @@ -38,11 +44,16 @@ export function createEnhancedFetch(): ( * * When we receive a 304 with matching etag we return the cached response. */ - function getHttpCache( + function readHttpCache( + url: string, + options: RequestInit | undefined, httpCacheKey: string, response: Response, ): Response | null { if (response.status !== 304) return null; + // TODO get the specific etag + const cachedResponse = httpCache.get(httpCacheKey); + if (cachedResponse) return cachedResponse.clone(); return null; } @@ -56,14 +67,14 @@ export function createEnhancedFetch(): ( */ const attach = (r: Response): [Response, Response | null] => [ r, - getHttpCache(httpCacheKey, r), + readHttpCache(url, options, httpCacheKey, r), ]; if (pendingRequest) return pendingRequest.then(attach); const promise = fetch(url, options) .then((res) => { - fillHttpCache(httpCacheKey, res); + writeHttpCache(url, options, httpCacheKey, res); return res; }) .finally(() => { From 956a5ce4f5fc3e6773cb3258fd5c7bcbcc6c16b1 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 30 Jul 2025 07:26:30 +0300 Subject: [PATCH 22/81] step --- packages/edge-config/package.json | 3 ++ .../edge-config/src/utils/enhanced-fetch.ts | 42 ++++++++++++++----- pnpm-lock.yaml | 15 +++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 1b0e967ec..08d0978af 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -43,6 +43,9 @@ ], "testEnvironment": "node" }, + "dependencies": { + "@httpland/etag-parser": "1.1.0" + }, "devDependencies": { "@changesets/cli": "2.28.1", "@edge-runtime/jest-environment": "2.3.10", diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/enhanced-fetch.ts index db32a1b3a..b0aa4a022 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -1,4 +1,5 @@ // import { trace } from './tracing'; +import { parseETag } from '@httpland/etag-parser'; /** * Generate a key for the dedupe cache @@ -14,12 +15,31 @@ function getDedupeCacheKey(url: string, init?: RequestInit): string { return JSON.stringify({ url, authorization: h.get('Authorization') }); } -function getHttpCacheKey(url: string, init?: RequestInit): string { +function getHttpCacheKey( + url: string, + init?: RequestInit, + /** + * Either the original 200 response if we're writing or the 304 response if we're reading + */ + response?: Response, +): string | null { const h = init?.headers instanceof Headers ? init.headers : new Headers(init?.headers); - return JSON.stringify({ url, authorization: h.get('Authorization') }); + try { + const etagHeader = response?.headers.get('ETag'); + if (!etagHeader) return null; + const etag = parseETag(etagHeader); + + return JSON.stringify({ + url, + authorization: h.get('Authorization'), + tag: etag.tag, + }); + } catch { + return null; + } } export function createEnhancedFetch(): ( @@ -32,11 +52,13 @@ export function createEnhancedFetch(): ( function writeHttpCache( url: string, options: RequestInit | undefined, - httpCacheKey: string, + /* the original 200 response */ + httpCacheKey: string | null, response: Response, ): void { - // TODO store this under the specific etag - if (response.status === 200) httpCache.set(httpCacheKey, response.clone()); + if (!httpCacheKey) return; + if (response.status !== 200) return; + httpCache.set(httpCacheKey, response.clone()); } /** @@ -47,11 +69,12 @@ export function createEnhancedFetch(): ( function readHttpCache( url: string, options: RequestInit | undefined, - httpCacheKey: string, + httpCacheKey: string | null, + /* the 304 response */ response: Response, ): Response | null { + if (!httpCacheKey) return null; if (response.status !== 304) return null; - // TODO get the specific etag const cachedResponse = httpCache.get(httpCacheKey); if (cachedResponse) return cachedResponse.clone(); return null; @@ -59,7 +82,6 @@ export function createEnhancedFetch(): ( return function enhancedFetch(url, options) { const dedupeCacheKey = getDedupeCacheKey(url, options); - const httpCacheKey = getHttpCacheKey(url, options); const pendingRequest = pendingRequests.get(dedupeCacheKey); /** @@ -67,14 +89,14 @@ export function createEnhancedFetch(): ( */ const attach = (r: Response): [Response, Response | null] => [ r, - readHttpCache(url, options, httpCacheKey, r), + readHttpCache(url, options, getHttpCacheKey(url, options, r), r), ]; if (pendingRequest) return pendingRequest.then(attach); const promise = fetch(url, options) .then((res) => { - writeHttpCache(url, options, httpCacheKey, res); + writeHttpCache(url, options, getHttpCacheKey(url, options, res), res); return res; }) .finally(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67dd9aa3e..dd00dad52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: packages/edge-config: dependencies: + '@httpland/etag-parser': + specifier: 1.1.0 + version: 1.1.0 '@opentelemetry/api': specifier: ^1.7.0 version: 1.7.0 @@ -1115,6 +1118,9 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} + '@httpland/etag-parser@1.1.0': + resolution: {integrity: sha512-l3eZdzJKQpKhP1WgFR+rlskZJUPC7wW+ffOs9vXX8ZhhN6S2JAUOcFWwhROzUN4N2FYcbnbwlSF2IG33ibagJw==} + '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -1380,6 +1386,9 @@ packages: '@microsoft/tsdoc@0.14.2': resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} + '@miyauci/isx@1.0.0': + resolution: {integrity: sha512-W3MhSwkunnVu1NPl6sSWX+TkzqeZiVmINQsNuTF6ukE7MIuPnWupQRB/ZWaH603CGhQ8MXQ1BvbMwfXkUirexw==} + '@neondatabase/serverless@0.10.2': resolution: {integrity: sha512-XaIMQ9fXDPWLiShfsg+YJQvmMMIyVQB7J3jnirckyoDxN7YIATyzXBThpUeFJqBUkbFJQ0e5PCxXTpK2rG4WbQ==} @@ -6402,6 +6411,10 @@ snapshots: '@fastify/busboy@2.1.1': {} + '@httpland/etag-parser@1.1.0': + dependencies: + '@miyauci/isx': 1.0.0 + '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.2 @@ -6798,6 +6811,8 @@ snapshots: '@microsoft/tsdoc@0.14.2': {} + '@miyauci/isx@1.0.0': {} + '@neondatabase/serverless@0.10.2': dependencies: '@types/pg': 8.11.6 From ad0e0480344e2da0e9d21f889d1bd4e25dbcae39 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 30 Jul 2025 08:49:22 +0300 Subject: [PATCH 23/81] step --- packages/edge-config/src/controller.ts | 9 +- .../src/utils/enhanced-fetch.test.ts | 41 ++- .../edge-config/src/utils/enhanced-fetch.ts | 255 ++++-------------- 3 files changed, 90 insertions(+), 215 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index e4affe6a8..ce5b1ba43 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -262,16 +262,15 @@ export class Controller { cache: this.cacheMode, }, ).then<{ value: T | undefined; digest: string; source: Source }>( - async (res) => { + async ([res, cachedRes]) => { const digest = res.headers.get('x-edge-config-digest'); + // TODO this header is not present on responses of the real API currently, + // but we mock it in tests already const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); if (res.ok) { - const value = (await res.json()) as T; - // TODO this header is not present on responses of the real API currently, - // but we mock it in tests already - + const value = (await (cachedRes ?? res).json()) as T; // set the cache if the loaded value is newer than the cached one if (updatedAt) { const existing = this.itemCache.get(key); diff --git a/packages/edge-config/src/utils/enhanced-fetch.test.ts b/packages/edge-config/src/utils/enhanced-fetch.test.ts index dee2319ae..94af70bed 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.test.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.test.ts @@ -97,11 +97,48 @@ describe('enhancedFetch', () => { const [res2, cachedRes2] = await enhancedFetch( 'https://example.com/api/data', ); + + // ensure the etag was added to the request headers + const headers = fetchMock.mock.calls[1]?.[1]?.headers; + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get('If-None-Match')).toEqual('W/"123"'); + expect(res1).toHaveProperty('status', 200); expect(res2).toHaveProperty('status', 304); expect(cachedRes2).toHaveProperty('status', 200); - // expect(res1).toStrictEqual(cachedRes2); - // expect(fetchMock).toHaveBeenCalledTimes(1); + const text1 = await res1.text(); + const cachedText = await cachedRes2?.text(); + expect(text1).toStrictEqual(cachedText); + expect(text1).toEqual(JSON.stringify({ name: 'A' })); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should return from the http cache if the response is not modified with weak etag', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ name: 'A' }), { + headers: { ETag: '"123"' }, + }); + fetchMock.mockResponseOnce('', { + status: 304, + headers: { ETag: 'W/"123"' }, // <--- only difference to above test + }); + const [res1] = await enhancedFetch('https://example.com/api/data'); + const [res2, cachedRes2] = await enhancedFetch( + 'https://example.com/api/data', + ); + + // ensure the etag was added to the request headers + const headers = fetchMock.mock.calls[1]?.[1]?.headers; + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get('If-None-Match')).toEqual('W/"123"'); + + expect(res1).toHaveProperty('status', 200); + expect(res2).toHaveProperty('status', 304); + expect(cachedRes2).toHaveProperty('status', 200); + const text1 = await res1.text(); + const cachedText = await cachedRes2?.text(); + expect(text1).toStrictEqual(cachedText); + expect(text1).toEqual(JSON.stringify({ name: 'A' })); + expect(fetchMock).toHaveBeenCalledTimes(2); }); }); }); diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/enhanced-fetch.ts index b0aa4a022..9e94e4868 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -1,5 +1,10 @@ // import { trace } from './tracing'; -import { parseETag } from '@httpland/etag-parser'; +import { + compareWeak, + parseETag, + stringifyETag, + type ETag, +} from '@httpland/etag-parser'; /** * Generate a key for the dedupe cache @@ -15,28 +20,10 @@ function getDedupeCacheKey(url: string, init?: RequestInit): string { return JSON.stringify({ url, authorization: h.get('Authorization') }); } -function getHttpCacheKey( - url: string, - init?: RequestInit, - /** - * Either the original 200 response if we're writing or the 304 response if we're reading - */ - response?: Response, -): string | null { - const h = - init?.headers instanceof Headers - ? init.headers - : new Headers(init?.headers); +function safeParseETag(headerValue: string | null): ETag | null { + if (!headerValue) return null; try { - const etagHeader = response?.headers.get('ETag'); - if (!etagHeader) return null; - const etag = parseETag(etagHeader); - - return JSON.stringify({ - url, - authorization: h.get('Authorization'), - tag: etag.tag, - }); + return parseETag(headerValue); } catch { return null; } @@ -47,18 +34,19 @@ export function createEnhancedFetch(): ( options?: RequestInit, ) => Promise<[Response, Response | null]> { const pendingRequests = new Map>(); - const httpCache = new Map(); + /* not a full http cache, but caches by etags */ + const httpCache = new Map(); function writeHttpCache( - url: string, - options: RequestInit | undefined, /* the original 200 response */ httpCacheKey: string | null, response: Response, ): void { if (!httpCacheKey) return; if (response.status !== 200) return; - httpCache.set(httpCacheKey, response.clone()); + const etag = safeParseETag(response.headers.get('ETag')); + if (!etag) return; + httpCache.set(httpCacheKey, { response: response.clone(), etag }); } /** @@ -67,36 +55,61 @@ export function createEnhancedFetch(): ( * When we receive a 304 with matching etag we return the cached response. */ function readHttpCache( - url: string, - options: RequestInit | undefined, httpCacheKey: string | null, /* the 304 response */ response: Response, ): Response | null { if (!httpCacheKey) return null; if (response.status !== 304) return null; - const cachedResponse = httpCache.get(httpCacheKey); - if (cachedResponse) return cachedResponse.clone(); - return null; + const cacheEntry = httpCache.get(httpCacheKey); + const etag = safeParseETag(response.headers.get('ETag')); + if (!etag) return null; + if (!cacheEntry?.etag) return null; + return compareWeak(etag, cacheEntry.etag) + ? cacheEntry.response.clone() + : null; + } + + function addIfNoneMatchHeader( + dedupeCacheKey: string, + options?: RequestInit, + ): Headers { + const h = new Headers(options?.headers); + + const cacheEntry = httpCache.get(dedupeCacheKey); + if (!cacheEntry) return h; + + if (!h.has('If-None-Match')) { + h.set( + 'If-None-Match', + stringifyETag({ tag: cacheEntry.etag.tag, weak: true }), + ); + } + + return h; } - return function enhancedFetch(url, options) { + return function enhancedFetch(url, options = {}) { const dedupeCacheKey = getDedupeCacheKey(url, options); const pendingRequest = pendingRequests.get(dedupeCacheKey); + // TODO get response clone here so we can guaranteed its never removed + // in between when we fetch and when we receive a response + options.headers = addIfNoneMatchHeader(dedupeCacheKey, options); + /** * Attaches the cached response */ const attach = (r: Response): [Response, Response | null] => [ r, - readHttpCache(url, options, getHttpCacheKey(url, options, r), r), + readHttpCache(dedupeCacheKey, r), ]; if (pendingRequest) return pendingRequest.then(attach); const promise = fetch(url, options) .then((res) => { - writeHttpCache(url, options, getHttpCacheKey(url, options, res), res); + writeHttpCache(dedupeCacheKey, res); return res; }) .finally(() => { @@ -106,177 +119,3 @@ export function createEnhancedFetch(): ( return promise.then(attach); }; } - -// interface CachedResponseEntry { -// etag: string; -// response: string; -// headers: Headers; -// status: number; -// time: number; -// } - -// type FetchOptions = Omit & { headers?: Headers }; - -// interface ResponseWithCachedResponse extends Response { -// cachedResponseBody?: unknown; -// cachedResponseHeaders?: Headers; -// } - -// /** -// * Creates a new response based on a cache entry -// */ -// function createResponse( -// cachedResponseEntry: CachedResponseEntry, -// ): ResponseWithCachedResponse { -// return new Response(cachedResponseEntry.response, { -// headers: { -// ...cachedResponseEntry.headers, -// Age: String( -// // age header may not be 0 when serving stale content, must be >= 1 -// Math.max(1, Math.floor((Date.now() - cachedResponseEntry.time) / 1000)), -// ), -// }, -// status: cachedResponseEntry.status, -// }); -// } - -// /** -// * Used for bad responses like 500s -// */ -// function createHandleStaleIfError( -// cachedResponseEntry: CachedResponseEntry, -// staleIfError: number | null, -// ) { -// return function handleStaleIfError( -// response: ResponseWithCachedResponse, -// ): ResponseWithCachedResponse { -// switch (response.status) { -// case 500: -// case 502: -// case 503: -// case 504: -// return typeof staleIfError === 'number' && -// cachedResponseEntry.time < Date.now() + staleIfError * 1000 -// ? createResponse(cachedResponseEntry) -// : response; -// default: -// return response; -// } -// }; -// } - -// /** -// * Used on network errors which end up throwing -// */ -// function createHandleStaleIfErrorException( -// cachedResponseEntry: CachedResponseEntry, -// staleIfError: number | null, -// ) { -// return function handleStaleIfError( -// reason: unknown, -// ): ResponseWithCachedResponse { -// if ( -// typeof staleIfError === 'number' && -// cachedResponseEntry.time < Date.now() + staleIfError * 1000 -// ) { -// return createResponse(cachedResponseEntry); -// } -// throw reason; -// }; -// } - -// /** -// * A cache of request urls & auth headers and the resulting responses. -// * -// * This cache does not use Response instances as the cache value as reusing -// * responses across requests leads to issues in Cloudflare Workers. -// */ -// export const cache = new Map(); - -// function extractStaleIfError(cacheControlHeader: string | null): number | null { -// if (!cacheControlHeader) return null; -// const matched = /stale-if-error=(?\d+)/i.exec( -// cacheControlHeader, -// ); -// return matched?.groups ? Number(matched.groups.staleIfError) : null; -// } - -// /** -// * This is similar to fetch, but it also implements ETag semantics, and -// * it implmenets stale-if-error semantics. -// */ -// export const enhancedFetch = trace( -// async function enhancedFetch( -// url: string, -// options: FetchOptions = {}, -// ): Promise { -// const { headers: customHeaders = new Headers(), ...customOptions } = -// options; -// const authHeader = customHeaders.get('Authorization'); -// const cacheKey = `${url},${authHeader || ''}`; - -// const cachedResponseEntry = cache.get(cacheKey); - -// if (cachedResponseEntry) { -// const { -// etag, -// response: cachedResponse, -// headers: cachedResponseHeaders, -// } = cachedResponseEntry; -// const headers = new Headers(customHeaders); -// headers.set('If-None-Match', etag); - -// const staleIfError = extractStaleIfError(headers.get('Cache-Control')); - -// const res: ResponseWithCachedResponse = await fetch(url, { -// ...customOptions, -// headers, -// }).then( -// createHandleStaleIfError(cachedResponseEntry, staleIfError), -// createHandleStaleIfErrorException(cachedResponseEntry, staleIfError), -// ); - -// if (res.status === 304) { -// console.log('resolving from cache'); -// res.cachedResponseBody = JSON.parse(cachedResponse); -// res.cachedResponseHeaders = cachedResponseHeaders; -// return res; -// } - -// const newETag = res.headers.get('ETag'); -// if (res.ok && newETag) { -// console.log('filling cache'); -// cache.set(cacheKey, { -// etag: newETag, -// response: await res.clone().text(), -// headers: new Headers(res.headers), -// status: res.status, -// time: Date.now(), -// }); -// } -// return res; -// } - -// const res = await fetch(url, options); -// const etag = res.headers.get('ETag'); -// if (res.ok && etag) { -// cache.set(cacheKey, { -// etag, -// response: await res.clone().text(), -// headers: new Headers(res.headers), -// status: res.status, -// time: Date.now(), -// }); -// } - -// return res; -// }, -// { -// name: 'fetchWithCachedResponse', -// attributesSuccess(result) { -// return { -// status: result.status, -// }; -// }, -// }, -// ); From 70f6612fbe3e03122adacd5b09e2b0969884e90c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 30 Jul 2025 08:57:40 +0300 Subject: [PATCH 24/81] ensure cached response body --- .../edge-config/src/utils/enhanced-fetch.ts | 64 ++++++------------- 1 file changed, 21 insertions(+), 43 deletions(-) diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/enhanced-fetch.ts index 9e94e4868..e344487e2 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -1,10 +1,5 @@ // import { trace } from './tracing'; -import { - compareWeak, - parseETag, - stringifyETag, - type ETag, -} from '@httpland/etag-parser'; +import { parseETag, stringifyETag, type ETag } from '@httpland/etag-parser'; /** * Generate a key for the dedupe cache @@ -56,36 +51,18 @@ export function createEnhancedFetch(): ( */ function readHttpCache( httpCacheKey: string | null, - /* the 304 response */ - response: Response, - ): Response | null { - if (!httpCacheKey) return null; - if (response.status !== 304) return null; + ): [ETag, Response] | [null, null] { + if (!httpCacheKey) return [null, null]; const cacheEntry = httpCache.get(httpCacheKey); - const etag = safeParseETag(response.headers.get('ETag')); - if (!etag) return null; - if (!cacheEntry?.etag) return null; - return compareWeak(etag, cacheEntry.etag) - ? cacheEntry.response.clone() - : null; + if (!cacheEntry) return [null, null]; + return [cacheEntry.etag, cacheEntry.response.clone()]; } - function addIfNoneMatchHeader( - dedupeCacheKey: string, - options?: RequestInit, - ): Headers { - const h = new Headers(options?.headers); - - const cacheEntry = httpCache.get(dedupeCacheKey); - if (!cacheEntry) return h; - - if (!h.has('If-None-Match')) { - h.set( - 'If-None-Match', - stringifyETag({ tag: cacheEntry.etag.tag, weak: true }), - ); - } - + function addIfNoneMatchHeader(options: RequestInit, etag: ETag): Headers { + const h = new Headers(options.headers); + const existing = h.get('If-None-Match'); + if (!existing) + h.set('If-None-Match', stringifyETag({ tag: etag.tag, weak: true })); return h; } @@ -93,17 +70,18 @@ export function createEnhancedFetch(): ( const dedupeCacheKey = getDedupeCacheKey(url, options); const pendingRequest = pendingRequests.get(dedupeCacheKey); - // TODO get response clone here so we can guaranteed its never removed - // in between when we fetch and when we receive a response - options.headers = addIfNoneMatchHeader(dedupeCacheKey, options); + // pull out the cached etag and the cached response at once, so we + // can guarantee the cached response is never removed in between when + // we fetch and when we receive a response + const [etag, cachedResponse] = readHttpCache(dedupeCacheKey); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- yep + if (etag && cachedResponse) { + options.headers = addIfNoneMatchHeader(options, etag); + } - /** - * Attaches the cached response - */ - const attach = (r: Response): [Response, Response | null] => [ - r, - readHttpCache(dedupeCacheKey, r), - ]; + /** Attaches the cached response */ + const attach = (r: Response): [Response, Response | null] => + r.status === 304 ? [r, cachedResponse] : [r, null]; if (pendingRequest) return pendingRequest.then(attach); From 54233ced5bc6f8a2134ebd2cb5cc83039c0bd532 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 30 Jul 2025 11:07:41 +0300 Subject: [PATCH 25/81] fix tests --- packages/edge-config/package.json | 4 +- packages/edge-config/src/controller.test.ts | 51 +++++- packages/edge-config/src/controller.ts | 9 +- .../src/utils/enhanced-fetch.test.ts | 45 ++--- .../edge-config/src/utils/enhanced-fetch.ts | 33 ++-- pnpm-lock.yaml | 170 ++---------------- 6 files changed, 88 insertions(+), 224 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 08d0978af..d57d6a27d 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -43,9 +43,7 @@ ], "testEnvironment": "node" }, - "dependencies": { - "@httpland/etag-parser": "1.1.0" - }, + "dependencies": {}, "devDependencies": { "@changesets/cli": "2.28.1", "@edge-runtime/jest-environment": "2.3.10", diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 07d3cc924..698b901a0 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -2,6 +2,11 @@ import fetchMock from 'jest-fetch-mock'; import { Controller, setTimestampOfLatestUpdate } from './controller'; import type { Connection } from './types'; +const delay = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + const connection: Connection = { baseUrl: 'https://edge-config.vercel.com', id: 'ecfg_FAKE_EDGE_CONFIG_ID', @@ -21,11 +26,12 @@ describe('controller', () => { setTimestampOfLatestUpdate(1000); - fetchMock.mockResponse(JSON.stringify('value1'), { + fetchMock.mockResponseOnce(JSON.stringify('value1'), { headers: { 'x-edge-config-digest': 'digest1', 'x-edge-config-updated-at': '1000', etag: '"digest1"', + 'content-type': 'application/json', }, }); @@ -45,14 +51,24 @@ describe('controller', () => { // should not fetch again expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/item/key1?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + }), + }, + ); - // should refresh in background and serve stale value + // should refresh in background and serve stale value in the meantime setTimestampOfLatestUpdate(7000); - fetchMock.mockResponse(JSON.stringify('value2'), { + fetchMock.mockResponseOnce(JSON.stringify('value2'), { headers: { 'x-edge-config-digest': 'digest2', 'x-edge-config-updated-at': '7000', etag: '"digest2"', + 'content-type': 'application/json', }, }); @@ -62,13 +78,23 @@ describe('controller', () => { source: 'STALE', }); - // should fetch again in background + // should have fetched again in background expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/item/key1?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'If-None-Match': '"digest1"', + }), + }, + ); // run event loop once - await Promise.resolve(); + await delay(0); - // should now serve the stale value + // should now serve the updated value await expect(controller.get('key1')).resolves.toEqual({ value: 'value2', digest: 'digest2', @@ -77,13 +103,24 @@ describe('controller', () => { // exceeds stale threshold should lead to cache MISS and blocking fetch setTimestampOfLatestUpdate(17001); - fetchMock.mockResponse(JSON.stringify('value3'), { + fetchMock.mockResponseOnce(JSON.stringify('value3'), { headers: { 'x-edge-config-digest': 'digest3', 'x-edge-config-updated-at': '17001', }, }); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/item/key1?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'If-None-Match': '"digest1"', + }), + }, + ); + await expect(controller.get('key1')).resolves.toEqual({ value: 'value3', digest: 'digest3', diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index ce5b1ba43..24c52c43a 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -148,7 +148,7 @@ export class Controller { if (cached) { // HIT - if (timestampOfLatestUpdate === cached.updatedAt) { + if (timestampOfLatestUpdate <= cached.updatedAt) { return { value: cached.value, digest: cached.digest, @@ -270,7 +270,7 @@ export class Controller { if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); if (res.ok) { - const value = (await (cachedRes ?? res).json()) as T; + const value = (await (cachedRes || res).json()) as T; // set the cache if the loaded value is newer than the cached one if (updatedAt) { const existing = this.itemCache.get(key); @@ -286,7 +286,10 @@ export class Controller { return { value, digest, source: 'MISS' }; } - await consumeResponseBody(res); + await Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]); if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); if (res.status === 404) { diff --git a/packages/edge-config/src/utils/enhanced-fetch.test.ts b/packages/edge-config/src/utils/enhanced-fetch.test.ts index 94af70bed..b77036462 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.test.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.test.ts @@ -86,9 +86,8 @@ describe('enhancedFetch', () => { describe('etag and if-none-match', () => { it('should return from the http cache if the response is not modified', async () => { - fetchMock.mockResponseOnce(JSON.stringify({ name: 'A' }), { - headers: { ETag: '"123"' }, - }); + const content = JSON.stringify({ name: 'A' }); + fetchMock.mockResponseOnce(content, { headers: { ETag: '"123"' } }); fetchMock.mockResponseOnce('', { status: 304, headers: { ETag: '"123"' }, @@ -98,38 +97,14 @@ describe('enhancedFetch', () => { 'https://example.com/api/data', ); - // ensure the etag was added to the request headers - const headers = fetchMock.mock.calls[1]?.[1]?.headers; - expect(headers).toBeInstanceOf(Headers); - expect((headers as Headers).get('If-None-Match')).toEqual('W/"123"'); - - expect(res1).toHaveProperty('status', 200); - expect(res2).toHaveProperty('status', 304); - expect(cachedRes2).toHaveProperty('status', 200); - const text1 = await res1.text(); - const cachedText = await cachedRes2?.text(); - expect(text1).toStrictEqual(cachedText); - expect(text1).toEqual(JSON.stringify({ name: 'A' })); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('should return from the http cache if the response is not modified with weak etag', async () => { - fetchMock.mockResponseOnce(JSON.stringify({ name: 'A' }), { - headers: { ETag: '"123"' }, - }); - fetchMock.mockResponseOnce('', { - status: 304, - headers: { ETag: 'W/"123"' }, // <--- only difference to above test - }); - const [res1] = await enhancedFetch('https://example.com/api/data'); - const [res2, cachedRes2] = await enhancedFetch( - 'https://example.com/api/data', - ); + // ensure the etag was not added to the request headers of the 1st request + const headers1 = fetchMock.mock.calls[0]?.[1]?.headers; + expect(headers1).toBeUndefined(); - // ensure the etag was added to the request headers - const headers = fetchMock.mock.calls[1]?.[1]?.headers; - expect(headers).toBeInstanceOf(Headers); - expect((headers as Headers).get('If-None-Match')).toEqual('W/"123"'); + // ensure the etag was added to the request headers of the 2nd request + const headers2 = fetchMock.mock.calls[1]?.[1]?.headers; + expect(headers2).toBeInstanceOf(Headers); + expect((headers2 as Headers).get('If-None-Match')).toEqual('"123"'); expect(res1).toHaveProperty('status', 200); expect(res2).toHaveProperty('status', 304); @@ -137,7 +112,7 @@ describe('enhancedFetch', () => { const text1 = await res1.text(); const cachedText = await cachedRes2?.text(); expect(text1).toStrictEqual(cachedText); - expect(text1).toEqual(JSON.stringify({ name: 'A' })); + expect(text1).toEqual(content); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/enhanced-fetch.ts index e344487e2..b67177567 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -1,5 +1,4 @@ // import { trace } from './tracing'; -import { parseETag, stringifyETag, type ETag } from '@httpland/etag-parser'; /** * Generate a key for the dedupe cache @@ -12,16 +11,11 @@ function getDedupeCacheKey(url: string, init?: RequestInit): string { ? init.headers : new Headers(init?.headers); - return JSON.stringify({ url, authorization: h.get('Authorization') }); -} - -function safeParseETag(headerValue: string | null): ETag | null { - if (!headerValue) return null; - try { - return parseETag(headerValue); - } catch { - return null; - } + return JSON.stringify({ + url, + authorization: h.get('Authorization'), + minUpdatedAt: h.get('x-edge-config-min-updated-at'), + }); } export function createEnhancedFetch(): ( @@ -30,7 +24,7 @@ export function createEnhancedFetch(): ( ) => Promise<[Response, Response | null]> { const pendingRequests = new Map>(); /* not a full http cache, but caches by etags */ - const httpCache = new Map(); + const httpCache = new Map(); function writeHttpCache( /* the original 200 response */ @@ -39,7 +33,7 @@ export function createEnhancedFetch(): ( ): void { if (!httpCacheKey) return; if (response.status !== 200) return; - const etag = safeParseETag(response.headers.get('ETag')); + const etag = response.headers.get('ETag'); if (!etag) return; httpCache.set(httpCacheKey, { response: response.clone(), etag }); } @@ -51,18 +45,17 @@ export function createEnhancedFetch(): ( */ function readHttpCache( httpCacheKey: string | null, - ): [ETag, Response] | [null, null] { + ): [string, Response] | [null, null] { if (!httpCacheKey) return [null, null]; const cacheEntry = httpCache.get(httpCacheKey); if (!cacheEntry) return [null, null]; - return [cacheEntry.etag, cacheEntry.response.clone()]; + return [cacheEntry.etag, cacheEntry.response]; } - function addIfNoneMatchHeader(options: RequestInit, etag: ETag): Headers { + function addIfNoneMatchHeader(options: RequestInit, etag: string): Headers { const h = new Headers(options.headers); const existing = h.get('If-None-Match'); - if (!existing) - h.set('If-None-Match', stringifyETag({ tag: etag.tag, weak: true })); + if (!existing) h.set('If-None-Match', etag); return h; } @@ -81,7 +74,9 @@ export function createEnhancedFetch(): ( /** Attaches the cached response */ const attach = (r: Response): [Response, Response | null] => - r.status === 304 ? [r, cachedResponse] : [r, null]; + r.status === 304 && cachedResponse + ? [r, cachedResponse.clone()] + : [r, null]; if (pendingRequest) return pendingRequest.then(attach); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd00dad52..aedd3bc51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 0.3.7 ts-jest: specifier: 29.2.6 - version: 29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) + version: 29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) turbo: specifier: 2.4.4 version: 2.4.4 @@ -102,9 +102,6 @@ importers: packages/edge-config: dependencies: - '@httpland/etag-parser': - specifier: 1.1.0 - version: 1.1.0 '@opentelemetry/api': specifier: ^1.7.0 version: 1.7.0 @@ -371,7 +368,7 @@ importers: version: 2.1.3 next: specifier: ^15.2.0 - version: 15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) postcss: specifier: ^8.5.3 version: 8.5.3 @@ -1118,9 +1115,6 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} - '@httpland/etag-parser@1.1.0': - resolution: {integrity: sha512-l3eZdzJKQpKhP1WgFR+rlskZJUPC7wW+ffOs9vXX8ZhhN6S2JAUOcFWwhROzUN4N2FYcbnbwlSF2IG33ibagJw==} - '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -1386,9 +1380,6 @@ packages: '@microsoft/tsdoc@0.14.2': resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} - '@miyauci/isx@1.0.0': - resolution: {integrity: sha512-W3MhSwkunnVu1NPl6sSWX+TkzqeZiVmINQsNuTF6ukE7MIuPnWupQRB/ZWaH603CGhQ8MXQ1BvbMwfXkUirexw==} - '@neondatabase/serverless@0.10.2': resolution: {integrity: sha512-XaIMQ9fXDPWLiShfsg+YJQvmMMIyVQB7J3jnirckyoDxN7YIATyzXBThpUeFJqBUkbFJQ0e5PCxXTpK2rG4WbQ==} @@ -5820,56 +5811,26 @@ snapshots: dependencies: '@babel/types': 7.26.9 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5880,78 +5841,36 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.9)': - dependencies: - '@babel/core': 7.23.9 - '@babel/helper-plugin-utils': 7.20.2 - optional: true - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -6411,10 +6330,6 @@ snapshots: '@fastify/busboy@2.1.1': {} - '@httpland/etag-parser@1.1.0': - dependencies: - '@miyauci/isx': 1.0.0 - '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.2 @@ -6811,8 +6726,6 @@ snapshots: '@microsoft/tsdoc@0.14.2': {} - '@miyauci/isx@1.0.0': {} - '@neondatabase/serverless@0.10.2': dependencies: '@types/pg': 8.11.6 @@ -7095,10 +7008,10 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.0 - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/parser': 8.25.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/type-utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) @@ -7310,7 +7223,7 @@ snapshots: '@babel/core': 7.23.9 '@babel/eslint-parser': 7.23.10(@babel/core@7.23.9)(eslint@8.56.0) '@rushstack/eslint-patch': 1.7.2 - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) eslint-config-prettier: 9.1.0(eslint@8.56.0) eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1) @@ -7611,20 +7524,6 @@ snapshots: axobject-query@4.1.0: {} - babel-jest@29.7.0(@babel/core@7.23.9): - dependencies: - '@babel/core': 7.23.9 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.0 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.23.9) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - optional: true - babel-jest@29.7.0(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -7655,23 +7554,6 @@ snapshots: '@types/babel__core': 7.20.0 '@types/babel__traverse': 7.18.5 - babel-preset-current-node-syntax@1.0.1(@babel/core@7.23.9): - dependencies: - '@babel/core': 7.23.9 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.23.9) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.23.9) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.23.9) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.23.9) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.23.9) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.9) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.9) - optional: true - babel-preset-current-node-syntax@1.0.1(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -7688,13 +7570,6 @@ snapshots: '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.26.0) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.26.0) - babel-preset-jest@29.6.3(@babel/core@7.23.9): - dependencies: - '@babel/core': 7.23.9 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.9) - optional: true - babel-preset-jest@29.6.3(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -8493,7 +8368,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.15.0 eslint: 8.56.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 @@ -8531,7 +8406,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0): + eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -8558,7 +8433,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) hasown: 2.0.1 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -8609,7 +8484,7 @@ snapshots: '@typescript-eslint/utils': 5.62.0(eslint@8.56.0)(typescript@5.7.3) eslint: 8.56.0 optionalDependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) transitivePeerDependencies: - supports-color @@ -10379,7 +10254,7 @@ snapshots: natural-compare@1.4.0: {} - next@15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.2.0 '@swc/counter': 0.1.3 @@ -10389,7 +10264,7 @@ snapshots: postcss: 8.4.31 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.23.9)(react@19.0.0) optionalDependencies: '@next/swc-darwin-arm64': 15.2.0 '@next/swc-darwin-x64': 15.2.0 @@ -11363,12 +11238,12 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.0.0): + styled-jsx@5.1.6(@babel/core@7.23.9)(react@19.0.0): dependencies: client-only: 0.0.1 react: 19.0.0 optionalDependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.23.9 sucrase@3.35.0: dependencies: @@ -11524,25 +11399,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.26.0) esbuild: 0.24.2 - ts-jest@29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): - dependencies: - bs-logger: 0.2.6 - ejs: 3.1.10 - fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) - jest-util: 29.7.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.1 - typescript: 5.7.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.23.9 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.23.9) - ts-jest@29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 From 1ad14c2f899aa8a4180c9bdf7d1ba7d4af23450c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 30 Jul 2025 16:48:10 +0300 Subject: [PATCH 26/81] add more tests --- packages/edge-config/src/controller.test.ts | 306 ++++++++++++------ packages/edge-config/src/controller.ts | 230 +++++-------- packages/edge-config/src/types.ts | 2 +- .../edge-config/src/utils/enhanced-fetch.ts | 4 +- 4 files changed, 304 insertions(+), 238 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 698b901a0..020ba98ed 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -2,11 +2,6 @@ import fetchMock from 'jest-fetch-mock'; import { Controller, setTimestampOfLatestUpdate } from './controller'; import type { Connection } from './types'; -const delay = (ms: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - const connection: Connection = { baseUrl: 'https://edge-config.vercel.com', id: 'ecfg_FAKE_EDGE_CONFIG_ID', @@ -15,17 +10,13 @@ const connection: Connection = { type: 'vercel', }; -// eslint-disable-next-line jest/require-top-level-describe -- [@vercel/style-guide@5 migration] -beforeEach(() => { - fetchMock.resetMocks(); -}); - -describe('controller', () => { - it('should work', async () => { - const controller = new Controller(connection, {}); - +// the "it" tests in the lifecycle are run sequentially, so their order matters +describe('lifecycle', () => { + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + it('should MISS the cache initially', async () => { setTimestampOfLatestUpdate(1000); - fetchMock.mockResponseOnce(JSON.stringify('value1'), { headers: { 'x-edge-config-digest': 'digest1', @@ -35,21 +26,14 @@ describe('controller', () => { }, }); - // blocking fetch first - await expect(controller.get('key1')).resolves.toEqual({ - value: 'value1', - digest: 'digest1', - source: 'MISS', - }); - - // cache HIT after await expect(controller.get('key1')).resolves.toEqual({ value: 'value1', digest: 'digest1', - source: 'HIT', + cache: 'MISS', }); + }); - // should not fetch again + it('should fire off a background refresh after the cache MISS', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/item/key1?version=1', @@ -57,11 +41,25 @@ describe('controller', () => { cache: 'no-store', headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', + 'x-edge-config-min-updated-at': '1000', }), }, ); + }); + + it('should HIT the cache if the timestamp has not changed', async () => { + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + cache: 'HIT', + }); + }); - // should refresh in background and serve stale value in the meantime + it('should not fire off any background refreshes after the cache HIT', () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should serve a stale value if the timestamp has changed but is within the threshold', async () => { setTimestampOfLatestUpdate(7000); fetchMock.mockResponseOnce(JSON.stringify('value2'), { headers: { @@ -75,10 +73,11 @@ describe('controller', () => { await expect(controller.get('key1')).resolves.toEqual({ value: 'value1', digest: 'digest1', - source: 'STALE', + cache: 'STALE', }); + }); - // should have fetched again in background + it('should trigger a background refresh after the STALE value', () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/item/key1?version=1', @@ -86,22 +85,26 @@ describe('controller', () => { cache: 'no-store', headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', - 'If-None-Match': '"digest1"', + // 'If-None-Match': '"digest1"', + 'x-edge-config-min-updated-at': '7000', }), }, ); + }); - // run event loop once - await delay(0); - - // should now serve the updated value + it('should serve the new value from cache after the background refresh completes', async () => { await expect(controller.get('key1')).resolves.toEqual({ value: 'value2', digest: 'digest2', - source: 'HIT', + cache: 'HIT', }); + }); + + it('should not fire off any subsequent background refreshes', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); - // exceeds stale threshold should lead to cache MISS and blocking fetch + it('should refresh when the stale threshold is exceeded', async () => { setTimestampOfLatestUpdate(17001); fetchMock.mockResponseOnce(JSON.stringify('value3'), { headers: { @@ -110,41 +113,52 @@ describe('controller', () => { }, }); + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value3', + digest: 'digest3', + cache: 'MISS', + }); + }); + + it('should have a blocking refresh after the stale threshold was exceeded', () => { + expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/item/key1?version=1', { cache: 'no-store', headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', - 'If-None-Match': '"digest1"', + // 'If-None-Match': '"digest1"', + 'x-edge-config-min-updated-at': '17001', }), }, ); + }); +}); - await expect(controller.get('key1')).resolves.toEqual({ - value: 'value3', - digest: 'digest3', - source: 'MISS', - }); - - // needs to fetch again - expect(fetchMock).toHaveBeenCalledTimes(3); +describe('deduping within a version', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + const controller = new Controller(connection, { + enableDevelopmentCache: false, }); - it('should dedupe within a version', async () => { - const controller = new Controller(connection, {}); + // let promisedValue1: ReturnType; + let promisedValue2: ReturnType; + it('should only fetch once given the same request', async () => { setTimestampOfLatestUpdate(1000); + const resolvers = Promise.withResolvers(); - const { promise, resolve } = Promise.withResolvers(); - - fetchMock.mockResolvedValueOnce(promise); + fetchMock.mockResolvedValueOnce(resolvers.promise); // blocking fetches first, which should get deduped - const read1 = controller.get('key1'); - const read2 = controller.get('key1'); + const promisedValue1 = controller.get('key1'); + // fetch again before resolving promise of the first fetch + promisedValue2 = controller.get('key1'); - resolve( + resolvers.resolve( new Response(JSON.stringify('value1'), { headers: { 'x-edge-config-digest': 'digest1', @@ -153,37 +167,105 @@ describe('controller', () => { }), ); - await expect(read1).resolves.toEqual({ + await expect(promisedValue1).resolves.toEqual({ value: 'value1', digest: 'digest1', - source: 'MISS', + cache: 'MISS', }); + }); - // reuses the pending fetch promise - await expect(read2).resolves.toEqual({ + it('should reuse the existing promise', async () => { + await expect(promisedValue2).resolves.toEqual({ value: 'value1', digest: 'digest1', - source: 'MISS', + cache: 'MISS', }); + }); - // hits the cache + it('should only have fetched once due to deduping', () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should hit the cache on subsequent reads without refetching', async () => { const read3 = controller.get('key1'); await expect(read3).resolves.toEqual({ value: 'value1', digest: 'digest1', - source: 'HIT', + cache: 'HIT', }); + }); - // + it('should not trigger a new background refresh', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); +describe('bypassing dedupe when the timestamp changes', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('should only fetch once given the same request', async () => { + setTimestampOfLatestUpdate(1000); + const read1 = Promise.withResolvers(); + const read2 = Promise.withResolvers(); + + fetchMock.mockResolvedValueOnce(read1.promise); + fetchMock.mockResolvedValueOnce(read2.promise); + + // blocking fetches first, which should get deduped + const promisedValue1 = controller.get('key1'); + setTimestampOfLatestUpdate(1001); + const promisedValue2 = controller.get('key1'); + + read1.resolve( + new Response(JSON.stringify('value1'), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + }, + }), + ); + + await expect(promisedValue1).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + cache: 'MISS', + }); + + read2.resolve( + new Response(JSON.stringify('value2'), { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '1001', + }, + }), + ); + + // reuses the pending fetch promise + await expect(promisedValue2).resolves.toEqual({ + value: 'value2', + digest: 'digest2', + cache: 'MISS', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + describe('development cache', () => { - it('should fetch on every read', async () => { - setTimestampOfLatestUpdate(undefined); - const controller = new Controller(connection, {}); + const controller = new Controller(connection, { + enableDevelopmentCache: true, + }); + beforeAll(() => { + fetchMock.resetMocks(); + }); + it('should fetch initially', async () => { + setTimestampOfLatestUpdate(undefined); fetchMock.mockResponseOnce(JSON.stringify('value1'), { headers: { 'x-edge-config-digest': 'digest1', @@ -194,72 +276,106 @@ describe('development cache', () => { await expect(controller.get('key1')).resolves.toEqual({ value: 'value1', digest: 'digest1', - source: 'MISS', + cache: 'MISS', }); - fetchMock.mockResponse(JSON.stringify('value2'), { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should not fetch when another fetch is pending', async () => { + fetchMock.mockResponseOnce(JSON.stringify('value2'), { headers: { 'x-edge-config-digest': 'digest2', 'x-edge-config-updated-at': '1000', + etag: '"digest2"', + 'content-type': 'application/json', }, }); - await expect(controller.get('key1')).resolves.toEqual({ + // run them in parallel so the deduplication can take action + const [promise1, promise2] = [ + controller.get('key1'), + controller.get('key1'), + ]; + + await expect(promise1).resolves.toEqual({ value: 'value2', digest: 'digest2', - source: 'MISS', + cache: 'MISS', }); - await expect(controller.get('key1')).resolves.toEqual({ + expect(fetchMock).toHaveBeenCalledTimes(2); + + await expect(promise2).resolves.toEqual({ value: 'value2', digest: 'digest2', - source: 'MISS', + cache: 'MISS', }); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - // eslint-disable-next-line jest/no-disabled-tests -- not implemented yet - it.skip('should work', async () => { - setTimestampOfLatestUpdate(undefined); - const controller = new Controller(connection, {}); - - fetchMock.mockResponseOnce(JSON.stringify('value1'), { + it('should use the etag http cache', async () => { + fetchMock.mockResponseOnce('', { + status: 304, headers: { - 'x-edge-config-digest': 'digest1', + 'x-edge-config-digest': 'digest2', 'x-edge-config-updated-at': '1000', + etag: '"digest2"', + 'content-type': 'application/json', }, }); await expect(controller.get('key1')).resolves.toEqual({ - value: 'value1', - digest: 'digest1', - source: 'MISS', + value: 'value2', + digest: 'digest2', + // hits the etag http cache, but misses the in-memory cache, so it's a MISS + cache: 'MISS', }); - fetchMock.mockResponse(JSON.stringify('value2'), { + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/item/key1?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'If-None-Match': '"digest2"', + }), + }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should return the latest value when the etag changes', async () => { + fetchMock.mockResponseOnce(JSON.stringify('value3'), { headers: { - 'x-edge-config-digest': 'digest2', + 'x-edge-config-digest': 'digest3', 'x-edge-config-updated-at': '1001', + // a newer etag will be returned + etag: '"digest3"', + 'content-type': 'application/json', }, }); await expect(controller.get('key1')).resolves.toEqual({ - value: 'value1', - digest: 'digest1', - source: 'HIT', - }); - - await Promise.resolve(); - - await expect(controller.get('key1')).resolves.toEqual({ - value: 'value2', - digest: 'digest2', - source: 'HIT', + value: 'value3', + digest: 'digest3', + cache: 'MISS', }); - await Promise.resolve(); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/item/key1?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + // we query with the older etag we had in memory + 'If-None-Match': '"digest2"', + }), + }, + ); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(4); }); }); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 24c52c43a..e1ba6e20a 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -4,11 +4,10 @@ import type { EdgeConfigFunctionsOptions, Connection, EdgeConfigClientOptions, - Source, + CacheSource, } from './types'; import { ERRORS, isEmptyKey, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; -import { addConsistentReadHeader } from './utils/add-consistent-read-header'; import { createEnhancedFetch } from './utils/enhanced-fetch'; const enhancedFetch = createEnhancedFetch(); @@ -23,14 +22,6 @@ export function setTimestampOfLatestUpdate( timestampOfLatestUpdate = timestamp; } -function canReusePendingFetch( - pending: { minUpdatedAt: number } | undefined, - requiredMinUpdatedAt: number, -): boolean { - if (!pending) return false; - return pending.minUpdatedAt >= requiredMinUpdatedAt; -} - function parseTs(updatedAt: string | null): number | null { if (!updatedAt) return null; const parsed = Number.parseInt(updatedAt, 10); @@ -53,17 +44,10 @@ export class Controller { } >(); - /** - * While in development we use SWR-like behavior for the api client to - * reduce latency. - */ - private isDevEnvironment = - process.env.NODE_ENV === 'development' && - process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; - private connection: Connection; private staleThreshold: number; private cacheMode: 'no-store' | 'force-cache'; + private enableDevelopmentCache: boolean; /** * A map of keys to pending promises @@ -75,29 +59,26 @@ export class Controller { promise: Promise<{ value: EdgeConfigValue | undefined; digest: string; - source: Source; + cache: CacheSource; }>; } >(); - private pendingEdgeConfigPromise: - | { - minUpdatedAt: number; - promise: Promise; - } - | undefined = undefined; - - constructor(connection: Connection, options: EdgeConfigClientOptions) { + constructor( + connection: Connection, + options: EdgeConfigClientOptions & { enableDevelopmentCache: boolean }, + ) { this.connection = connection; this.staleThreshold = options.staleThreshold ?? DEFAULT_STALE_THRESHOLD; this.cacheMode = options.cache || 'no-store'; + this.enableDevelopmentCache = options.enableDevelopmentCache; } public async get( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T | undefined; digest: string; source: Source }> { - if (this.isDevEnvironment || !timestampOfLatestUpdate) { + ): Promise<{ value: T | undefined; digest: string; cache: CacheSource }> { + if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { return this.fetchItem(key, timestampOfLatestUpdate, localOptions); } @@ -112,39 +93,7 @@ export class Controller { // only use the cache if we have a timestamp of the latest update if (timestampOfLatestUpdate) { - const cachedItem = this.itemCache.get(key); - const cachedConfig = this.edgeConfigCache; - let cached: { - value: T | undefined; - updatedAt: number; - digest: string; - } | null = null; - if (cachedItem && cachedConfig) { - cached = - cachedItem.updatedAt > cachedConfig.updatedAt - ? { - value: cachedItem.value as T | undefined, - updatedAt: cachedItem.updatedAt, - digest: cachedItem.digest, - } - : { - digest: cachedConfig.digest, - value: cachedConfig.items[key] as T | undefined, - updatedAt: cachedConfig.updatedAt, - }; - } else if (cachedItem && !cachedConfig) { - cached = { - value: cachedItem.value as T | undefined, - updatedAt: cachedItem.updatedAt, - digest: cachedItem.digest, - }; - } else if (!cachedItem && cachedConfig) { - cached = { - value: cachedConfig.items[key] as T | undefined, - updatedAt: cachedConfig.updatedAt, - digest: cachedConfig.digest, - }; - } + const cached = this.getCachedItem(key); if (cached) { // HIT @@ -152,7 +101,7 @@ export class Controller { return { value: cached.value, digest: cached.digest, - source: 'HIT', + cache: 'HIT', }; } @@ -163,53 +112,16 @@ export class Controller { cached.updatedAt >= timestampOfLatestUpdate - this.staleThreshold ) { // background refresh - // reuse existing promise if there is one - const pendingItemPromise = this.pendingItemFetches.get(key); - const pendingEdgeConfigPromise = this.pendingEdgeConfigPromise; - - const canReusePendingItemFetch = canReusePendingFetch( - pendingItemPromise, + void this.fetchItem( + key, timestampOfLatestUpdate, - ); - const canReusePendingEdgeConfigFetch = canReusePendingFetch( - pendingEdgeConfigPromise, - timestampOfLatestUpdate, - ); - - if (!canReusePendingItemFetch && !canReusePendingEdgeConfigFetch) { - // trigger a new fetch if we can't reuse the pending fetches - void this.fetchItem( - key, - timestampOfLatestUpdate, - localOptions, - ).catch(() => null); - } - - // if ( - // pendingItemPromise && - // pendingItemPromise.minUpdatedAt >= timestampOfLatestUpdate && - // // ensure the pending promise can not end up being stale - // pendingItemPromise.minUpdatedAt + this.staleThreshold >= - // timestampOfLatestUpdate - // ) { - // // do nothing - // } else { - // // TODO cancel existing pending fetch with an AbortController if - // // there is one? does this lead to problems if it is being awaited - // // by a blocking read? - // // - // // TODO use waitUntil? - // void this.fetchItem( - // key, - // timestampOfLatestUpdate, - // localOptions, - // ).catch(() => null); - // } + localOptions, + ).catch(() => null); return { value: cached.value, digest: cached.digest, - source: 'STALE', + cache: 'STALE', }; // we're outdated, but we can't serve the STALE value @@ -220,32 +132,59 @@ export class Controller { // so we just fall through } } - - // MISS, with pending fetch - // reuse existing promise if there is one - const pendingPromise = this.pendingItemFetches.get(key); - if ( - pendingPromise && - pendingPromise.minUpdatedAt >= timestampOfLatestUpdate && - // ensure the pending promise can not end up being stale - pendingPromise.minUpdatedAt + this.staleThreshold >= - timestampOfLatestUpdate - ) { - // TODO should we check once the promise resolves whether it ended up - // being stale and do a blocking refetch in that case? - return pendingPromise.promise as Promise<{ - value: T | undefined; - digest: string; - source: Source; - }>; - } } - // MISS, without pending fetch - // otherwise, create a new promise + // MISS return this.fetchItem(key, timestampOfLatestUpdate, localOptions); } + /** + * Returns an item from the item cache or edge config cache, depending on + * which value is newer, or null if there is no cached value. + */ + private getCachedItem( + key: string, + ): { + value: T | undefined; + updatedAt: number; + digest: string; + } | null { + const cachedItem = this.itemCache.get(key); + const cachedConfig = this.edgeConfigCache; + + if (cachedItem && cachedConfig) { + return cachedItem.updatedAt > cachedConfig.updatedAt + ? { + value: cachedItem.value as T | undefined, + updatedAt: cachedItem.updatedAt, + digest: cachedItem.digest, + } + : { + digest: cachedConfig.digest, + value: cachedConfig.items[key] as T | undefined, + updatedAt: cachedConfig.updatedAt, + }; + } + + if (cachedItem && !cachedConfig) { + return { + value: cachedItem.value as T | undefined, + updatedAt: cachedItem.updatedAt, + digest: cachedItem.digest, + }; + } + + if (!cachedItem && cachedConfig) { + return { + value: cachedConfig.items[key] as T | undefined, + updatedAt: cachedConfig.updatedAt, + digest: cachedConfig.digest, + }; + } + + return null; + } + private fetchItem( key: string, minUpdatedAt: number | undefined, @@ -253,15 +192,15 @@ export class Controller { ): Promise<{ value: T | undefined; digest: string; - source: Source; + cache: CacheSource; }> { const promise = enhancedFetch( `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, { - headers: this.getHeaders(localOptions), + headers: this.getHeaders(localOptions, minUpdatedAt), cache: this.cacheMode, }, - ).then<{ value: T | undefined; digest: string; source: Source }>( + ).then<{ value: T | undefined; digest: string; cache: CacheSource }>( async ([res, cachedRes]) => { const digest = res.headers.get('x-edge-config-digest'); // TODO this header is not present on responses of the real API currently, @@ -269,8 +208,10 @@ export class Controller { const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - if (res.ok) { - const value = (await (cachedRes || res).json()) as T; + if (res.ok || (res.status === 304 && cachedRes)) { + const value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as T; // set the cache if the loaded value is newer than the cached one if (updatedAt) { const existing = this.itemCache.get(key); @@ -283,7 +224,7 @@ export class Controller { }); } } - return { value, digest, source: 'MISS' }; + return { value, digest, cache: 'MISS' }; } await Promise.all([ @@ -305,7 +246,7 @@ export class Controller { responseTimestamp: Date.now(), }); } - return { value: undefined, digest, source: 'MISS' }; + return { value: undefined, digest, cache: 'MISS' }; } // if the x-edge-config-digest header is not present, it means // the edge config itself does not exist @@ -326,15 +267,15 @@ export class Controller { public async has( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ exists: boolean; digest: string; source: Source }> { + ): Promise<{ exists: boolean; digest: string; cache: CacheSource }> { return fetch( `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, { method: 'HEAD', - headers: this.getHeaders(localOptions), + headers: this.getHeaders(localOptions, timestampOfLatestUpdate), cache: this.cacheMode, }, - ).then<{ exists: boolean; digest: string; source: Source }>((res) => { + ).then<{ exists: boolean; digest: string; cache: CacheSource }>((res) => { if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); const digest = res.headers.get('x-edge-config-digest'); @@ -348,7 +289,7 @@ export class Controller { return { digest, exists: res.status !== 404, - source: 'MISS', + cache: 'MISS', }; throw new UnexpectedNetworkError(res); }); @@ -360,7 +301,7 @@ export class Controller { return fetch( `${this.connection.baseUrl}/digest?version=${this.connection.version}`, { - headers: this.getHeaders(localOptions), + headers: this.getHeaders(localOptions, timestampOfLatestUpdate), cache: this.cacheMode, }, ).then(async (res) => { @@ -397,7 +338,7 @@ export class Controller { return fetch( `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, { - headers: this.getHeaders(localOptions), + headers: this.getHeaders(localOptions, timestampOfLatestUpdate), cache: this.cacheMode, }, ).then<{ value: T; digest: string }>(async (res) => { @@ -427,7 +368,7 @@ export class Controller { return fetch( `${this.connection.baseUrl}/items?version=${this.connection.version}`, { - headers: this.getHeaders(localOptions), + headers: this.getHeaders(localOptions, timestampOfLatestUpdate), cache: this.cacheMode, }, ).then<{ value: T; digest: string }>(async (res) => { @@ -453,12 +394,19 @@ export class Controller { private getHeaders( localOptions: EdgeConfigFunctionsOptions | undefined, + minUpdatedAt: number | undefined, ): Headers { const headers: Record = { Authorization: `Bearer ${this.connection.token}`, }; const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) addConsistentReadHeader(localHeaders); + + if (localOptions?.consistentRead || minUpdatedAt) { + localHeaders.set( + 'x-edge-config-min-updated-at', + `${localOptions?.consistentRead ? Number.MAX_SAFE_INTEGER : minUpdatedAt}`, + ); + } return localHeaders; } diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 180611c61..00a481bd2 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -186,7 +186,7 @@ export interface EdgeConfigClientOptions { cache?: 'no-store' | 'force-cache'; } -export type Source = +export type CacheSource = | 'HIT' // value is cached and deemed fresh | 'STALE' // value is cached but we know it's outdated | 'MISS' // value was fetched over network as the staleThreshold was exceeded diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/enhanced-fetch.ts index b67177567..d3bf2e0d5 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -78,7 +78,9 @@ export function createEnhancedFetch(): ( ? [r, cachedResponse.clone()] : [r, null]; - if (pendingRequest) return pendingRequest.then(attach); + // we need to clone to avoid returning the same request as its body + // can only be consumed once + if (pendingRequest) return pendingRequest.then((r) => attach(r.clone())); const promise = fetch(url, options) .then((res) => { From 3daaa911468428c37b8a92991cb0a4a659e3aca7 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 30 Jul 2025 16:49:24 +0300 Subject: [PATCH 27/81] remove responseTimestamp --- packages/edge-config/src/controller.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index e1ba6e20a..dc18b92e0 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -30,9 +30,8 @@ function parseTs(updatedAt: string | null): number | null { } export class Controller { - private edgeConfigCache: - | (EmbeddedEdgeConfig & { updatedAt: number; responseTimestamp: number }) - | null = null; + private edgeConfigCache: (EmbeddedEdgeConfig & { updatedAt: number }) | null = + null; private itemCache = new Map< string, // an undefined value signals the key does not exist @@ -40,7 +39,6 @@ export class Controller { value: EdgeConfigValue | undefined; updatedAt: number; digest: string; - responseTimestamp: number; } >(); @@ -220,7 +218,6 @@ export class Controller { value, updatedAt, digest, - responseTimestamp: Date.now(), }); } } @@ -243,7 +240,6 @@ export class Controller { value: undefined, updatedAt, digest, - responseTimestamp: Date.now(), }); } return { value: undefined, digest, cache: 'MISS' }; From 8e098268652a96f04b31161ddd97a77033adb6b3 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 30 Jul 2025 16:52:26 +0300 Subject: [PATCH 28/81] simplify --- packages/edge-config/src/controller.ts | 40 ++++++++++---------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index dc18b92e0..2ff84e425 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -29,19 +29,17 @@ function parseTs(updatedAt: string | null): number | null { return parsed; } +interface CachedItem { + // an undefined value signals the key does not exist + value: T | undefined; + updatedAt: number; + digest: string; +} + export class Controller { private edgeConfigCache: (EmbeddedEdgeConfig & { updatedAt: number }) | null = null; - private itemCache = new Map< - string, - // an undefined value signals the key does not exist - { - value: EdgeConfigValue | undefined; - updatedAt: number; - digest: string; - } - >(); - + private itemCache = new Map(); private connection: Connection; private staleThreshold: number; private cacheMode: 'no-store' | 'force-cache'; @@ -152,32 +150,24 @@ export class Controller { if (cachedItem && cachedConfig) { return cachedItem.updatedAt > cachedConfig.updatedAt - ? { - value: cachedItem.value as T | undefined, - updatedAt: cachedItem.updatedAt, - digest: cachedItem.digest, - } - : { + ? (cachedItem as CachedItem) + : ({ digest: cachedConfig.digest, - value: cachedConfig.items[key] as T | undefined, + value: cachedConfig.items[key], updatedAt: cachedConfig.updatedAt, - }; + } as CachedItem); } if (cachedItem && !cachedConfig) { - return { - value: cachedItem.value as T | undefined, - updatedAt: cachedItem.updatedAt, - digest: cachedItem.digest, - }; + return cachedItem as CachedItem; } if (!cachedItem && cachedConfig) { return { - value: cachedConfig.items[key] as T | undefined, + value: cachedConfig.items[key], updatedAt: cachedConfig.updatedAt, digest: cachedConfig.digest, - }; + } as CachedItem; } return null; From d792d8c776245d7e17875b52fbcb3aad180655a8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 30 Jul 2025 18:01:44 +0300 Subject: [PATCH 29/81] fix fetch deduping tests --- packages/edge-config/src/controller.ts | 6 +++--- .../edge-config/src/utils/enhanced-fetch.test.ts | 14 +++++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 2ff84e425..e8eec2127 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -81,11 +81,11 @@ export class Controller { // check full config cache // check item cache // - // pick newer version on HIT + // if HIT, pick newer version // otherwise - // background fetch if STALE - // blocking fetch if MISS + // if STALE, serve cached value and trigger background refresh + // if MISS, perform blocking fetch // only use the cache if we have a timestamp of the latest update if (timestampOfLatestUpdate) { diff --git a/packages/edge-config/src/utils/enhanced-fetch.test.ts b/packages/edge-config/src/utils/enhanced-fetch.test.ts index b77036462..503df058c 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.test.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.test.ts @@ -20,14 +20,22 @@ describe('enhancedFetch', () => { const invocation1Promise = enhancedFetch('https://example.com/api/data'); const invocation2Promise = enhancedFetch('https://example.com/api/data'); const invocation3Promise = enhancedFetch('https://example.com/api/data'); - resolve(new Response(JSON.stringify({ name: 'John' }))); + resolve( + new Response(JSON.stringify({ name: 'John' }), { + headers: { 'content-type': 'application/json' }, + }), + ); const [res1, res2, res3] = await Promise.all([ invocation1Promise, invocation2Promise, invocation3Promise, ]); - expect(res1).toStrictEqual(res2); - expect(res1).toStrictEqual(res3); + expect(res1).toEqual([expect.any(Response), null]); + expect(res2).toEqual([expect.any(Response), null]); + expect(res3).toEqual([expect.any(Response), null]); + await expect(res1[0].json()).resolves.toStrictEqual({ name: 'John' }); + await expect(res2[0].json()).resolves.toStrictEqual({ name: 'John' }); + await expect(res3[0].json()).resolves.toStrictEqual({ name: 'John' }); expect(fetchMock).toHaveBeenCalledTimes(1); }); From 32837c271201f7b300ed40dc99601a462b739989 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 31 Jul 2025 16:31:26 +0300 Subject: [PATCH 30/81] implement and test getAll --- packages/edge-config/package.json | 4 +- packages/edge-config/src/controller.test.ts | 139 +++++++++++++- packages/edge-config/src/controller.ts | 190 +++++++++++++------- packages/edge-config/src/types.ts | 2 +- pnpm-lock.yaml | 22 +++ 5 files changed, 287 insertions(+), 70 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index d57d6a27d..39ec386d7 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -43,7 +43,9 @@ ], "testEnvironment": "node" }, - "dependencies": {}, + "dependencies": { + "@vercel/functions": "^2.2.5" + }, "devDependencies": { "@changesets/cli": "2.28.1", "@edge-runtime/jest-environment": "2.3.10", diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 020ba98ed..683367683 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -11,10 +11,15 @@ const connection: Connection = { }; // the "it" tests in the lifecycle are run sequentially, so their order matters -describe('lifecycle', () => { +describe('lifecycle: reading a single item', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + const controller = new Controller(connection, { enableDevelopmentCache: false, }); + it('should MISS the cache initially', async () => { setTimestampOfLatestUpdate(1000); fetchMock.mockResponseOnce(JSON.stringify('value1'), { @@ -120,7 +125,7 @@ describe('lifecycle', () => { }); }); - it('should have a blocking refresh after the stale threshold was exceeded', () => { + it('should have done a blocking refresh after the stale threshold was exceeded', () => { expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/item/key1?version=1', @@ -136,6 +141,136 @@ describe('lifecycle', () => { }); }); +describe('lifecycle: reading the full config', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('should MISS the cache initially', async () => { + setTimestampOfLatestUpdate(1000); + fetchMock.mockResponseOnce(JSON.stringify({ key1: 'value1' }), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value1' }, + digest: 'digest1', + cache: 'MISS', + }); + }); + + it('should fire off a background refresh after the cache MISS', () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/items?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'x-edge-config-min-updated-at': '1000', + }), + }, + ); + }); + + it('should HIT the cache if the timestamp has not changed', async () => { + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value1' }, + digest: 'digest1', + cache: 'HIT', + }); + }); + + it('should not fire off any background refreshes after the cache HIT', () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should serve a stale value if the timestamp has changed but is within the threshold', async () => { + setTimestampOfLatestUpdate(7000); + fetchMock.mockResponseOnce(JSON.stringify({ key1: 'value2' }), { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '7000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value1' }, + digest: 'digest1', + cache: 'STALE', + }); + }); + + it('should trigger a background refresh after the STALE value', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/items?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + // 'If-None-Match': '"digest1"', + 'x-edge-config-min-updated-at': '7000', + }), + }, + ); + }); + + it('should serve the new value from cache after the background refresh completes', async () => { + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value2' }, + digest: 'digest2', + cache: 'HIT', + }); + }); + + it('should not fire off any subsequent background refreshes', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should refresh when the stale threshold is exceeded', async () => { + setTimestampOfLatestUpdate(17001); + fetchMock.mockResponseOnce(JSON.stringify({ key1: 'value3' }), { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '17001', + }, + }); + + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value3' }, + digest: 'digest3', + cache: 'MISS', + }); + }); + + it('should have done a blocking refresh after the stale threshold was exceeded', () => { + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/items?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + // 'If-None-Match': '"digest1"', + 'x-edge-config-min-updated-at': '17001', + }), + }, + ); + }); +}); + describe('deduping within a version', () => { beforeAll(() => { fetchMock.resetMocks(); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index e8eec2127..6b6355a16 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -1,10 +1,11 @@ +import { waitUntil } from '@vercel/functions'; import type { EdgeConfigValue, EmbeddedEdgeConfig, EdgeConfigFunctionsOptions, Connection, EdgeConfigClientOptions, - CacheSource, + CacheStatus, } from './types'; import { ERRORS, isEmptyKey, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; @@ -29,6 +30,18 @@ function parseTs(updatedAt: string | null): number | null { return parsed; } +function getCacheStatus( + latestUpdate: number | undefined, + updatedAt: number, + staleThreshold: number, +): CacheStatus { + if (latestUpdate === undefined) return 'MISS'; + if (latestUpdate <= updatedAt) return 'HIT'; + // check if it is within the threshold + if (updatedAt >= latestUpdate - staleThreshold) return 'STALE'; + return 'MISS'; +} + interface CachedItem { // an undefined value signals the key does not exist value: T | undefined; @@ -45,21 +58,6 @@ export class Controller { private cacheMode: 'no-store' | 'force-cache'; private enableDevelopmentCache: boolean; - /** - * A map of keys to pending promises - */ - private pendingItemFetches = new Map< - string, - { - minUpdatedAt: number; - promise: Promise<{ - value: EdgeConfigValue | undefined; - digest: string; - cache: CacheSource; - }>; - } - >(); - constructor( connection: Connection, options: EdgeConfigClientOptions & { enableDevelopmentCache: boolean }, @@ -73,7 +71,7 @@ export class Controller { public async get( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T | undefined; digest: string; cache: CacheSource }> { + ): Promise<{ value: T | undefined; digest: string; cache: CacheStatus }> { if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { return this.fetchItem(key, timestampOfLatestUpdate, localOptions); } @@ -92,8 +90,14 @@ export class Controller { const cached = this.getCachedItem(key); if (cached) { + const cacheStatus = getCacheStatus( + timestampOfLatestUpdate, + cached.updatedAt, + this.staleThreshold, + ); + // HIT - if (timestampOfLatestUpdate <= cached.updatedAt) { + if (cacheStatus === 'HIT') { return { value: cached.value, digest: cached.digest, @@ -101,18 +105,14 @@ export class Controller { }; } - // STALE - // we're outdated, but check if we can serve the STALE value - if ( - timestampOfLatestUpdate > cached.updatedAt && - cached.updatedAt >= timestampOfLatestUpdate - this.staleThreshold - ) { + // we're outdated, but we can still serve the STALE value + if (cacheStatus === 'STALE') { // background refresh - void this.fetchItem( - key, - timestampOfLatestUpdate, - localOptions, - ).catch(() => null); + waitUntil( + this.fetchItem(key, timestampOfLatestUpdate, localOptions).catch( + () => null, + ), + ); return { value: cached.value, @@ -173,6 +173,62 @@ export class Controller { return null; } + private async fetchFullConfig>( + minUpdatedAt: number | undefined, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T; + digest: string; + cache: CacheStatus; + }> { + return enhancedFetch( + `${this.connection.baseUrl}/items?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions, minUpdatedAt), + cache: this.cacheMode, + }, + ).then<{ value: T; digest: string; cache: CacheStatus }>( + async ([res, cachedRes]) => { + const digest = res.headers.get('x-edge-config-digest'); + // TODO this header is not present on responses of the real API currently, + // but we mock it in tests already + const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + + if (res.status === 401) { + // don't need to empty cachedRes as 401s can never be cached anyhow + await consumeResponseBody(res); + throw new Error(ERRORS.UNAUTHORIZED); + } + + // this can't really happen, but we need to ensure digest exists + if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + + if (res.ok || (res.status === 304 && cachedRes)) { + const value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as T; + + if (res.status === 304) await consumeResponseBody(res); + + if (updatedAt) { + const existing = this.edgeConfigCache; + if (!existing || existing.updatedAt < updatedAt) { + this.edgeConfigCache = { + items: value, + updatedAt, + digest, + }; + } + } + + return { value, digest, cache: 'MISS' }; + } + + throw new UnexpectedNetworkError(res); + }, + ); + } + private fetchItem( key: string, minUpdatedAt: number | undefined, @@ -180,15 +236,15 @@ export class Controller { ): Promise<{ value: T | undefined; digest: string; - cache: CacheSource; + cache: CacheStatus; }> { - const promise = enhancedFetch( + return enhancedFetch( `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, { headers: this.getHeaders(localOptions, minUpdatedAt), cache: this.cacheMode, }, - ).then<{ value: T | undefined; digest: string; cache: CacheSource }>( + ).then<{ value: T | undefined; digest: string; cache: CacheStatus }>( async ([res, cachedRes]) => { const digest = res.headers.get('x-edge-config-digest'); // TODO this header is not present on responses of the real API currently, @@ -200,6 +256,9 @@ export class Controller { const value = (await ( res.status === 304 && cachedRes ? cachedRes : res ).json()) as T; + + if (res.status === 304) await consumeResponseBody(res); + // set the cache if the loaded value is newer than the cached one if (updatedAt) { const existing = this.itemCache.get(key); @@ -241,19 +300,12 @@ export class Controller { throw new UnexpectedNetworkError(res); }, ); - - // save the pending promise and the minimum updatedAt - if (minUpdatedAt) { - this.pendingItemFetches.set(key, { minUpdatedAt, promise }); - } - - return promise; } public async has( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ exists: boolean; digest: string; cache: CacheSource }> { + ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }> { return fetch( `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, { @@ -261,7 +313,7 @@ export class Controller { headers: this.getHeaders(localOptions, timestampOfLatestUpdate), cache: this.cacheMode, }, - ).then<{ exists: boolean; digest: string; cache: CacheSource }>((res) => { + ).then<{ exists: boolean; digest: string; cache: CacheStatus }>((res) => { if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); const digest = res.headers.get('x-edge-config-digest'); @@ -348,34 +400,40 @@ export class Controller { }); } - public async getAll( + public async getAll>( localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T; digest: string }> { - return fetch( - `${this.connection.baseUrl}/items?version=${this.connection.version}`, - { - headers: this.getHeaders(localOptions, timestampOfLatestUpdate), - cache: this.cacheMode, - }, - ).then<{ value: T; digest: string }>(async (res) => { - if (res.ok) { - const digest = res.headers.get('x-edge-config-digest'); - if (!digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - const value = (await res.json()) as T; - return { value, digest }; + ): Promise<{ value: T; digest: string; cache: CacheStatus }> { + // if we have the items and they + if (timestampOfLatestUpdate && this.edgeConfigCache) { + const cacheStatus = getCacheStatus( + timestampOfLatestUpdate, + this.edgeConfigCache.updatedAt, + this.staleThreshold, + ); + + // HIT + if (cacheStatus === 'HIT') { + return { + value: this.edgeConfigCache.items as T, + digest: this.edgeConfigCache.digest, + cache: 'HIT', + }; } - await consumeResponseBody(res); - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - // the /items endpoint never returns 404, so if we get a 404 - // it means the edge config itself did not exist - if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - // if (res.cachedResponseBody !== undefined) - // return res.cachedResponseBody as T; - throw new UnexpectedNetworkError(res); - }); + if (cacheStatus === 'STALE') { + // background refresh + waitUntil( + this.fetchFullConfig(timestampOfLatestUpdate, localOptions).catch(), + ); + return { + value: this.edgeConfigCache.items as T, + digest: this.edgeConfigCache.digest, + cache: 'STALE', + }; + } + } + + return this.fetchFullConfig(timestampOfLatestUpdate, localOptions); } private getHeaders( diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 00a481bd2..456edce16 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -186,7 +186,7 @@ export interface EdgeConfigClientOptions { cache?: 'no-store' | 'force-cache'; } -export type CacheSource = +export type CacheStatus = | 'HIT' // value is cached and deemed fresh | 'STALE' // value is cached but we know it's outdated | 'MISS' // value was fetched over network as the staleThreshold was exceeded diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aedd3bc51..d07f59b5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@opentelemetry/api': specifier: ^1.7.0 version: 1.7.0 + '@vercel/functions': + specifier: ^2.2.5 + version: 2.2.5 devDependencies: '@changesets/cli': specifier: 2.28.1 @@ -1865,6 +1868,19 @@ packages: '@upstash/redis@1.34.0': resolution: {integrity: sha512-TrXNoJLkysIl8SBc4u9bNnyoFYoILpCcFJcLyWCccb/QSUmaVKdvY0m5diZqc3btExsapcMbaw/s/wh9Sf1pJw==} + '@vercel/functions@2.2.5': + resolution: {integrity: sha512-ghRGwKd0o2QYhv6Zn/dMtnJV7S1ckUwChMFQYMr9RIg4e/cnwF5OgXuyVpoMeGRiDhSMvfOAH50iSwmoDwgZKw==} + engines: {node: '>= 18'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + + '@vercel/oidc@2.0.0': + resolution: {integrity: sha512-U0hncpXof7gC9xtmSrjz6vrDqdwJXi8z/zSc9FyS80AoXKfCZtpkBz9gtL3x30Agmpxpwe35P1W2dP9Epa/RGA==} + engines: {node: '>= 18'} + '@vercel/style-guide@5.2.0': resolution: {integrity: sha512-fNSKEaZvSkiBoF6XEefs8CcgAV9K9e+MbcsDZjUsktHycKdA0jvjAzQi1W/FzLS+Nr5zZ6oejCwq/97dHUKe0g==} engines: {node: '>=16'} @@ -7218,6 +7234,12 @@ snapshots: dependencies: crypto-js: 4.2.0 + '@vercel/functions@2.2.5': + dependencies: + '@vercel/oidc': 2.0.0 + + '@vercel/oidc@2.0.0': {} + '@vercel/style-guide@5.2.0(@next/eslint-plugin-next@14.2.23)(eslint@8.56.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(prettier@3.5.2)(typescript@5.7.3)': dependencies: '@babel/core': 7.23.9 From 701f1676ea2aecaf21bfc81c92b2fe4d3b896456 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 31 Jul 2025 16:33:02 +0300 Subject: [PATCH 31/81] async --- packages/edge-config/src/controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 6b6355a16..eb70b6005 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -229,7 +229,7 @@ export class Controller { ); } - private fetchItem( + private async fetchItem( key: string, minUpdatedAt: number | undefined, localOptions?: EdgeConfigFunctionsOptions, From 5180fbcaf6179aaf6257774203a891f438f3b919 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 31 Jul 2025 21:29:30 +0300 Subject: [PATCH 32/81] add HEAD and has() --- packages/edge-config/src/controller.test.ts | 142 +++++++- packages/edge-config/src/controller.ts | 325 +++++++++++++----- .../edge-config/src/utils/enhanced-fetch.ts | 1 + 3 files changed, 372 insertions(+), 96 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 683367683..b83fd0aea 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -38,11 +38,12 @@ describe('lifecycle: reading a single item', () => { }); }); - it('should fire off a background refresh after the cache MISS', () => { + it('should have performed a blocking fetch to resolve the cache MISS', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/item/key1?version=1', { + method: 'GET', cache: 'no-store', headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', @@ -87,6 +88,7 @@ describe('lifecycle: reading a single item', () => { expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/item/key1?version=1', { + method: 'GET', cache: 'no-store', headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', @@ -130,6 +132,7 @@ describe('lifecycle: reading a single item', () => { expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/item/key1?version=1', { + method: 'GET', cache: 'no-store', headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', @@ -168,7 +171,7 @@ describe('lifecycle: reading the full config', () => { }); }); - it('should fire off a background refresh after the cache MISS', () => { + it('should have performed a blocking fetch to resolve the cache MISS', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/items?version=1', @@ -271,6 +274,139 @@ describe('lifecycle: reading the full config', () => { }); }); +describe('lifecycle: checking existence of a single item', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('should MISS the cache initially', async () => { + setTimestampOfLatestUpdate(1000); + fetchMock.mockResponseOnce('', { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.has('key1')).resolves.toEqual({ + exists: true, + digest: 'digest1', + cache: 'MISS', + }); + }); + + it('should have performed a blocking fetch to resolve the cache MISS', () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/item/key1?version=1', + { + method: 'HEAD', + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'x-edge-config-min-updated-at': '1000', + }), + }, + ); + }); + + it('should HIT the cache if the timestamp has not changed', async () => { + await expect(controller.has('key1')).resolves.toEqual({ + exists: true, + digest: 'digest1', + cache: 'HIT', + }); + }); + + it('should not fire off any background refreshes after the cache HIT', () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should serve a stale exitence value if the timestamp has changed but is within the threshold', async () => { + setTimestampOfLatestUpdate(7000); + fetchMock.mockResponseOnce('', { + status: 404, + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '7000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.has('key1')).resolves.toEqual({ + exists: true, + digest: 'digest1', + cache: 'STALE', + }); + }); + + it('should trigger a background refresh after the STALE value', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/item/key1?version=1', + { + method: 'HEAD', + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'x-edge-config-min-updated-at': '7000', + }), + }, + ); + }); + + it('should serve the new value from cache after the background refresh completes', async () => { + await expect(controller.has('key1')).resolves.toEqual({ + exists: false, + digest: 'digest2', + cache: 'HIT', + }); + }); + + it('should not fire off any subsequent background refreshes', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should refresh when the stale threshold is exceeded', async () => { + setTimestampOfLatestUpdate(17001); + fetchMock.mockResponseOnce('', { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '17001', + }, + }); + + await expect(controller.has('key1')).resolves.toEqual({ + exists: true, + digest: 'digest3', + cache: 'MISS', + }); + }); + + it('should have done a blocking refresh after the stale threshold was exceeded', () => { + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/item/key1?version=1', + { + method: 'HEAD', + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + // 'If-None-Match': '"digest1"', + 'x-edge-config-min-updated-at': '17001', + }), + }, + ); + }); +}); + describe('deduping within a version', () => { beforeAll(() => { fetchMock.resetMocks(); @@ -471,6 +607,7 @@ describe('development cache', () => { expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/item/key1?version=1', { + method: 'GET', cache: 'no-store', headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', @@ -502,6 +639,7 @@ describe('development cache', () => { expect(fetchMock).toHaveBeenLastCalledWith( 'https://edge-config.vercel.com/item/key1?version=1', { + method: 'GET', cache: 'no-store', headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index eb70b6005..b4f8f1805 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -11,12 +11,23 @@ import { ERRORS, isEmptyKey, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; import { createEnhancedFetch } from './utils/enhanced-fetch'; -const enhancedFetch = createEnhancedFetch(); - const DEFAULT_STALE_THRESHOLD = 10_000; // 10 seconds let timestampOfLatestUpdate: number | undefined; +/** + * A symbol to represent an unresolved value. + * + * This is used to indicate that a value is not yet available, but we want to + * cache the fact that it exists. + * + * This is only used if we definitely know that the value exists, we just don't + * know what it is yet. + * + * When we definitely know a value does not exist, we use "undefined" instead. + */ +const unresolvedValue = Symbol('unresolvedValue'); + export function setTimestampOfLatestUpdate( timestamp: number | undefined, ): void { @@ -43,8 +54,20 @@ function getCacheStatus( } interface CachedItem { - // an undefined value signals the key does not exist - value: T | undefined; + /** + * An "undefined" value signals the key does not exist on the Edge Config in + * the specified version and updatedAt timestamp. + * + * An "unresolved" signals that the value is not yet available, + */ + value: T | undefined | typeof unresolvedValue; + /** the timestamp of the edge config's last update (not the item's) */ + updatedAt: number; + digest: string; +} + +interface ResolvedCachedItem { + value: Exclude | undefined; updatedAt: number; digest: string; } @@ -58,6 +81,9 @@ export class Controller { private cacheMode: 'no-store' | 'force-cache'; private enableDevelopmentCache: boolean; + // create an instance per controller so the caches are isolated + private enhancedFetch: ReturnType; + constructor( connection: Connection, options: EdgeConfigClientOptions & { enableDevelopmentCache: boolean }, @@ -66,6 +92,7 @@ export class Controller { this.staleThreshold = options.staleThreshold ?? DEFAULT_STALE_THRESHOLD; this.cacheMode = options.cache || 'no-store'; this.enableDevelopmentCache = options.enableDevelopmentCache; + this.enhancedFetch = createEnhancedFetch(); } public async get( @@ -73,7 +100,18 @@ export class Controller { localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T | undefined; digest: string; cache: CacheStatus }> { if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { - return this.fetchItem(key, timestampOfLatestUpdate, localOptions); + return this.fetchItem( + 'GET', + key, + timestampOfLatestUpdate, + localOptions, + ).then((res) => ({ + // TODO typescript should know that GET never returns unresolvedValue, + // so we should not need the explicit "as" cast or the whole "then" statement + value: res.value as T | undefined, + digest: res.digest, + cache: res.cache, + })); } // check full config cache @@ -87,7 +125,9 @@ export class Controller { // only use the cache if we have a timestamp of the latest update if (timestampOfLatestUpdate) { - const cached = this.getCachedItem(key); + const cached = this.getCachedItem(key, true) as + | ResolvedCachedItem + | undefined; if (cached) { const cacheStatus = getCacheStatus( @@ -109,9 +149,12 @@ export class Controller { if (cacheStatus === 'STALE') { // background refresh waitUntil( - this.fetchItem(key, timestampOfLatestUpdate, localOptions).catch( - () => null, - ), + this.fetchItem( + 'GET', + key, + timestampOfLatestUpdate, + localOptions, + ).catch(() => null), ); return { @@ -131,7 +174,18 @@ export class Controller { } // MISS - return this.fetchItem(key, timestampOfLatestUpdate, localOptions); + return this.fetchItem( + 'GET', + key, + timestampOfLatestUpdate, + localOptions, + ).then((res) => ({ + // TODO typescript should know that GET never returns unresolvedValue, + // so we should not need the explicit "as" cast or the whole "then" statement + value: res.value as T | undefined, + digest: res.digest, + cache: res.cache, + })); } /** @@ -140,26 +194,33 @@ export class Controller { */ private getCachedItem( key: string, - ): { - value: T | undefined; - updatedAt: number; - digest: string; - } | null { - const cachedItem = this.itemCache.get(key); + ignoreUnresolved: boolean, + ): typeof ignoreUnresolved extends true + ? ResolvedCachedItem | null + : CachedItem | null { + const v = this.itemCache.get(key); + const cachedItem = + ignoreUnresolved && v?.value === unresolvedValue ? undefined : v; const cachedConfig = this.edgeConfigCache; if (cachedItem && cachedConfig) { return cachedItem.updatedAt > cachedConfig.updatedAt - ? (cachedItem as CachedItem) + ? (cachedItem as typeof ignoreUnresolved extends true + ? ResolvedCachedItem + : CachedItem) : ({ digest: cachedConfig.digest, - value: cachedConfig.items[key], + value: cachedConfig.items[key] as T, updatedAt: cachedConfig.updatedAt, - } as CachedItem); + } as typeof ignoreUnresolved extends true + ? ResolvedCachedItem + : CachedItem); } if (cachedItem && !cachedConfig) { - return cachedItem as CachedItem; + return cachedItem as typeof ignoreUnresolved extends true + ? ResolvedCachedItem + : CachedItem; } if (!cachedItem && cachedConfig) { @@ -167,7 +228,9 @@ export class Controller { value: cachedConfig.items[key], updatedAt: cachedConfig.updatedAt, digest: cachedConfig.digest, - } as CachedItem; + } as typeof ignoreUnresolved extends true + ? ResolvedCachedItem + : CachedItem; } return null; @@ -181,7 +244,7 @@ export class Controller { digest: string; cache: CacheStatus; }> { - return enhancedFetch( + return this.enhancedFetch( `${this.connection.baseUrl}/items?version=${this.connection.version}`, { headers: this.getHeaders(localOptions, minUpdatedAt), @@ -229,108 +292,182 @@ export class Controller { ); } + /** + * Loads the item using a GET or check its existence using a HEAD request. + */ private async fetchItem( + method: 'GET' | 'HEAD', key: string, minUpdatedAt: number | undefined, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ - value: T | undefined; + value: T | undefined | typeof unresolvedValue; digest: string; cache: CacheStatus; }> { - return enhancedFetch( + return this.enhancedFetch( `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, { + method, headers: this.getHeaders(localOptions, minUpdatedAt), cache: this.cacheMode, }, - ).then<{ value: T | undefined; digest: string; cache: CacheStatus }>( - async ([res, cachedRes]) => { - const digest = res.headers.get('x-edge-config-digest'); - // TODO this header is not present on responses of the real API currently, - // but we mock it in tests already - const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); - if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - - if (res.ok || (res.status === 304 && cachedRes)) { - const value = (await ( - res.status === 304 && cachedRes ? cachedRes : res - ).json()) as T; + ).then<{ + value: typeof method extends 'GET' + ? T | undefined + : T | undefined | typeof unresolvedValue; + digest: string; + cache: CacheStatus; + }>(async ([res, cachedRes]) => { + const digest = res.headers.get('x-edge-config-digest'); + // TODO this header is not present on responses of the real API currently, + // but we mock it in tests already + const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + + if (res.ok || (res.status === 304 && cachedRes)) { + // avoid undici memory leaks by consuming response bodies + if (method === 'HEAD') { + waitUntil( + Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]), + ); + } else if (res.status === 304) { + waitUntil(consumeResponseBody(res)); + } - if (res.status === 304) await consumeResponseBody(res); + const value = + method === 'GET' + ? ((await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as T) + : unresolvedValue; + if (updatedAt) { + const existing = this.itemCache.get(key); // set the cache if the loaded value is newer than the cached one - if (updatedAt) { - const existing = this.itemCache.get(key); - if (!existing || existing.updatedAt < updatedAt) { - this.itemCache.set(key, { - value, - updatedAt, - digest, - }); - } + if (!existing || existing.updatedAt < updatedAt) { + this.itemCache.set(key, { + value, + updatedAt, + digest, + }); } - return { value, digest, cache: 'MISS' }; } + return { value, digest, cache: 'MISS' }; + } - await Promise.all([ - consumeResponseBody(res), - cachedRes ? consumeResponseBody(cachedRes) : null, - ]); - - 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 (digest && updatedAt) { - const existing = this.itemCache.get(key); - if (!existing || existing.updatedAt < updatedAt) { - this.itemCache.set(key, { - value: undefined, - updatedAt, - digest, - }); - } - return { value: undefined, digest, cache: 'MISS' }; + await Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]); + + 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 (digest && updatedAt) { + const existing = this.itemCache.get(key); + if (!existing || existing.updatedAt < updatedAt) { + this.itemCache.set(key, { value: undefined, updatedAt, digest }); } - // 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); + return { value: undefined, digest, cache: 'MISS' }; } - throw new UnexpectedNetworkError(res); - }, - ); + // 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); + } + throw new UnexpectedNetworkError(res); + }); } public async has( key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }> { - return fetch( - `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, - { - method: 'HEAD', - headers: this.getHeaders(localOptions, timestampOfLatestUpdate), - cache: this.cacheMode, - }, - ).then<{ exists: boolean; digest: string; cache: CacheStatus }>((res) => { - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - const digest = res.headers.get('x-edge-config-digest'); + if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { + return this.fetchItem( + 'HEAD', + key, + timestampOfLatestUpdate, + localOptions, + ).then((res) => ({ + exists: res.value !== undefined, + digest: res.digest, + cache: res.cache, + })); + } - if (!digest) { - // 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); + // check full config cache + // check item cache + // + // if HIT, pick newer version + + // otherwise + // if STALE, serve cached value and trigger background refresh + // if MISS, perform blocking fetch + + // only use the cache if we have a timestamp of the latest update + if (timestampOfLatestUpdate) { + const cached = this.getCachedItem(key, false) as CachedItem | undefined; + + if (cached) { + const cacheStatus = getCacheStatus( + timestampOfLatestUpdate, + cached.updatedAt, + this.staleThreshold, + ); + + // HIT + if (cacheStatus === 'HIT') { + return { + exists: cached.value !== undefined, + digest: cached.digest, + cache: 'HIT', + }; + } + + // we're outdated, but we can still serve the STALE value + if (cacheStatus === 'STALE') { + // background refresh + waitUntil( + this.fetchItem( + 'HEAD', + key, + timestampOfLatestUpdate, + localOptions, + ).catch(() => null), + ); + + return { + exists: cached.value !== undefined, + digest: cached.digest, + cache: 'STALE', + }; + + // we're outdated, but we can't serve the STALE value + // so we need to fetch the latest value in a BLOCKING way and then + // update the cache afterwards + // + // this is the same behavior as if we had no cache it at all, + // so we just fall through + } } + } - if (res.ok) - return { - digest, - exists: res.status !== 404, - cache: 'MISS', - }; - throw new UnexpectedNetworkError(res); - }); + // MISS + return this.fetchItem( + 'HEAD', + key, + timestampOfLatestUpdate, + localOptions, + ).then((res) => ({ + exists: res.value !== undefined, + digest: res.digest, + cache: res.cache, + })); } public async digest( diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/enhanced-fetch.ts index d3bf2e0d5..dfa142265 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.ts +++ b/packages/edge-config/src/utils/enhanced-fetch.ts @@ -13,6 +13,7 @@ function getDedupeCacheKey(url: string, init?: RequestInit): string { return JSON.stringify({ url, + method: init?.method, authorization: h.get('Authorization'), minUpdatedAt: h.get('x-edge-config-min-updated-at'), }); From 8ddba8b6bb4d14ef25ca9c81425e5a99cb8fd24e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 31 Jul 2025 21:45:45 +0300 Subject: [PATCH 33/81] more tests --- packages/edge-config/src/controller.test.ts | 136 +++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index b83fd0aea..efe263fcb 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -527,7 +527,7 @@ describe('bypassing dedupe when the timestamp changes', () => { }); }); -describe('development cache', () => { +describe('development cache: get', () => { const controller = new Controller(connection, { enableDevelopmentCache: true, }); @@ -652,3 +652,137 @@ describe('development cache', () => { expect(fetchMock).toHaveBeenCalledTimes(4); }); }); + +describe('development cache: has', () => { + const controller = new Controller(connection, { + enableDevelopmentCache: true, + }); + beforeAll(() => { + fetchMock.resetMocks(); + }); + + it('should fetch initially', async () => { + setTimestampOfLatestUpdate(undefined); + fetchMock.mockResponseOnce('', { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + }, + }); + + await expect(controller.has('key1')).resolves.toEqual({ + exists: true, + digest: 'digest1', + cache: 'MISS', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should not fetch when another fetch is pending', async () => { + fetchMock.mockResponseOnce('', { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '1000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }); + + // run them in parallel so the deduplication can take action + const [promise1, promise2] = [ + controller.has('key1'), + controller.has('key1'), + ]; + + await expect(promise1).resolves.toEqual({ + exists: true, + digest: 'digest2', + cache: 'MISS', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + + await expect(promise2).resolves.toEqual({ + exists: true, + digest: 'digest2', + cache: 'MISS', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('lifecycle: mixing get, has and getAll', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('get(key1) should MISS the cache initially', async () => { + setTimestampOfLatestUpdate(1000); + fetchMock.mockResponseOnce(JSON.stringify('value1'), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + cache: 'MISS', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('has(key1) should HIT the cache subsequently', async () => { + await expect(controller.has('key1')).resolves.toEqual({ + exists: true, + digest: 'digest1', + cache: 'HIT', + }); + // still one + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('has(key2) should MISS the cache initially', async () => { + fetchMock.mockResponseOnce('', { + status: 404, + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.has('key2')).resolves.toEqual({ + exists: false, + digest: 'digest1', + cache: 'MISS', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('get(key2) should HIT the cache subsequently', async () => { + // in this case GET knows that the value does not exist, + // so it does not need to perform a fetch at all since + // there is no such item + setTimestampOfLatestUpdate(1000); + await expect(controller.get('key2')).resolves.toEqual({ + value: undefined, + digest: 'digest1', + cache: 'HIT', + }); + // still two + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); From 95552e470cc611b9e1f4cbd2a2ded6af84598bcc Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 1 Aug 2025 09:52:58 +0300 Subject: [PATCH 34/81] refactor with ai --- packages/edge-config/src/controller.ts | 305 +++++++++---------------- 1 file changed, 105 insertions(+), 200 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index b4f8f1805..a82b1d02b 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -16,17 +16,14 @@ const DEFAULT_STALE_THRESHOLD = 10_000; // 10 seconds let timestampOfLatestUpdate: number | undefined; /** - * A symbol to represent an unresolved value. - * - * This is used to indicate that a value is not yet available, but we want to - * cache the fact that it exists. - * - * This is only used if we definitely know that the value exists, we just don't - * know what it is yet. - * - * When we definitely know a value does not exist, we use "undefined" instead. + * Unified cache entry that stores both the value and existence information */ -const unresolvedValue = Symbol('unresolvedValue'); +interface CacheEntry { + value: T | undefined; + updatedAt: number; + digest: string; + exists: boolean; +} export function setTimestampOfLatestUpdate( timestamp: number | undefined, @@ -53,29 +50,10 @@ function getCacheStatus( return 'MISS'; } -interface CachedItem { - /** - * An "undefined" value signals the key does not exist on the Edge Config in - * the specified version and updatedAt timestamp. - * - * An "unresolved" signals that the value is not yet available, - */ - value: T | undefined | typeof unresolvedValue; - /** the timestamp of the edge config's last update (not the item's) */ - updatedAt: number; - digest: string; -} - -interface ResolvedCachedItem { - value: Exclude | undefined; - updatedAt: number; - digest: string; -} - export class Controller { private edgeConfigCache: (EmbeddedEdgeConfig & { updatedAt: number }) | null = null; - private itemCache = new Map(); + private itemCache = new Map(); private connection: Connection; private staleThreshold: number; private cacheMode: 'no-store' | 'force-cache'; @@ -105,29 +83,51 @@ export class Controller { key, timestampOfLatestUpdate, localOptions, - ).then((res) => ({ - // TODO typescript should know that GET never returns unresolvedValue, - // so we should not need the explicit "as" cast or the whole "then" statement - value: res.value as T | undefined, - digest: res.digest, - cache: res.cache, - })); + ); } - // check full config cache - // check item cache - // - // if HIT, pick newer version + return this.handleCachedRequest< + T, + { value: T | undefined; digest: string; cache: CacheStatus } + >(key, 'GET', localOptions, (cached, cacheStatus) => ({ + value: cached.value, + digest: cached.digest, + cache: cacheStatus, + })); + } - // otherwise - // if STALE, serve cached value and trigger background refresh - // if MISS, perform blocking fetch + /** + * Updates the edge config cache if the new data is newer + */ + private updateEdgeConfigCache( + items: Record, + updatedAt: number | null, + digest: string, + ): void { + if (updatedAt) { + const existing = this.edgeConfigCache; + if (!existing || existing.updatedAt < updatedAt) { + this.edgeConfigCache = { + items, + updatedAt, + digest, + }; + } + } + } + /** + * Generic handler for cached requests that implements the common cache logic + */ + private async handleCachedRequest( + key: string, + method: 'GET' | 'HEAD', + localOptions: EdgeConfigFunctionsOptions | undefined, + callback: (cached: CacheEntry, cacheStatus: CacheStatus) => R, + ): Promise { // only use the cache if we have a timestamp of the latest update if (timestampOfLatestUpdate) { - const cached = this.getCachedItem(key, true) as - | ResolvedCachedItem - | undefined; + const cached = this.getCachedItem(key); if (cached) { const cacheStatus = getCacheStatus( @@ -137,55 +137,47 @@ export class Controller { ); // HIT - if (cacheStatus === 'HIT') { - return { - value: cached.value, - digest: cached.digest, - cache: 'HIT', - }; - } + if (cacheStatus === 'HIT') return callback(cached, 'HIT'); // we're outdated, but we can still serve the STALE value if (cacheStatus === 'STALE') { // background refresh waitUntil( this.fetchItem( - 'GET', + method, key, timestampOfLatestUpdate, localOptions, ).catch(() => null), ); - return { - value: cached.value, - digest: cached.digest, - cache: 'STALE', - }; - - // we're outdated, but we can't serve the STALE value - // so we need to fetch the latest value in a BLOCKING way and then - // update the cache afterwards - // - // this is the same behavior as if we had no cache it at all, - // so we just fall through + return callback(cached, 'STALE'); } } } // MISS - return this.fetchItem( - 'GET', + const result = await this.fetchItem( + method, key, timestampOfLatestUpdate, localOptions, - ).then((res) => ({ - // TODO typescript should know that GET never returns unresolvedValue, - // so we should not need the explicit "as" cast or the whole "then" statement - value: res.value as T | undefined, - digest: res.digest, - cache: res.cache, - })); + ); + + // For HEAD requests, we need to return the exists format + if (method === 'HEAD') { + return { + exists: result.value !== undefined, + digest: result.digest, + cache: result.cache, + } as R; + } + + return { + value: result.value, + digest: result.digest, + cache: result.cache, + } as R; } /** @@ -194,43 +186,32 @@ export class Controller { */ private getCachedItem( key: string, - ignoreUnresolved: boolean, - ): typeof ignoreUnresolved extends true - ? ResolvedCachedItem | null - : CachedItem | null { - const v = this.itemCache.get(key); - const cachedItem = - ignoreUnresolved && v?.value === unresolvedValue ? undefined : v; + ): CacheEntry | null { + const itemCacheEntry = this.itemCache.get(key); const cachedConfig = this.edgeConfigCache; - if (cachedItem && cachedConfig) { - return cachedItem.updatedAt > cachedConfig.updatedAt - ? (cachedItem as typeof ignoreUnresolved extends true - ? ResolvedCachedItem - : CachedItem) - : ({ + if (itemCacheEntry && cachedConfig) { + return itemCacheEntry.updatedAt >= cachedConfig.updatedAt + ? (itemCacheEntry as CacheEntry) + : { digest: cachedConfig.digest, value: cachedConfig.items[key] as T, updatedAt: cachedConfig.updatedAt, - } as typeof ignoreUnresolved extends true - ? ResolvedCachedItem - : CachedItem); + exists: Object.hasOwn(cachedConfig.items, key), + }; } - if (cachedItem && !cachedConfig) { - return cachedItem as typeof ignoreUnresolved extends true - ? ResolvedCachedItem - : CachedItem; + if (itemCacheEntry && !cachedConfig) { + return itemCacheEntry as CacheEntry; } - if (!cachedItem && cachedConfig) { + if (!itemCacheEntry && cachedConfig) { return { - value: cachedConfig.items[key], + value: cachedConfig.items[key] as T, updatedAt: cachedConfig.updatedAt, digest: cachedConfig.digest, - } as typeof ignoreUnresolved extends true - ? ResolvedCachedItem - : CachedItem; + exists: Object.hasOwn(cachedConfig.items, key), + }; } return null; @@ -253,17 +234,13 @@ export class Controller { ).then<{ value: T; digest: string; cache: CacheStatus }>( async ([res, cachedRes]) => { const digest = res.headers.get('x-edge-config-digest'); - // TODO this header is not present on responses of the real API currently, - // but we mock it in tests already const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); if (res.status === 401) { - // don't need to empty cachedRes as 401s can never be cached anyhow await consumeResponseBody(res); throw new Error(ERRORS.UNAUTHORIZED); } - // this can't really happen, but we need to ensure digest exists if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); if (res.ok || (res.status === 304 && cachedRes)) { @@ -273,16 +250,7 @@ export class Controller { if (res.status === 304) await consumeResponseBody(res); - if (updatedAt) { - const existing = this.edgeConfigCache; - if (!existing || existing.updatedAt < updatedAt) { - this.edgeConfigCache = { - items: value, - updatedAt, - digest, - }; - } - } + this.updateEdgeConfigCache(value, updatedAt, digest); return { value, digest, cache: 'MISS' }; } @@ -301,7 +269,7 @@ export class Controller { minUpdatedAt: number | undefined, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ - value: T | undefined | typeof unresolvedValue; + value: T | undefined; digest: string; cache: CacheStatus; }> { @@ -313,15 +281,11 @@ export class Controller { cache: this.cacheMode, }, ).then<{ - value: typeof method extends 'GET' - ? T | undefined - : T | undefined | typeof unresolvedValue; + value: T | undefined; digest: string; cache: CacheStatus; }>(async ([res, cachedRes]) => { const digest = res.headers.get('x-edge-config-digest'); - // TODO this header is not present on responses of the real API currently, - // but we mock it in tests already const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); @@ -338,21 +302,21 @@ export class Controller { waitUntil(consumeResponseBody(res)); } - const value = - method === 'GET' - ? ((await ( - res.status === 304 && cachedRes ? cachedRes : res - ).json()) as T) - : unresolvedValue; + let value: T | undefined; + if (method === 'GET') { + value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as T; + } if (updatedAt) { const existing = this.itemCache.get(key); - // set the cache if the loaded value is newer than the cached one if (!existing || existing.updatedAt < updatedAt) { this.itemCache.set(key, { value, updatedAt, digest, + exists: method === 'GET' ? value !== undefined : res.ok, }); } } @@ -366,17 +330,18 @@ export class Controller { 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 (digest && updatedAt) { const existing = this.itemCache.get(key); if (!existing || existing.updatedAt < updatedAt) { - this.itemCache.set(key, { value: undefined, updatedAt, digest }); + this.itemCache.set(key, { + value: undefined, + updatedAt, + digest, + exists: false, + }); } return { value: undefined, digest, cache: 'MISS' }; } - // 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); } throw new UnexpectedNetworkError(res); @@ -400,73 +365,13 @@ export class Controller { })); } - // check full config cache - // check item cache - // - // if HIT, pick newer version - - // otherwise - // if STALE, serve cached value and trigger background refresh - // if MISS, perform blocking fetch - - // only use the cache if we have a timestamp of the latest update - if (timestampOfLatestUpdate) { - const cached = this.getCachedItem(key, false) as CachedItem | undefined; - - if (cached) { - const cacheStatus = getCacheStatus( - timestampOfLatestUpdate, - cached.updatedAt, - this.staleThreshold, - ); - - // HIT - if (cacheStatus === 'HIT') { - return { - exists: cached.value !== undefined, - digest: cached.digest, - cache: 'HIT', - }; - } - - // we're outdated, but we can still serve the STALE value - if (cacheStatus === 'STALE') { - // background refresh - waitUntil( - this.fetchItem( - 'HEAD', - key, - timestampOfLatestUpdate, - localOptions, - ).catch(() => null), - ); - - return { - exists: cached.value !== undefined, - digest: cached.digest, - cache: 'STALE', - }; - - // we're outdated, but we can't serve the STALE value - // so we need to fetch the latest value in a BLOCKING way and then - // update the cache afterwards - // - // this is the same behavior as if we had no cache it at all, - // so we just fall through - } - } - } - - // MISS - return this.fetchItem( - 'HEAD', - key, - timestampOfLatestUpdate, - localOptions, - ).then((res) => ({ - exists: res.value !== undefined, - digest: res.digest, - cache: res.cache, + return this.handleCachedRequest< + EdgeConfigValue, + { exists: boolean; digest: string; cache: CacheStatus } + >(key, 'HEAD', localOptions, (cached, cacheStatus) => ({ + exists: cached.exists, + digest: cached.digest, + cache: cacheStatus, })); } From c8d1d9c080a143ca56321746299e8e24e992f33d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 1 Aug 2025 09:56:38 +0300 Subject: [PATCH 35/81] remove digest --- packages/edge-config/src/controller.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index a82b1d02b..ef2d04e6a 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -375,25 +375,6 @@ export class Controller { })); } - public async digest( - localOptions?: Pick, - ): Promise { - return fetch( - `${this.connection.baseUrl}/digest?version=${this.connection.version}`, - { - headers: this.getHeaders(localOptions, timestampOfLatestUpdate), - cache: this.cacheMode, - }, - ).then(async (res) => { - if (res.ok) return res.json() as Promise; - await consumeResponseBody(res); - - // if (res.cachedResponseBody !== undefined) - // return res.cachedResponseBody as string; - throw new UnexpectedNetworkError(res); - }); - } - public async getMultiple( keys: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, From c1d17bc60462e7514bffa65b53f2c8ed511badf6 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 1 Aug 2025 14:26:39 +0300 Subject: [PATCH 36/81] handle edge cases --- packages/edge-config/src/controller.test.ts | 95 ++++++++++++- packages/edge-config/src/controller.ts | 139 ++++++++++++-------- 2 files changed, 177 insertions(+), 57 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index efe263fcb..39d5cabb1 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -35,6 +35,8 @@ describe('lifecycle: reading a single item', () => { value: 'value1', digest: 'digest1', cache: 'MISS', + exists: true, + updatedAt: 1000, }); }); @@ -58,6 +60,8 @@ describe('lifecycle: reading a single item', () => { value: 'value1', digest: 'digest1', cache: 'HIT', + exists: true, + updatedAt: 1000, }); }); @@ -80,6 +84,8 @@ describe('lifecycle: reading a single item', () => { value: 'value1', digest: 'digest1', cache: 'STALE', + exists: true, + updatedAt: 1000, }); }); @@ -104,6 +110,8 @@ describe('lifecycle: reading a single item', () => { value: 'value2', digest: 'digest2', cache: 'HIT', + exists: true, + updatedAt: 7000, }); }); @@ -124,6 +132,8 @@ describe('lifecycle: reading a single item', () => { value: 'value3', digest: 'digest3', cache: 'MISS', + exists: true, + updatedAt: 17001, }); }); @@ -168,6 +178,7 @@ describe('lifecycle: reading the full config', () => { value: { key1: 'value1' }, digest: 'digest1', cache: 'MISS', + updatedAt: 1000, }); }); @@ -190,6 +201,7 @@ describe('lifecycle: reading the full config', () => { value: { key1: 'value1' }, digest: 'digest1', cache: 'HIT', + updatedAt: 1000, }); }); @@ -212,6 +224,7 @@ describe('lifecycle: reading the full config', () => { value: { key1: 'value1' }, digest: 'digest1', cache: 'STALE', + updatedAt: 1000, }); }); @@ -235,6 +248,7 @@ describe('lifecycle: reading the full config', () => { value: { key1: 'value2' }, digest: 'digest2', cache: 'HIT', + updatedAt: 7000, }); }); @@ -255,6 +269,7 @@ describe('lifecycle: reading the full config', () => { value: { key1: 'value3' }, digest: 'digest3', cache: 'MISS', + updatedAt: 17001, }); }); @@ -298,6 +313,7 @@ describe('lifecycle: checking existence of a single item', () => { exists: true, digest: 'digest1', cache: 'MISS', + updatedAt: 1000, }); }); @@ -321,6 +337,7 @@ describe('lifecycle: checking existence of a single item', () => { exists: true, digest: 'digest1', cache: 'HIT', + updatedAt: 1000, }); }); @@ -344,6 +361,7 @@ describe('lifecycle: checking existence of a single item', () => { exists: true, digest: 'digest1', cache: 'STALE', + updatedAt: 1000, }); }); @@ -367,6 +385,7 @@ describe('lifecycle: checking existence of a single item', () => { exists: false, digest: 'digest2', cache: 'HIT', + updatedAt: 7000, }); }); @@ -387,6 +406,7 @@ describe('lifecycle: checking existence of a single item', () => { exists: true, digest: 'digest3', cache: 'MISS', + updatedAt: 17001, }); }); @@ -442,6 +462,8 @@ describe('deduping within a version', () => { value: 'value1', digest: 'digest1', cache: 'MISS', + exists: true, + updatedAt: 1000, }); }); @@ -450,6 +472,8 @@ describe('deduping within a version', () => { value: 'value1', digest: 'digest1', cache: 'MISS', + updatedAt: 1000, + exists: true, }); }); @@ -463,6 +487,8 @@ describe('deduping within a version', () => { value: 'value1', digest: 'digest1', cache: 'HIT', + updatedAt: 1000, + exists: true, }); }); @@ -505,6 +531,8 @@ describe('bypassing dedupe when the timestamp changes', () => { value: 'value1', digest: 'digest1', cache: 'MISS', + updatedAt: 1000, + exists: true, }); read2.resolve( @@ -516,11 +544,12 @@ describe('bypassing dedupe when the timestamp changes', () => { }), ); - // reuses the pending fetch promise await expect(promisedValue2).resolves.toEqual({ value: 'value2', digest: 'digest2', cache: 'MISS', + updatedAt: 1001, + exists: true, }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -548,6 +577,8 @@ describe('development cache: get', () => { value: 'value1', digest: 'digest1', cache: 'MISS', + exists: true, + updatedAt: 1000, }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -573,6 +604,8 @@ describe('development cache: get', () => { value: 'value2', digest: 'digest2', cache: 'MISS', + exists: true, + updatedAt: 1000, }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -581,6 +614,8 @@ describe('development cache: get', () => { value: 'value2', digest: 'digest2', cache: 'MISS', + exists: true, + updatedAt: 1000, }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -602,6 +637,8 @@ describe('development cache: get', () => { digest: 'digest2', // hits the etag http cache, but misses the in-memory cache, so it's a MISS cache: 'MISS', + updatedAt: 1000, + exists: true, }); expect(fetchMock).toHaveBeenLastCalledWith( @@ -634,6 +671,8 @@ describe('development cache: get', () => { value: 'value3', digest: 'digest3', cache: 'MISS', + exists: true, + updatedAt: 1001, }); expect(fetchMock).toHaveBeenLastCalledWith( @@ -674,6 +713,7 @@ describe('development cache: has', () => { exists: true, digest: 'digest1', cache: 'MISS', + updatedAt: 1000, }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -696,9 +736,10 @@ describe('development cache: has', () => { ]; await expect(promise1).resolves.toEqual({ - exists: true, digest: 'digest2', cache: 'MISS', + exists: true, + updatedAt: 1000, }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -707,6 +748,7 @@ describe('development cache: has', () => { exists: true, digest: 'digest2', cache: 'MISS', + updatedAt: 1000, }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -737,6 +779,8 @@ describe('lifecycle: mixing get, has and getAll', () => { value: 'value1', digest: 'digest1', cache: 'MISS', + updatedAt: 1000, + exists: true, }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -747,6 +791,8 @@ describe('lifecycle: mixing get, has and getAll', () => { exists: true, digest: 'digest1', cache: 'HIT', + updatedAt: 1000, + value: 'value1', // we have the value from the previous GET call }); // still one expect(fetchMock).toHaveBeenCalledTimes(1); @@ -767,6 +813,8 @@ describe('lifecycle: mixing get, has and getAll', () => { exists: false, digest: 'digest1', cache: 'MISS', + updatedAt: 1000, + value: undefined, }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -781,8 +829,51 @@ describe('lifecycle: mixing get, has and getAll', () => { value: undefined, digest: 'digest1', cache: 'HIT', + exists: false, + updatedAt: 1000, }); // still two expect(fetchMock).toHaveBeenCalledTimes(2); }); + + it('has(key3) should MISS the cache initially', async () => { + fetchMock.mockResponseOnce(JSON.stringify('value3'), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.has('key3')).resolves.toEqual({ + exists: true, + digest: 'digest1', + cache: 'MISS', + updatedAt: 1000, + value: undefined, + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('get(key3) should MISS the cache subsequently', async () => { + fetchMock.mockResponseOnce(JSON.stringify('value3'), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.get('key3')).resolves.toEqual({ + exists: true, + digest: 'digest1', + cache: 'MISS', + updatedAt: 1000, + value: 'value3', + }); + // still one + expect(fetchMock).toHaveBeenCalledTimes(4); + }); }); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index ef2d04e6a..631e78171 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -76,7 +76,12 @@ export class Controller { public async get( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T | undefined; digest: string; cache: CacheStatus }> { + ): Promise<{ + value: T | undefined; + digest: string; + cache: CacheStatus; + exists: boolean; + }> { if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { return this.fetchItem( 'GET', @@ -86,14 +91,7 @@ export class Controller { ); } - return this.handleCachedRequest< - T, - { value: T | undefined; digest: string; cache: CacheStatus } - >(key, 'GET', localOptions, (cached, cacheStatus) => ({ - value: cached.value, - digest: cached.digest, - cache: cacheStatus, - })); + return this.handleCachedRequest(key, 'GET', localOptions); } /** @@ -119,15 +117,19 @@ export class Controller { /** * Generic handler for cached requests that implements the common cache logic */ - private async handleCachedRequest( + private async handleCachedRequest( key: string, method: 'GET' | 'HEAD', localOptions: EdgeConfigFunctionsOptions | undefined, - callback: (cached: CacheEntry, cacheStatus: CacheStatus) => R, - ): Promise { + ): Promise<{ + value: T | undefined; + digest: string; + cache: CacheStatus; + exists: boolean; + }> { // only use the cache if we have a timestamp of the latest update if (timestampOfLatestUpdate) { - const cached = this.getCachedItem(key); + const cached = this.getCachedItem(key, method); if (cached) { const cacheStatus = getCacheStatus( @@ -137,7 +139,7 @@ export class Controller { ); // HIT - if (cacheStatus === 'HIT') return callback(cached, 'HIT'); + if (cacheStatus === 'HIT') return { ...cached, cache: 'HIT' }; // we're outdated, but we can still serve the STALE value if (cacheStatus === 'STALE') { @@ -151,7 +153,7 @@ export class Controller { ).catch(() => null), ); - return callback(cached, 'STALE'); + return { ...cached, cache: 'STALE' }; } } } @@ -164,20 +166,7 @@ export class Controller { localOptions, ); - // For HEAD requests, we need to return the exists format - if (method === 'HEAD') { - return { - exists: result.value !== undefined, - digest: result.digest, - cache: result.cache, - } as R; - } - - return { - value: result.value, - digest: result.digest, - cache: result.cache, - } as R; + return result; } /** @@ -186,8 +175,15 @@ export class Controller { */ private getCachedItem( key: string, + method: 'GET' | 'HEAD', ): CacheEntry | null { - const itemCacheEntry = this.itemCache.get(key); + const item = this.itemCache.get(key); + // treat cache entries where we don't know that they exist, but we don't + // know their value yet as a MISS when we are interested in the value + const itemCacheEntry = + method === 'GET' && item?.exists && item.value === undefined + ? undefined + : item; const cachedConfig = this.edgeConfigCache; if (itemCacheEntry && cachedConfig) { @@ -224,6 +220,7 @@ export class Controller { value: T; digest: string; cache: CacheStatus; + updatedAt: number; }> { return this.enhancedFetch( `${this.connection.baseUrl}/items?version=${this.connection.version}`, @@ -231,11 +228,15 @@ export class Controller { headers: this.getHeaders(localOptions, minUpdatedAt), cache: this.cacheMode, }, - ).then<{ value: T; digest: string; cache: CacheStatus }>( + ).then<{ value: T; digest: string; cache: CacheStatus; updatedAt: number }>( async ([res, cachedRes]) => { const digest = res.headers.get('x-edge-config-digest'); const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + if (!updatedAt || !digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + if (res.status === 401) { await consumeResponseBody(res); throw new Error(ERRORS.UNAUTHORIZED); @@ -252,7 +253,7 @@ export class Controller { this.updateEdgeConfigCache(value, updatedAt, digest); - return { value, digest, cache: 'MISS' }; + return { value, digest, cache: 'MISS', updatedAt }; } throw new UnexpectedNetworkError(res); @@ -272,6 +273,8 @@ export class Controller { value: T | undefined; digest: string; cache: CacheStatus; + exists: boolean; + updatedAt: number; }> { return this.enhancedFetch( `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, @@ -284,10 +287,13 @@ export class Controller { value: T | undefined; digest: string; cache: CacheStatus; + exists: boolean; + updatedAt: number; }>(async ([res, cachedRes]) => { const digest = res.headers.get('x-edge-config-digest'); const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); - if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + + if (!digest || !updatedAt) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); if (res.ok || (res.status === 304 && cachedRes)) { // avoid undici memory leaks by consuming response bodies @@ -316,11 +322,18 @@ export class Controller { value, updatedAt, digest, - exists: method === 'GET' ? value !== undefined : res.ok, + exists: + method === 'GET' ? value !== undefined : res.status !== 404, }); } } - return { value, digest, cache: 'MISS' }; + return { + value, + digest, + cache: 'MISS', + exists: res.status !== 404, + updatedAt, + }; } await Promise.all([ @@ -340,7 +353,13 @@ export class Controller { exists: false, }); } - return { value: undefined, digest, cache: 'MISS' }; + return { + value: undefined, + digest, + cache: 'MISS', + exists: false, + updatedAt, + }; } throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); } @@ -353,26 +372,29 @@ export class Controller { localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }> { if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { - return this.fetchItem( - 'HEAD', - key, - timestampOfLatestUpdate, - localOptions, - ).then((res) => ({ - exists: res.value !== undefined, - digest: res.digest, - cache: res.cache, - })); + return this.fetchItem('HEAD', key, timestampOfLatestUpdate, localOptions); } - return this.handleCachedRequest< - EdgeConfigValue, - { exists: boolean; digest: string; cache: CacheStatus } - >(key, 'HEAD', localOptions, (cached, cacheStatus) => ({ - exists: cached.exists, - digest: cached.digest, - cache: cacheStatus, - })); + return this.handleCachedRequest(key, 'HEAD', localOptions); + } + + public async digest( + localOptions?: Pick, + ): Promise { + return fetch( + `${this.connection.baseUrl}/digest?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions, timestampOfLatestUpdate), + cache: this.cacheMode, + }, + ).then(async (res) => { + if (res.ok) return res.json() as Promise; + await consumeResponseBody(res); + + // if (res.cachedResponseBody !== undefined) + // return res.cachedResponseBody as string; + throw new UnexpectedNetworkError(res); + }); } public async getMultiple( @@ -425,7 +447,12 @@ export class Controller { public async getAll>( localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T; digest: string; cache: CacheStatus }> { + ): Promise<{ + value: T; + digest: string; + cache: CacheStatus; + updatedAt: number; + }> { // if we have the items and they if (timestampOfLatestUpdate && this.edgeConfigCache) { const cacheStatus = getCacheStatus( @@ -440,6 +467,7 @@ export class Controller { value: this.edgeConfigCache.items as T, digest: this.edgeConfigCache.digest, cache: 'HIT', + updatedAt: this.edgeConfigCache.updatedAt, }; } @@ -452,6 +480,7 @@ export class Controller { value: this.edgeConfigCache.items as T, digest: this.edgeConfigCache.digest, cache: 'STALE', + updatedAt: this.edgeConfigCache.updatedAt, }; } } From e796307311bc08897dee9bc9861910ab1d1bf2e6 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 1 Aug 2025 14:27:06 +0300 Subject: [PATCH 37/81] remove old implementation --- packages/edge-config/src/index-copy.ts | 684 ------------------------- 1 file changed, 684 deletions(-) delete mode 100644 packages/edge-config/src/index-copy.ts diff --git a/packages/edge-config/src/index-copy.ts b/packages/edge-config/src/index-copy.ts deleted file mode 100644 index 3ac95a46e..000000000 --- a/packages/edge-config/src/index-copy.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { readFile } from '@vercel/edge-config-fs'; -import { name as sdkName, version as sdkVersion } from '../package.json'; -import { - assertIsKey, - assertIsKeys, - isEmptyKey, - ERRORS, - UnexpectedNetworkError, - hasOwn, - parseConnectionString, - pick, -} from './utils'; -import type { - Connection, - EdgeConfigClient, - EdgeConfigItems, - EdgeConfigValue, - EmbeddedEdgeConfig, - EdgeConfigFunctionsOptions, -} from './types'; -import { fetchWithCachedResponse } from './utils/fetch-with-cached-response'; -import { trace } from './utils/tracing'; - -export { setTracerProvider } from './utils/tracing'; - -export { - parseConnectionString, - type EdgeConfigClient, - type EdgeConfigItems, - type EdgeConfigValue, - type EmbeddedEdgeConfig, -}; - -const jsonParseCache = new Map(); - -const readFileTraced = trace(readFile, { name: 'readFile' }); -const jsonParseTraced = trace(JSON.parse, { name: 'JSON.parse' }); - -const privateEdgeConfigSymbol = Symbol.for('privateEdgeConfig'); - -const cachedJsonParseTraced = trace( - (edgeConfigId: string, content: string) => { - const cached = jsonParseCache.get(edgeConfigId); - if (cached) return cached; - - const parsed = jsonParseTraced(content) as unknown; - - // freeze the object to avoid mutations of the return value of a "get" call - // from affecting the return value of future "get" calls - jsonParseCache.set(edgeConfigId, Object.freeze(parsed)); - return parsed; - }, - { name: 'cached JSON.parse' }, -); - -/** - * Reads an Edge Config from the local file system. - * This is used at runtime on serverless functions. - */ -const getFileSystemEdgeConfig = trace( - async function getFileSystemEdgeConfig( - connection: Connection, - ): Promise { - // can't optimize non-vercel hosted edge configs - if (connection.type !== 'vercel') return null; - // can't use fs optimizations outside of lambda - if (!process.env.AWS_LAMBDA_FUNCTION_NAME) return null; - - try { - const content = await readFileTraced( - `/opt/edge-config/${connection.id}.json`, - 'utf-8', - ); - - return cachedJsonParseTraced( - connection.id, - content, - ) as EmbeddedEdgeConfig; - } catch { - return null; - } - }, - { - name: 'getFileSystemEdgeConfig', - }, -); - -/** - * Reads an Edge Config from the local file system using an async import. - * This is used at runtime on serverless functions. - */ -const getBuildContainerEdgeConfig = trace( - async function getBuildContainerEdgeConfig( - connection: Connection, - ): Promise { - // can't optimize non-vercel hosted edge configs - if (connection.type !== 'vercel') return null; - - // the folder won't exist in development, only when deployed - if (process.env.NODE_ENV === 'development') return null; - - try { - const edgeConfig = (await import( - /* webpackIgnore. true */ `/tmp/edge-config/${connection.id}.json` - )) as { default: EmbeddedEdgeConfig }; - return edgeConfig.default; - } catch { - return null; - } - }, - { - name: 'getBuildContainerEdgeConfig', - }, -); - -/** - * Will return an embedded Edge Config object from memory, - * but only when the `privateEdgeConfigSymbol` is in global scope. - */ -const getPrivateEdgeConfig = trace( - async function getPrivateEdgeConfig( - connection: Connection, - ): Promise { - const privateEdgeConfig = Reflect.get( - globalThis, - privateEdgeConfigSymbol, - ) as - | { - get: (id: string) => Promise; - } - | undefined; - - if ( - typeof privateEdgeConfig === 'object' && - typeof privateEdgeConfig.get === 'function' - ) { - return privateEdgeConfig.get(connection.id); - } - - return null; - }, - { - name: 'getPrivateEdgeConfig', - }, -); - -/** - * Returns a function to retrieve the entire Edge Config. - * It'll keep the fetched Edge Config in memory, making subsequent calls fast, - * while revalidating in the background. - */ -function createGetInMemoryEdgeConfig( - shouldUseDevelopmentCache: boolean, - connection: Connection, - headers: Record, - fetchCache: EdgeConfigClientOptions['cache'], -): ( - localOptions?: EdgeConfigFunctionsOptions, -) => Promise { - // Functions as cache to keep track of the Edge Config. - let embeddedEdgeConfigPromise: Promise | null = - null; - - // Promise that points to the most recent request. - // It'll ensure that subsequent calls won't make another fetch call, - // while one is still on-going. - // Will overwrite `embeddedEdgeConfigPromise` only when resolved. - let latestRequest: Promise | null = null; - - return trace( - (localOptions) => { - if (localOptions?.consistentRead || !shouldUseDevelopmentCache) - return Promise.resolve(null); - - if (!latestRequest) { - latestRequest = fetchWithCachedResponse( - `${connection.baseUrl}/items?version=${connection.version}`, - { - headers: new Headers(headers), - cache: fetchCache, - }, - ).then(async (res) => { - const digest = res.headers.get('x-edge-config-digest'); - let body: EdgeConfigValue | undefined; - - // We ignore all errors here and just proceed. - if (!res.ok) { - await consumeResponseBody(res); - body = res.cachedResponseBody as EdgeConfigValue | undefined; - if (!body) return null; - } else { - body = (await res.json()) as EdgeConfigItems; - } - - return { digest, items: body } as EmbeddedEdgeConfig; - }); - - // Once the request is resolved, we set the proper config to the promise - // such that the next call will return the resolved value. - latestRequest.then( - (resolved) => { - embeddedEdgeConfigPromise = Promise.resolve(resolved); - latestRequest = null; - }, - // Attach a `.catch` handler to this promise so that if it does throw, - // we don't get an unhandled promise rejection event. We unset the - // `latestRequest` so that the next call will make a new request. - () => { - embeddedEdgeConfigPromise = null; - latestRequest = null; - }, - ); - } - - if (!embeddedEdgeConfigPromise) { - // If the `embeddedEdgeConfigPromise` is `null`, it means that there's - // no previous request, so we'll set the `latestRequest` to the current - // request. - embeddedEdgeConfigPromise = latestRequest; - } - - return embeddedEdgeConfigPromise; - }, - { - name: 'getInMemoryEdgeConfig', - }, - ); -} - -/** - * Uses `MAX_SAFE_INTEGER` as minimum updated at timestamp to force - * a request to the origin. - */ -function addConsistentReadHeader(headers: Headers): void { - headers.set('x-edge-config-min-updated-at', `${Number.MAX_SAFE_INTEGER}`); -} - -/** - * Check if running in Vercel build environment - */ -const isVercelBuild = - process.env.VERCEL === '1' && - process.env.CI === '1' && - !process.env.VERCEL_URL; // VERCEL_URL is only available at runtime - -/** - * Reads the Edge Config from a local provider, if available, - * to avoid Network requests. - */ -async function getLocalEdgeConfig( - connection: Connection, - options?: EdgeConfigFunctionsOptions, - getInMemoryEdgeConfig?: ( - options?: EdgeConfigFunctionsOptions, - ) => Promise, -): Promise { - if (options?.consistentRead) return null; - - // Try using the Edge Config from the build container if we are in a build. - // This guarantees the same version of an Edge Config is used throughout the build process. - if (isVercelBuild) { - const buildContainerEdgeConfig = - await getBuildContainerEdgeConfig(connection); - if (buildContainerEdgeConfig) return buildContainerEdgeConfig; - } - - // Try using the Edge Config from the in-memory cache at runtime. - const inMemoryEdgeConfig = await getInMemoryEdgeConfig?.(options); - if (inMemoryEdgeConfig) return inMemoryEdgeConfig; - - // Fall back to the private Edge Config if we don't have one in memory. - const privateEdgeConfig = await getPrivateEdgeConfig(connection); - if (privateEdgeConfig) return privateEdgeConfig; - - // Fall back to the file system Edge Config otherwise - const fileSystemEdgeConfig = await getFileSystemEdgeConfig(connection); - if (fileSystemEdgeConfig) return fileSystemEdgeConfig; - - // Fall back to the build container Edge Config as a last resort. - // This edge config might be quite outdated, but it's better than not resolving at all. - const buildContainerEdgeConfig = - await getBuildContainerEdgeConfig(connection); - if (buildContainerEdgeConfig) return buildContainerEdgeConfig; - - return null; -} - -/** - * This function reads the respone body - * - * Reading the response body serves two purposes - * - * 1) In Node.js it avoids memory leaks - * - * See https://github.com/nodejs/undici/blob/v5.21.2/README.md#garbage-collection - * See https://github.com/node-fetch/node-fetch/issues/83 - * - * 2) In Cloudflare it avoids running into a deadlock. They have a maximum number - * of concurrent fetches (which is documented). Concurrency counts until the - * body of a response is read. It is not uncommon to never read a response body - * (e.g. if you only care about the status code). This can lead to deadlock as - * fetches appear to never resolve. - * - * See https://developers.cloudflare.com/workers/platform/limits/#simultaneous-open-connections - */ -async function consumeResponseBody(res: Response): Promise { - await res.arrayBuffer(); -} - -interface EdgeConfigClientOptions { - /** - * The stale-if-error response directive indicates that the cache can reuse a - * stale response when an upstream server generates an error, or when the error - * is generated locally - for example due to a connection error. - * - * Any response with a status code of 500, 502, 503, or 504 is considered an error. - * - * Pass a negative number, 0, or false to turn disable stale-if-error semantics. - * - * The time is supplied in seconds. Defaults to one week (`604800`). - */ - staleIfError?: number | false; - /** - * In development, a stale-while-revalidate cache is employed as the default caching strategy. - * - * This cache aims to deliver speedy Edge Config reads during development, though it comes - * at the cost of delayed visibility for updates to Edge Config. Typically, you may need to - * refresh twice to observe these changes as the stale value is replaced. - * - * This cache is not used in preview or production deployments as superior optimisations are applied there. - */ - disableDevelopmentCache?: boolean; - - /** - * Sets a `cache` option on the `fetch` call made by Edge Config. - * - * Unlike Next.js, this defaults to `no-store`, as you most likely want to use Edge Config dynamically. - */ - cache?: 'no-store' | 'force-cache'; -} - -/** - * Create an Edge Config client. - * - * The client has multiple methods which allow you to read the Edge Config. - * - * If you need to programmatically write to an Edge Config, check out the [Update your Edge Config items](https://vercel.com/docs/storage/edge-config/vercel-api#update-your-edge-config-items) section. - * - * @param connectionString - A connection string. Usually you'd pass in `process.env.EDGE_CONFIG` here, which contains a connection string. - * @returns An Edge Config Client instance - */ -export const createClient = trace( - function createClient( - connectionString: string | undefined, - options: EdgeConfigClientOptions = { - staleIfError: 604800 /* one week */, - cache: 'no-store', - }, - ): EdgeConfigClient { - if (!connectionString) - throw new Error('@vercel/edge-config: No connection string provided'); - - const connection = parseConnectionString(connectionString); - - if (!connection) - throw new Error( - '@vercel/edge-config: Invalid connection string provided', - ); - - const edgeConfigId = connection.id; - const baseUrl = connection.baseUrl; - const version = connection.version; // version of the edge config read access api we talk to - const headers: Record = { - Authorization: `Bearer ${connection.token}`, - }; - - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- [@vercel/style-guide@5 migration] - if (typeof process !== 'undefined' && process.env.VERCEL_ENV) - headers['x-edge-config-vercel-env'] = process.env.VERCEL_ENV; - - if (typeof sdkName === 'string' && typeof sdkVersion === 'string') - headers['x-edge-config-sdk'] = `${sdkName}@${sdkVersion}`; - - if (typeof options.staleIfError === 'number' && options.staleIfError > 0) - headers['cache-control'] = `stale-if-error=${options.staleIfError}`; - - const fetchCache = options.cache || 'no-store'; - - /** - * While in development we use SWR-like behavior for the api client to - * reduce latency. - */ - const shouldUseDevelopmentCache = - !options.disableDevelopmentCache && - process.env.NODE_ENV === 'development' && - process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; - - const getInMemoryEdgeConfig = createGetInMemoryEdgeConfig( - shouldUseDevelopmentCache, - connection, - headers, - fetchCache, - ); - - const api: Omit = { - get: trace( - async function get( - key: string, - localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = await getLocalEdgeConfig( - connection, - localOptions, - getInMemoryEdgeConfig, - ); - - assertIsKey(key); - if (isEmptyKey(key)) return undefined; - - if (localEdgeConfig) { - // 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); - } - - const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) - addConsistentReadHeader(localHeaders); - - return fetchWithCachedResponse( - `${baseUrl}/item/${key}?version=${version}`, - { - headers: localHeaders, - cache: fetchCache, - }, - ).then(async (res) => { - if (res.ok) return res.json(); - await consumeResponseBody(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 undefined; - // 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.cachedResponseBody !== undefined) - return res.cachedResponseBody as T; - throw new UnexpectedNetworkError(res); - }); - }, - { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, - ), - has: trace( - async function has( - key, - localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = await getLocalEdgeConfig( - connection, - localOptions, - getInMemoryEdgeConfig, - ); - - assertIsKey(key); - if (isEmptyKey(key)) return false; - - if (localEdgeConfig) { - return Promise.resolve(hasOwn(localEdgeConfig.items, key)); - } - - const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) - addConsistentReadHeader(localHeaders); - - // this is a HEAD request anyhow, no need for fetchWithCachedResponse - return fetch(`${baseUrl}/item/${key}?version=${version}`, { - method: 'HEAD', - headers: localHeaders, - cache: fetchCache, - }).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); - }); - }, - { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, - ), - getAll: trace( - async function getAll( - keys?: (keyof T)[], - localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = await getLocalEdgeConfig( - connection, - localOptions, - getInMemoryEdgeConfig, - ); - - if (localEdgeConfig) { - if (keys === undefined) { - return Promise.resolve(localEdgeConfig.items as T); - } - - assertIsKeys(keys); - return Promise.resolve(pick(localEdgeConfig.items, keys) as T); - } - - if (Array.isArray(keys)) assertIsKeys(keys); - - const search = Array.isArray(keys) - ? new URLSearchParams( - keys - .filter((key) => typeof key === 'string' && !isEmptyKey(key)) - .map((key) => ['key', key] as [string, string]), - ).toString() - : null; - - // empty search keys array was given, - // so skip the request and return an empty object - if (search === '') return Promise.resolve({} as T); - - const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) - addConsistentReadHeader(localHeaders); - - return fetchWithCachedResponse( - `${baseUrl}/items?version=${version}${ - search === null ? '' : `&${search}` - }`, - { - headers: localHeaders, - cache: fetchCache, - }, - ).then(async (res) => { - if (res.ok) return res.json(); - await consumeResponseBody(res); - - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - // the /items endpoint never returns 404, so if we get a 404 - // it means the edge config itself did not exist - if (res.status === 404) - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - if (res.cachedResponseBody !== undefined) - return res.cachedResponseBody as T; - throw new UnexpectedNetworkError(res); - }); - }, - { name: 'getAll', isVerboseTrace: false, attributes: { edgeConfigId } }, - ), - digest: trace( - async function digest( - localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = await getLocalEdgeConfig( - connection, - localOptions, - getInMemoryEdgeConfig, - ); - - if (localEdgeConfig) { - return Promise.resolve(localEdgeConfig.digest); - } - - const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) - addConsistentReadHeader(localHeaders); - - return fetchWithCachedResponse( - `${baseUrl}/digest?version=${version}`, - { - headers: localHeaders, - cache: fetchCache, - }, - ).then(async (res) => { - if (res.ok) return res.json() as Promise; - await consumeResponseBody(res); - - if (res.cachedResponseBody !== undefined) - return res.cachedResponseBody as string; - throw new UnexpectedNetworkError(res); - }); - }, - { name: 'digest', isVerboseTrace: false, attributes: { edgeConfigId } }, - ), - }; - - return { ...api, connection }; - }, - { - name: 'createClient', - }, -); - -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 { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- [@vercel/style-guide@5 migration] - if (!defaultEdgeConfigClient) { - defaultEdgeConfigClient = createClient(process.env.EDGE_CONFIG); - } -} - -/** - * Reads a single item from the default Edge Config. - * - * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).get()`. - * - * @see {@link EdgeConfigClient.get} - * @param key - the key to read - * @returns the value stored under the given key, or undefined - */ -export const get: EdgeConfigClient['get'] = (...args) => { - init(); - return defaultEdgeConfigClient.get(...args); -}; - -/** - * Reads multiple or all values. - * - * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getAll()`. - * - * @see {@link EdgeConfigClient.getAll} - * @param keys - the keys to read - * @returns the value stored under the given key, or undefined - */ -export const getAll: EdgeConfigClient['getAll'] = (...args) => { - init(); - return defaultEdgeConfigClient.getAll(...args); -}; - -/** - * Check if a given key exists in the Edge Config. - * - * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).has()`. - * - * @see {@link EdgeConfigClient.has} - * @param key - the key to check - * @returns true if the given key exists in the Edge Config. - */ -export const has: EdgeConfigClient['has'] = (...args) => { - init(); - return defaultEdgeConfigClient.has(...args); -}; - -/** - * Get the digest of the Edge Config. - * - * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).digest()`. - * - * @see {@link EdgeConfigClient.digest} - * @returns The digest of the Edge Config. - */ -export const digest: EdgeConfigClient['digest'] = (...args) => { - init(); - return defaultEdgeConfigClient.digest(...args); -}; - -/** - * Safely clones a read-only Edge Config object and makes it mutable. - */ -export function clone(edgeConfigValue: T): T { - // Use JSON.parse and JSON.stringify instead of anything else due to - // the value possibly being a Proxy object. - return JSON.parse(JSON.stringify(edgeConfigValue)) as T; -} From 47e3a2608562bbcb913546ec1a762c9c4177c9aa Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 1 Aug 2025 15:26:51 +0300 Subject: [PATCH 38/81] convert index --- packages/edge-config/src/controller.ts | 3 +- packages/edge-config/src/index.ts | 92 +++++++------------ packages/edge-config/src/types.ts | 8 -- .../src/utils/add-consistent-read-header.ts | 7 -- 4 files changed, 37 insertions(+), 73 deletions(-) delete mode 100644 packages/edge-config/src/utils/add-consistent-read-header.ts diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 631e78171..d901d7688 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -6,6 +6,7 @@ import type { Connection, EdgeConfigClientOptions, CacheStatus, + EdgeConfigItems, } from './types'; import { ERRORS, isEmptyKey, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; @@ -445,7 +446,7 @@ export class Controller { }); } - public async getAll>( + public async getAll( localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 04253fb32..15e2ba7c1 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -1,11 +1,5 @@ import { name as sdkName, version as sdkVersion } from '../package.json'; -import { - assertIsKey, - isEmptyKey, - ERRORS, - UnexpectedNetworkError, - parseConnectionString, -} from './utils'; +import { assertIsKey, isEmptyKey, parseConnectionString } from './utils'; import type { EdgeConfigClient, EdgeConfigItems, @@ -14,10 +8,7 @@ import type { EdgeConfigFunctionsOptions, EdgeConfigClientOptions, } from './types'; -// import { fetch } from './utils/enhanced-fetch'; import { trace } from './utils/tracing'; -import { consumeResponseBody } from './utils/consume-response-body'; -import { addConsistentReadHeader } from './utils/add-consistent-read-header'; import { Controller } from './controller'; export { setTracerProvider } from './utils/tracing'; @@ -59,8 +50,6 @@ export const createClient = trace( '@vercel/edge-config: Invalid connection string provided', ); - const baseUrl = connection.baseUrl; - const version = connection.version; // version of the edge config read access api we talk to const headers: Record = { Authorization: `Bearer ${connection.token}`, }; @@ -75,8 +64,6 @@ export const createClient = trace( if (typeof options.staleIfError === 'number' && options.staleIfError > 0) headers['cache-control'] = `stale-if-error=${options.staleIfError}`; - const fetchCache = options.cache || 'no-store'; - /** * While in development we use SWR-like behavior for the api client to * reduce latency. @@ -86,17 +73,16 @@ export const createClient = trace( process.env.NODE_ENV === 'development' && process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; - const controller = new Controller( - connection, - options, - shouldUseDevelopmentCache, - ); + const controller = new Controller(connection, { + ...options, + enableDevelopmentCache: shouldUseDevelopmentCache, + }); const edgeConfigId = connection.id; const methods: Pick< EdgeConfigClient, - 'get' | 'has' | 'getMultiple' | 'getAll' | 'digest' + 'get' | 'has' | 'getMultiple' | 'getAll' > = { get: trace( async function get( @@ -142,7 +128,7 @@ export const createClient = trace( }, ), getAll: trace( - async function getAll( + async function getAll( localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; digest: string } | T> { const data = await controller.getAll(localOptions); @@ -150,28 +136,6 @@ export const createClient = trace( }, { name: 'getAll', isVerboseTrace: false, attributes: { edgeConfigId } }, ), - digest: trace( - async function digest( - localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localHeaders = new Headers(headers); - if (localOptions?.consistentRead) - addConsistentReadHeader(localHeaders); - - return fetch(`${baseUrl}/digest?version=${version}`, { - headers: localHeaders, - cache: fetchCache, - }).then(async (res) => { - if (res.ok) return res.json() as Promise; - await consumeResponseBody(res); - - // if (res.cachedResponseBody !== undefined) - // return res.cachedResponseBody as string; - throw new UnexpectedNetworkError(res); - }); - }, - { name: 'digest', isVerboseTrace: false, attributes: { edgeConfigId } }, - ), }; return { ...methods, connection }; @@ -208,34 +172,48 @@ export const get: EdgeConfigClient['get'] = (...args) => { }; /** - * Check if a given key exists in the Edge Config. + * Reads all items from the default Edge Config. * * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).has()`. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getAll()`. * - * @see {@link EdgeConfigClient.has} - * @param key - the key to check - * @returns true if the given key exists in the Edge Config. + * @see {@link EdgeConfigClient.getAll} */ -export const has = ((...args: Parameters) => { +export const getAll: EdgeConfigClient['getAll'] = (...args) => { init(); - return defaultEdgeConfigClient.has(...args); -}) as EdgeConfigClient['has']; + return defaultEdgeConfigClient.getAll(...args); +}; /** - * Get the digest of the Edge Config. + * Reads multiple items from the default Edge Config. * * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).digest()`. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getMultiple()`. * - * @see {@link EdgeConfigClient.digest} - * @returns The digest of the Edge Config. + * @see {@link EdgeConfigClient.getMultiple} + * @param keys - the keys to read + * @returns the values stored under the given keys, or undefined */ -export const digest: EdgeConfigClient['digest'] = (...args) => { +export const getMultiple: EdgeConfigClient['getMultiple'] = (...args) => { init(); - return defaultEdgeConfigClient.digest(...args); + return defaultEdgeConfigClient.getMultiple(...args); }; +/** + * Check if a given key exists in the Edge Config. + * + * This is a convenience method which reads the default Edge Config. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).has()`. + * + * @see {@link EdgeConfigClient.has} + * @param key - the key to check + * @returns true if the given key exists in the Edge Config. + */ +export const has = ((...args: Parameters) => { + init(); + return defaultEdgeConfigClient.has(...args); +}) as EdgeConfigClient['has']; + /** * Safely clones a read-only Edge Config object and makes it mutable. */ diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 456edce16..6f37036b1 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -94,14 +94,6 @@ export interface EdgeConfigClient { ): Promise<{ exists: boolean; digest: string }>; (key: string, options?: EdgeConfigFunctionsOptions): Promise; }; - /** - * Get the digest of the Edge Config. - * - * The digest is a unique hash result based on the contents stored in the Edge Config. - * - * @returns The digest of the Edge Config. - */ - digest: (options?: EdgeConfigFunctionsOptions) => Promise; } export type EdgeConfigItems = Record; diff --git a/packages/edge-config/src/utils/add-consistent-read-header.ts b/packages/edge-config/src/utils/add-consistent-read-header.ts deleted file mode 100644 index 65708243a..000000000 --- a/packages/edge-config/src/utils/add-consistent-read-header.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Uses `MAX_SAFE_INTEGER` as minimum updated at timestamp to force - * a request to the origin. - */ -export function addConsistentReadHeader(headers: Headers): void { - headers.set('x-edge-config-min-updated-at', `${Number.MAX_SAFE_INTEGER}`); -} From 6626f71eedd9caa6245b8dab0c120f01f088a854 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 1 Aug 2025 18:16:31 +0300 Subject: [PATCH 39/81] rm edge-config-fs --- packages/edge-config-fs/.eslintrc.js | 3 --- packages/edge-config-fs/README.md | 4 ---- packages/edge-config-fs/index.edge.cjs | 5 ----- packages/edge-config-fs/index.edge.js | 3 --- packages/edge-config-fs/index.node.cjs | 3 --- packages/edge-config-fs/index.node.js | 1 - packages/edge-config-fs/package.json | 29 -------------------------- packages/edge-config-fs/types.d.ts | 1 - 8 files changed, 49 deletions(-) delete mode 100644 packages/edge-config-fs/.eslintrc.js delete mode 100644 packages/edge-config-fs/README.md delete mode 100644 packages/edge-config-fs/index.edge.cjs delete mode 100644 packages/edge-config-fs/index.edge.js delete mode 100644 packages/edge-config-fs/index.node.cjs delete mode 100644 packages/edge-config-fs/index.node.js delete mode 100644 packages/edge-config-fs/package.json delete mode 100644 packages/edge-config-fs/types.d.ts diff --git a/packages/edge-config-fs/.eslintrc.js b/packages/edge-config-fs/.eslintrc.js deleted file mode 100644 index e35135249..000000000 --- a/packages/edge-config-fs/.eslintrc.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - root: true, -}; diff --git a/packages/edge-config-fs/README.md b/packages/edge-config-fs/README.md deleted file mode 100644 index 8ca8a472f..000000000 --- a/packages/edge-config-fs/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# `@vercel/edge-config-fs` - -This is an internal package in support of `@vercel/edge-config`. -You should always use `@vercel/edge-config` directly. diff --git a/packages/edge-config-fs/index.edge.cjs b/packages/edge-config-fs/index.edge.cjs deleted file mode 100644 index 062e4dd06..000000000 --- a/packages/edge-config-fs/index.edge.cjs +++ /dev/null @@ -1,5 +0,0 @@ -function readFile() { - throw new Error('readFile cannot be called from the edge runtime.'); -} - -module.exports = { readFile }; diff --git a/packages/edge-config-fs/index.edge.js b/packages/edge-config-fs/index.edge.js deleted file mode 100644 index 5292c20b6..000000000 --- a/packages/edge-config-fs/index.edge.js +++ /dev/null @@ -1,3 +0,0 @@ -export function readFile() { - throw new Error('readFile cannot be called from the edge runtime.'); -} diff --git a/packages/edge-config-fs/index.node.cjs b/packages/edge-config-fs/index.node.cjs deleted file mode 100644 index 5c6a83876..000000000 --- a/packages/edge-config-fs/index.node.cjs +++ /dev/null @@ -1,3 +0,0 @@ -const { readFile } = require('node:fs/promises'); - -module.exports = { readFile }; diff --git a/packages/edge-config-fs/index.node.js b/packages/edge-config-fs/index.node.js deleted file mode 100644 index 8122e8526..000000000 --- a/packages/edge-config-fs/index.node.js +++ /dev/null @@ -1 +0,0 @@ -export { readFile } from 'node:fs/promises'; diff --git a/packages/edge-config-fs/package.json b/packages/edge-config-fs/package.json deleted file mode 100644 index 559f6248e..000000000 --- a/packages/edge-config-fs/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@vercel/edge-config-fs", - "version": "0.1.0", - "description": "Use @vercel/edge-config instead.", - "homepage": "https://vercel.com", - "repository": { - "type": "git", - "url": "https://github.com/vercel/storage.git", - "directory": "packages/edge-config-fs" - }, - "license": "Apache-2.0", - "sideEffects": false, - "type": "module", - "exports": { - "import": { - "types": "./types.d.ts", - "edge-light": "./index.edge.js", - "node": "./index.node.js", - "default": "./index.edge.js" - }, - "require": { - "types": "./types.d.ts", - "edge-light": "./index.edge.cjs", - "node": "./index.node.cjs", - "default": "./index.edge.cjs" - } - }, - "types": "./types.d.ts" -} diff --git a/packages/edge-config-fs/types.d.ts b/packages/edge-config-fs/types.d.ts deleted file mode 100644 index 06801e0b1..000000000 --- a/packages/edge-config-fs/types.d.ts +++ /dev/null @@ -1 +0,0 @@ -export { readFile } from 'fs/promises'; From 922f3627cb231a5dd39270a06268dd300902c23f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 1 Aug 2025 18:16:49 +0300 Subject: [PATCH 40/81] fix 500 handling --- packages/edge-config/src/controller.ts | 11 + packages/edge-config/src/index.node.test.ts | 731 +++++++------------- packages/edge-config/src/index.ts | 17 +- 3 files changed, 265 insertions(+), 494 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index d901d7688..bd3b4edd8 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -1,4 +1,5 @@ import { waitUntil } from '@vercel/functions'; +import { name as sdkName, version as sdkVersion } from '../package.json'; import type { EdgeConfigValue, EmbeddedEdgeConfig, @@ -234,6 +235,8 @@ export class Controller { const digest = res.headers.get('x-edge-config-digest'); const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + if (res.status === 500) throw new UnexpectedNetworkError(res); + if (!updatedAt || !digest) { throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); } @@ -294,6 +297,7 @@ export class Controller { const digest = res.headers.get('x-edge-config-digest'); const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + if (res.status === 500) throw new UnexpectedNetworkError(res); if (!digest || !updatedAt) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); if (res.ok || (res.status === 304 && cachedRes)) { @@ -505,6 +509,13 @@ export class Controller { ); } + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- [@vercel/style-guide@5 migration] + if (typeof process !== 'undefined' && process.env.VERCEL_ENV) + localHeaders.set('x-edge-config-vercel-env', process.env.VERCEL_ENV); + + if (typeof sdkName === 'string' && typeof sdkVersion === 'string') + localHeaders.set('x-edge-config-sdk', `${sdkName}@${sdkVersion}`); + return localHeaders; } } diff --git a/packages/edge-config/src/index.node.test.ts b/packages/edge-config/src/index.node.test.ts index 930c0045f..59d95bf35 100644 --- a/packages/edge-config/src/index.node.test.ts +++ b/packages/edge-config/src/index.node.test.ts @@ -1,9 +1,6 @@ -import { readFile } from '@vercel/edge-config-fs'; import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; -import type { EmbeddedEdgeConfig } from './types'; -import { cache } from './utils/enhanced-fetch'; -import { get, has, digest, createClient, getAll } from './index'; +import { get, has, getAll, getMultiple } from './index'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; @@ -11,21 +8,6 @@ const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; // eslint-disable-next-line jest/require-top-level-describe -- [@vercel/style-guide@5 migration] 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', () => { @@ -38,17 +20,24 @@ describe('default Edge Config', () => { }); it('should fetch an item from the Edge Config specified by process.env.EDGE_CONFIG', async () => { - fetchMock.mockResponse(JSON.stringify('bar')); + fetchMock.mockResponse(JSON.stringify('bar'), { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); await expect(get('foo')).resolves.toEqual('bar'); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/item/foo?version=1`, { + method: 'GET', 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-control': 'stale-if-error=604800', }), cache: 'no-store', }); @@ -57,7 +46,13 @@ describe('default Edge Config', () => { describe('get(key)', () => { describe('when item exists', () => { it('should return the value', async () => { - fetchMock.mockResponse(JSON.stringify('bar')); + fetchMock.mockResponse(JSON.stringify('bar'), { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); await expect(get('foo')).resolves.toEqual('bar'); @@ -65,11 +60,11 @@ describe('default Edge Config', () => { expect(fetchMock).toHaveBeenCalledWith( `${baseUrl}/item/foo?version=1`, { + method: 'GET', 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', }, @@ -89,8 +84,9 @@ describe('default Edge Config', () => { { status: 404, headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', 'content-type': 'application/json', - 'x-edge-config-digest': 'fake', }, }, ); @@ -101,11 +97,11 @@ describe('default Edge Config', () => { expect(fetchMock).toHaveBeenCalledWith( `${baseUrl}/item/foo?version=1`, { + method: 'GET', 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', }, @@ -133,11 +129,11 @@ describe('default Edge Config', () => { expect(fetchMock).toHaveBeenCalledWith( `${baseUrl}/item/foo?version=1`, { + method: 'GET', 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', }, @@ -155,11 +151,11 @@ describe('default Edge Config', () => { expect(fetchMock).toHaveBeenCalledWith( `${baseUrl}/item/foo?version=1`, { + method: 'GET', 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', }, @@ -179,11 +175,11 @@ describe('default Edge Config', () => { expect(fetchMock).toHaveBeenCalledWith( `${baseUrl}/item/foo?version=1`, { + method: 'GET', 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', }, @@ -192,454 +188,233 @@ describe('default Edge Config', () => { }); }); - describe('getAll(keys)', () => { - describe('when called without keys', () => { - it('should return all items', async () => { - fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' })); - - await expect(getAll()).resolves.toEqual({ foo: 'foo1' }); - - 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.mockResponse(JSON.stringify({ foo: 'foo1', bar: 'bar1' })); - - await expect(getAll(['foo', 'bar'])).resolves.toEqual({ - foo: 'foo1', - bar: 'bar1', - }); - - 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 called with an empty string key', () => { - it('should return the selected items', async () => { - await expect(getAll([''])).resolves.toEqual({}); - expect(fetchMock).toHaveBeenCalledTimes(0); - }); - }); - - describe('when called with an empty string key mix', () => { - it('should return the selected items', async () => { - fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' })); - await expect(getAll(['foo', ''])).resolves.toEqual({ foo: 'foo1' }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - }); - - describe('when the edge config does not exist', () => { - it('should throw', async () => { - fetchMock.mockResponse( - JSON.stringify({ - error: { - code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', - }, - }), - { status: 404, headers: { 'content-type': 'application/json' } }, - ); - - await expect(getAll(['foo', 'bar'])).rejects.toThrow( - '@vercel/edge-config: Edge Config not found', - ); - - 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 network fails', () => { - it('should throw a Network error', async () => { - fetchMock.mockReject(new Error('Unexpected fetch error')); - - await expect(getAll()).rejects.toThrow('Unexpected fetch error'); - - 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 an unexpected status code is returned', () => { - it('should throw a Unexpected error on 500', async () => { - fetchMock.mockResponse('', { status: 500 }); - - await expect(getAll()).rejects.toThrow( - '@vercel/edge-config: Unexpected error due to response with status code 500', - ); - - 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('has(key)', () => { - describe('when item exists', () => { - it('should return true', async () => { - fetchMock.mockResponse(''); - - 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.mockResponse( - JSON.stringify({ - error: { - code: 'edge_config_item_not_found', - message: 'Could not find the edge config item: foo', - }, - }), - { - status: 404, - headers: { - 'content-type': 'application/json', - 'x-edge-config-digest': 'fake', - }, - }, - ); - - await expect(has('foo')).resolves.toEqual(false); - - 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 edge config does not exist', () => { - it('should return false', async () => { - fetchMock.mockResponse( - JSON.stringify({ - error: { - code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', - }, - }), - { status: 404, headers: { 'content-type': 'application/json' } }, - ); - - await expect(has('foo')).rejects.toThrow( - '@vercel/edge-config: Edge Config not found', - ); - - 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('/', () => { - describe('when the request succeeds', () => { - it('should return the digest', async () => { - fetchMock.mockResponse(JSON.stringify('awe1')); - - await expect(digest()).resolves.toEqual('awe1'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/digest?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 the server returns an unexpected status code', () => { - it('should throw an Unexpected error on 500', async () => { - fetchMock.mockResponse('', { status: 500 }); - - await expect(digest()).rejects.toThrow( - '@vercel/edge-config: Unexpected error due to response with status code 500', - ); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/digest?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', - }); - }); - - it('should throw an Unexpected error on 404', async () => { - fetchMock.mockResponse('', { status: 404 }); - - await expect(digest()).rejects.toThrow( - '@vercel/edge-config: Unexpected error due to response with status code 404', - ); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/digest?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 the network fails', () => { - it('should throw a Network error', async () => { - fetchMock.mockReject(new Error('Unexpected fetch error')); - - await expect(digest()).rejects.toThrow('Unexpected fetch error'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/digest?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', - }); - }); - }); - }); -}); - -// these test the happy path only, as the cases are tested through the -// "default Edge Config" tests above anyhow -describe('createClient', () => { - describe('when running with lambda layer on serverless function', () => { - beforeAll(() => { - process.env.AWS_LAMBDA_FUNCTION_NAME = 'some-value'; - }); - - afterAll(() => { - delete process.env.AWS_LAMBDA_FUNCTION_NAME; - }); - - beforeEach(() => { - (readFile as jest.Mock).mockClear(); - }); - - describe('get(key)', () => { - describe('when item exists', () => { - it('should return the value', async () => { - const edgeConfig = createClient(process.env.EDGE_CONFIG); - await expect(edgeConfig.get('foo')).resolves.toEqual('bar'); - expect(fetchMock).toHaveBeenCalledTimes(0); - expect(readFile).toHaveBeenCalledTimes(1); - expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', - 'utf-8', - ); - }); - }); - - describe('when the item does not exist', () => { - it('should return undefined', async () => { - const edgeConfig = createClient(process.env.EDGE_CONFIG); - await expect(edgeConfig.get('baz')).resolves.toEqual(undefined); - expect(fetchMock).toHaveBeenCalledTimes(0); - expect(readFile).toHaveBeenCalledTimes(1); - expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', - 'utf-8', - ); - }); - }); - }); - - describe('get(key, { consistentRead: true })', () => { - it('should handle multiple concurrent requests correctly', async () => { - const edgeConfig = createClient(process.env.EDGE_CONFIG); - - let i = 0; - // Create a more realistic response with a proper body stream - // @ts-expect-error - aaa - fetchMock.mockImplementation(() => { - return new Response(JSON.stringify(`bar${i++}`), { - headers: { 'content-type': 'application/json' }, - }); - }); - - // Make multiple concurrent requests - const a = edgeConfig.get('foo', { consistentRead: true }); - const b = edgeConfig.get('foo', { consistentRead: true }); - - await a; - await b; - await expect(a).resolves.toEqual('bar0'); - await expect(b).resolves.toEqual('bar1'); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - }); - - describe('has(key)', () => { - describe('when item exists', () => { - it('should return true', async () => { - const edgeConfig = createClient(process.env.EDGE_CONFIG); - await expect(edgeConfig.has('foo')).resolves.toEqual(true); - expect(fetchMock).toHaveBeenCalledTimes(0); - expect(readFile).toHaveBeenCalledTimes(1); - expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', - 'utf-8', - ); - }); - }); - - describe('when the item does not exist', () => { - it('should return false', async () => { - const edgeConfig = createClient(process.env.EDGE_CONFIG); - await expect(edgeConfig.has('baz')).resolves.toEqual(false); - expect(fetchMock).toHaveBeenCalledTimes(0); - expect(readFile).toHaveBeenCalledTimes(1); - expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', - 'utf-8', - ); - }); - }); - }); - - describe('digest()', () => { - it('should return the digest', async () => { - const edgeConfig = createClient(process.env.EDGE_CONFIG); - await expect(edgeConfig.digest()).resolves.toEqual('awe1'); - expect(fetchMock).toHaveBeenCalledTimes(0); - expect(readFile).toHaveBeenCalledTimes(1); - expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', - 'utf-8', - ); - }); - }); - }); - - describe('fetch cache', () => { - it('should respect the fetch cache option', async () => { - fetchMock.mockResponse(JSON.stringify('bar2')); - const edgeConfig = createClient(process.env.EDGE_CONFIG, { - cache: 'force-cache', - }); - await expect(edgeConfig.get('foo')).resolves.toEqual('bar2'); - - // returns undefined as file does not exist - expect(readFile).toHaveBeenCalledTimes(1); - expect(readFile).toHaveBeenCalledWith( - '/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', - { - cache: 'force-cache', - headers: new Headers({ - Authorization: 'Bearer token-1', - 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, - 'x-edge-config-vercel-env': 'test', - }), - }, - ); - }); - }); + // describe('getAll(keys)', () => { + // describe('when called without keys', () => { + // it('should return all items', async () => { + // fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' })); + + // await expect(getAll()).resolves.toEqual({ foo: 'foo1' }); + + // 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.mockResponse(JSON.stringify({ foo: 'foo1', bar: 'bar1' })); + + // await expect(getMultiple(['foo', 'bar'])).resolves.toEqual({ + // foo: 'foo1', + // bar: 'bar1', + // }); + + // 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 called with an empty string key', () => { + // it('should return the selected items', async () => { + // await expect(getMultiple([''])).resolves.toEqual({}); + // expect(fetchMock).toHaveBeenCalledTimes(0); + // }); + // }); + + // describe('when called with an empty string key mix', () => { + // it('should return the selected items', async () => { + // fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' })); + // await expect(getMultiple(['foo', ''])).resolves.toEqual({ + // foo: 'foo1', + // }); + // expect(fetchMock).toHaveBeenCalledTimes(1); + // }); + // }); + + // describe('when the edge config does not exist', () => { + // it('should throw', async () => { + // fetchMock.mockResponse( + // JSON.stringify({ + // error: { + // code: 'edge_config_not_found', + // message: 'Could not find the edge config: ecfg-1', + // }, + // }), + // { status: 404, headers: { 'content-type': 'application/json' } }, + // ); + + // await expect(getMultiple(['foo', 'bar'])).rejects.toThrow( + // '@vercel/edge-config: Edge Config not found', + // ); + + // 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 network fails', () => { + // it('should throw a Network error', async () => { + // fetchMock.mockReject(new Error('Unexpected fetch error')); + + // await expect(getAll()).rejects.toThrow('Unexpected fetch error'); + + // 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 an unexpected status code is returned', () => { + // it('should throw a Unexpected error on 500', async () => { + // fetchMock.mockResponse('', { status: 500 }); + + // await expect(getAll()).rejects.toThrow( + // '@vercel/edge-config: Unexpected error due to response with status code 500', + // ); + + // 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('has(key)', () => { + // describe('when item exists', () => { + // it('should return true', async () => { + // fetchMock.mockResponse(''); + + // 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.mockResponse( + // JSON.stringify({ + // error: { + // code: 'edge_config_item_not_found', + // message: 'Could not find the edge config item: foo', + // }, + // }), + // { + // status: 404, + // headers: { + // 'content-type': 'application/json', + // 'x-edge-config-digest': 'fake', + // }, + // }, + // ); + + // await expect(has('foo')).resolves.toEqual(false); + + // 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 edge config does not exist', () => { + // it('should return false', async () => { + // fetchMock.mockResponse( + // JSON.stringify({ + // error: { + // code: 'edge_config_not_found', + // message: 'Could not find the edge config: ecfg-1', + // }, + // }), + // { status: 404, headers: { 'content-type': 'application/json' } }, + // ); + + // await expect(has('foo')).rejects.toThrow( + // '@vercel/edge-config: Edge Config not found', + // ); + + // 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', + // }, + // ); + // }); + // }); + // }); }); diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 15e2ba7c1..8ea7ea5f8 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -1,4 +1,3 @@ -import { name as sdkName, version as sdkVersion } from '../package.json'; import { assertIsKey, isEmptyKey, parseConnectionString } from './utils'; import type { EdgeConfigClient, @@ -50,20 +49,6 @@ export const createClient = trace( '@vercel/edge-config: Invalid connection string provided', ); - const headers: Record = { - Authorization: `Bearer ${connection.token}`, - }; - - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- [@vercel/style-guide@5 migration] - if (typeof process !== 'undefined' && process.env.VERCEL_ENV) - headers['x-edge-config-vercel-env'] = process.env.VERCEL_ENV; - - if (typeof sdkName === 'string' && typeof sdkVersion === 'string') - headers['x-edge-config-sdk'] = `${sdkName}@${sdkVersion}`; - - if (typeof options.staleIfError === 'number' && options.staleIfError > 0) - headers['cache-control'] = `stale-if-error=${options.staleIfError}`; - /** * While in development we use SWR-like behavior for the api client to * reduce latency. @@ -85,7 +70,7 @@ export const createClient = trace( 'get' | 'has' | 'getMultiple' | 'getAll' > = { get: trace( - async function get( + async function get( key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise { From 22f147aa2293f1605042d4d18b189514cf050691 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 01:07:17 +0300 Subject: [PATCH 41/81] wip --- packages/edge-config/src/controller.ts | 2 + packages/edge-config/src/index.common.test.ts | 129 +---- packages/edge-config/src/index.edge.test.ts | 514 ------------------ packages/edge-config/src/index.node.test.ts | 475 ++++++++-------- packages/edge-config/src/index.ts | 7 + packages/edge-config/src/types.ts | 4 +- 6 files changed, 287 insertions(+), 844 deletions(-) delete mode 100644 packages/edge-config/src/index.edge.test.ts diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index bd3b4edd8..a3bb8e56f 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -60,6 +60,7 @@ export class Controller { private staleThreshold: number; private cacheMode: 'no-store' | 'force-cache'; private enableDevelopmentCache: boolean; + private staleIfError: number; // create an instance per controller so the caches are isolated private enhancedFetch: ReturnType; @@ -70,6 +71,7 @@ export class Controller { ) { this.connection = connection; this.staleThreshold = options.staleThreshold ?? DEFAULT_STALE_THRESHOLD; + this.staleIfError = options.staleIfError ?? 604800 * 1000 /* one week */; this.cacheMode = options.cache || 'no-store'; this.enableDevelopmentCache = options.enableDevelopmentCache; this.enhancedFetch = createEnhancedFetch(); diff --git a/packages/edge-config/src/index.common.test.ts b/packages/edge-config/src/index.common.test.ts index 8e7f2038d..cb28ff168 100644 --- a/packages/edge-config/src/index.common.test.ts +++ b/packages/edge-config/src/index.common.test.ts @@ -6,7 +6,6 @@ import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import type { EdgeConfigClient } from './types'; -import { cache } from './utils/enhanced-fetch'; import * as pkg from './index'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; @@ -118,7 +117,6 @@ describe('when running without lambda layer or via edge function', () => { beforeEach(() => { fetchMock.resetMocks(); - cache.clear(); edgeConfig = pkg.createClient(modifiedConnectionString); }); @@ -137,7 +135,13 @@ describe('when running without lambda layer or via edge function', () => { describe('get', () => { describe('when item exists', () => { it('should fetch using information from the passed token', async () => { - fetchMock.mockResponse(JSON.stringify('bar')); + fetchMock.mockResponse(JSON.stringify('bar'), { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); await expect(edgeConfig.get('foo')).resolves.toEqual('bar'); @@ -145,11 +149,11 @@ describe('when running without lambda layer or via edge function', () => { expect(fetchMock).toHaveBeenCalledWith( `${modifiedBaseUrl}/item/foo?version=1`, { + method: 'GET', 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', }, @@ -157,8 +161,10 @@ describe('when running without lambda layer or via edge function', () => { }); }); describe('attempting to read an empty key', () => { - it('should return undefined', async () => { - await expect(edgeConfig.get('')).resolves.toBe(undefined); + it('should throw', async () => { + await expect(edgeConfig.get('')).rejects.toThrow( + '@vercel/edge-config: Can not read empty key', + ); expect(fetchMock).toHaveBeenCalledTimes(0); }); }); @@ -167,7 +173,13 @@ describe('when running without lambda layer or via edge function', () => { describe('has(key)', () => { describe('when item exists', () => { it('should return true', async () => { - fetchMock.mockResponse(''); + fetchMock.mockResponse('', { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); await expect(edgeConfig.has('foo')).resolves.toEqual(true); @@ -180,7 +192,6 @@ describe('when running without lambda layer or via edge function', () => { 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', }, @@ -188,99 +199,16 @@ describe('when running without lambda layer or via edge function', () => { }); }); describe('attempting to read an empty key', () => { - it('should return false', async () => { - await expect(edgeConfig.has('')).resolves.toBe(false); - expect(fetchMock).toHaveBeenCalledTimes(0); - }); - }); - }); - - describe('digest()', () => { - describe('when the request succeeds', () => { - it('should return the digest', async () => { - fetchMock.mockResponse(JSON.stringify('awe1')); - - await expect(edgeConfig.digest()).resolves.toEqual('awe1'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - `${modifiedBaseUrl}/digest?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', - }, + it('should throw', async () => { + await expect(edgeConfig.has('')).rejects.toThrow( + '@vercel/edge-config: Can not read empty key', ); + expect(fetchMock).toHaveBeenCalledTimes(0); }); }); }); }); -describe('etags and If-None-Match', () => { - const modifiedConnectionString = - 'https://edge-config.vercel.com/ecfg-2?token=token-2'; - const modifiedBaseUrl = 'https://edge-config.vercel.com/ecfg-2'; - let edgeConfig: EdgeConfigClient; - - beforeEach(() => { - fetchMock.resetMocks(); - cache.clear(); - edgeConfig = pkg.createClient(modifiedConnectionString); - }); - - describe('when reading the same item twice', () => { - it('should reuse the response', async () => { - fetchMock.mockResponseOnce(JSON.stringify('bar'), { - headers: { ETag: 'a' }, - }); - - await expect(edgeConfig.get('foo')).resolves.toEqual('bar'); - - // the server would not actually send a response body the second time - // as the etag matches - fetchMock.mockResponseOnce('', { - status: 304, - headers: { ETag: 'a' }, - }); - - // second call should reuse response - - await expect(edgeConfig.get('foo')).resolves.toEqual('bar'); - - expect(fetchMock).toHaveBeenCalledTimes(2); - 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', - }, - ); - 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', - 'If-None-Match': 'a', - }), - cache: 'no-store', - }, - ); - }); - }); -}); - describe('stale-if-error semantics', () => { const modifiedConnectionString = 'https://edge-config.vercel.com/ecfg-2?token=token-2'; @@ -289,14 +217,18 @@ describe('stale-if-error semantics', () => { beforeEach(() => { fetchMock.resetMocks(); - cache.clear(); edgeConfig = pkg.createClient(modifiedConnectionString); }); describe('when reading the same item twice but the second read has an internal server error', () => { it('should reuse the cached/stale response', async () => { fetchMock.mockResponseOnce(JSON.stringify('bar'), { - headers: { ETag: 'a' }, + headers: { + ETag: 'a', + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, }); await expect(edgeConfig.get('foo')).resolves.toEqual('bar'); @@ -388,7 +320,6 @@ describe('connectionStrings', () => { beforeEach(() => { fetchMock.resetMocks(); - cache.clear(); edgeConfig = pkg.createClient(modifiedConnectionString); }); @@ -417,7 +348,7 @@ describe('connectionStrings', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - `https://example.com/ecfg-2/item/foo?version=1`, + 'https://example.com/ecfg-2/item/foo?version=1', { headers: new Headers({ Authorization: 'Bearer token-2', diff --git a/packages/edge-config/src/index.edge.test.ts b/packages/edge-config/src/index.edge.test.ts deleted file mode 100644 index 1a2fb5d07..000000000 --- a/packages/edge-config/src/index.edge.test.ts +++ /dev/null @@ -1,514 +0,0 @@ -import fetchMock from 'jest-fetch-mock'; -import { version as pkgVersion } from '../package.json'; -import { cache } from './utils/enhanced-fetch'; -import { get, has, digest, getAll, createClient } from './index'; - -const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; -const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; - -describe('default Edge Config', () => { - beforeEach(() => { - fetchMock.resetMocks(); - cache.clear(); - }); - - 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', - ); - }); - }); - - it('should fetch an item from the Edge Config specified by process.env.EDGE_CONFIG', async () => { - fetchMock.mockResponse(JSON.stringify('bar')); - - await expect(get('foo')).resolves.toEqual('bar'); - - 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('get(key)', () => { - describe('when item exists', () => { - it('should return the value', async () => { - fetchMock.mockResponse(JSON.stringify('bar')); - - await expect(get('foo')).resolves.toEqual('bar'); - - 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 the item does not exist', () => { - it('should return undefined', async () => { - fetchMock.mockResponse( - JSON.stringify({ - error: { - code: 'edge_config_item_not_found', - message: 'Could not find the edge config item: foo', - }, - }), - { - status: 404, - headers: { - 'content-type': 'application/json', - 'x-edge-config-digest': 'fake', - }, - }, - ); - - await expect(get('foo')).resolves.toEqual(undefined); - - 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 the edge config does not exist', () => { - it('should return undefined', async () => { - fetchMock.mockResponse( - JSON.stringify({ - error: { - code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', - }, - }), - { status: 404, headers: { 'content-type': 'application/json' } }, - ); - - await expect(get('foo')).rejects.toThrow( - '@vercel/edge-config: Edge Config not found', - ); - - 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 the network fails', () => { - it('should throw a Network error', async () => { - fetchMock.mockReject(new Error('Unexpected fetch error')); - - await expect(get('foo')).rejects.toThrow('Unexpected fetch error'); - - 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 an unexpected status code is returned', () => { - it('should throw a Unexpected error on 500', async () => { - fetchMock.mockResponse('', { status: 500 }); - - await expect(get('foo')).rejects.toThrow( - '@vercel/edge-config: Unexpected error due to response with status code 500', - ); - - 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.mockResponse(JSON.stringify({ foo: 'foo1' })); - - await expect(getAll()).resolves.toEqual({ foo: 'foo1' }); - - 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.mockResponse(JSON.stringify({ foo: 'foo1', bar: 'bar1' })); - - await expect(getAll(['foo', 'bar'])).resolves.toEqual({ - foo: 'foo1', - bar: 'bar1', - }); - - 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 called with an empty string key', () => { - it('should return the selected items', async () => { - await expect(getAll([''])).resolves.toEqual({}); - expect(fetchMock).toHaveBeenCalledTimes(0); - }); - }); - - describe('when called with an empty string key mix', () => { - it('should return the selected items', async () => { - fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' })); - await expect(getAll(['foo', ''])).resolves.toEqual({ foo: 'foo1' }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - }); - - describe('when the edge config does not exist', () => { - it('should throw', async () => { - fetchMock.mockResponse( - JSON.stringify({ - error: { - code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', - }, - }), - { status: 404, headers: { 'content-type': 'application/json' } }, - ); - - await expect(getAll(['foo', 'bar'])).rejects.toThrow( - '@vercel/edge-config: Edge Config not found', - ); - - 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 network fails', () => { - it('should throw a Network error', async () => { - fetchMock.mockReject(new Error('Unexpected fetch error')); - - await expect(getAll()).rejects.toThrow('Unexpected fetch error'); - - 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 an unexpected status code is returned', () => { - it('should throw a Unexpected error on 500', async () => { - fetchMock.mockResponse('', { status: 500 }); - - await expect(getAll()).rejects.toThrow( - '@vercel/edge-config: Unexpected error due to response with status code 500', - ); - - 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('has(key)', () => { - describe('when item exists', () => { - it('should return true', async () => { - fetchMock.mockResponse(''); - - 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.mockResponse( - JSON.stringify({ - error: { - code: 'edge_config_item_not_found', - message: 'Could not find the edge config item: foo', - }, - }), - { - status: 404, - headers: { - 'content-type': 'application/json', - 'x-edge-config-digest': 'fake', - }, - }, - ); - - await expect(has('foo')).resolves.toEqual(false); - - 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 edge config does not exist', () => { - it('should return false', async () => { - fetchMock.mockResponse( - JSON.stringify({ - error: { - code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', - }, - }), - { status: 404, headers: { 'content-type': 'application/json' } }, - ); - - await expect(has('foo')).rejects.toThrow( - '@vercel/edge-config: Edge Config not found', - ); - - 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('/', () => { - describe('when the request succeeds', () => { - it('should return the digest', async () => { - fetchMock.mockResponse(JSON.stringify('awe1')); - - await expect(digest()).resolves.toEqual('awe1'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/digest?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 the server returns an unexpected status code', () => { - it('should throw an Unexpected error on 500', async () => { - fetchMock.mockResponse('', { status: 500 }); - - await expect(digest()).rejects.toThrow( - '@vercel/edge-config: Unexpected error due to response with status code 500', - ); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/digest?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', - }); - }); - - it('should throw an Unexpected error on 404', async () => { - fetchMock.mockResponse('', { status: 404 }); - - await expect(digest()).rejects.toThrow( - '@vercel/edge-config: Unexpected error due to response with status code 404', - ); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/digest?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 the network fails', () => { - it('should throw a Network error', async () => { - fetchMock.mockReject(new Error('Unexpected fetch error')); - - await expect(digest()).rejects.toThrow('Unexpected fetch error'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/digest?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('createClient', () => { - beforeEach(() => { - fetchMock.resetMocks(); - cache.clear(); - }); - - describe('when the request succeeds', () => { - it('should respect the fetch cache option', async () => { - fetchMock.mockResponse(JSON.stringify('awe1')); - - const edgeConfig = createClient(process.env.EDGE_CONFIG, { - cache: 'force-cache', - }); - - await expect(edgeConfig.get('foo')).resolves.toEqual('awe1'); - - 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: 'force-cache', - }); - }); - }); -}); diff --git a/packages/edge-config/src/index.node.test.ts b/packages/edge-config/src/index.node.test.ts index 59d95bf35..7796a19b5 100644 --- a/packages/edge-config/src/index.node.test.ts +++ b/packages/edge-config/src/index.node.test.ts @@ -188,233 +188,250 @@ describe('default Edge Config', () => { }); }); - // describe('getAll(keys)', () => { - // describe('when called without keys', () => { - // it('should return all items', async () => { - // fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' })); - - // await expect(getAll()).resolves.toEqual({ foo: 'foo1' }); - - // 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.mockResponse(JSON.stringify({ foo: 'foo1', bar: 'bar1' })); - - // await expect(getMultiple(['foo', 'bar'])).resolves.toEqual({ - // foo: 'foo1', - // bar: 'bar1', - // }); - - // 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 called with an empty string key', () => { - // it('should return the selected items', async () => { - // await expect(getMultiple([''])).resolves.toEqual({}); - // expect(fetchMock).toHaveBeenCalledTimes(0); - // }); - // }); - - // describe('when called with an empty string key mix', () => { - // it('should return the selected items', async () => { - // fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' })); - // await expect(getMultiple(['foo', ''])).resolves.toEqual({ - // foo: 'foo1', - // }); - // expect(fetchMock).toHaveBeenCalledTimes(1); - // }); - // }); - - // describe('when the edge config does not exist', () => { - // it('should throw', async () => { - // fetchMock.mockResponse( - // JSON.stringify({ - // error: { - // code: 'edge_config_not_found', - // message: 'Could not find the edge config: ecfg-1', - // }, - // }), - // { status: 404, headers: { 'content-type': 'application/json' } }, - // ); - - // await expect(getMultiple(['foo', 'bar'])).rejects.toThrow( - // '@vercel/edge-config: Edge Config not found', - // ); - - // 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 network fails', () => { - // it('should throw a Network error', async () => { - // fetchMock.mockReject(new Error('Unexpected fetch error')); - - // await expect(getAll()).rejects.toThrow('Unexpected fetch error'); - - // 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 an unexpected status code is returned', () => { - // it('should throw a Unexpected error on 500', async () => { - // fetchMock.mockResponse('', { status: 500 }); - - // await expect(getAll()).rejects.toThrow( - // '@vercel/edge-config: Unexpected error due to response with status code 500', - // ); - - // 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('has(key)', () => { - // describe('when item exists', () => { - // it('should return true', async () => { - // fetchMock.mockResponse(''); - - // 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.mockResponse( - // JSON.stringify({ - // error: { - // code: 'edge_config_item_not_found', - // message: 'Could not find the edge config item: foo', - // }, - // }), - // { - // status: 404, - // headers: { - // 'content-type': 'application/json', - // 'x-edge-config-digest': 'fake', - // }, - // }, - // ); - - // await expect(has('foo')).resolves.toEqual(false); - - // 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 edge config does not exist', () => { - // it('should return false', async () => { - // fetchMock.mockResponse( - // JSON.stringify({ - // error: { - // code: 'edge_config_not_found', - // message: 'Could not find the edge config: ecfg-1', - // }, - // }), - // { status: 404, headers: { 'content-type': 'application/json' } }, - // ); - - // await expect(has('foo')).rejects.toThrow( - // '@vercel/edge-config: Edge Config not found', - // ); - - // 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('getAll()', () => { + it('should return all items', async () => { + fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' }), { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); + + await expect(getAll()).resolves.toEqual({ foo: 'foo1' }); + + 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: 'no-store', + }); + }); + }); + + describe('getMultiple(keys)', () => { + describe('when called with keys', () => { + it('should return the selected items', async () => { + fetchMock.mockResponse(JSON.stringify({ foo: 'foo1', bar: 'bar1' }), { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); + + await expect(getMultiple(['foo', 'bar'])).resolves.toEqual({ + foo: 'foo1', + bar: 'bar1', + }); + + 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: 'no-store', + }, + ); + }); + }); + + describe('when called with an empty string key', () => { + it('should return the selected items', async () => { + await expect(getMultiple([''])).resolves.toEqual({}); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + }); + + describe('when called with an empty string key mix', () => { + it('should return the selected items', async () => { + fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' }), { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); + await expect(getMultiple(['foo', ''])).resolves.toEqual({ + foo: 'foo1', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the edge config does not exist', () => { + it('should throw', async () => { + fetchMock.mockResponse( + JSON.stringify({ + error: { + code: 'edge_config_not_found', + message: 'Could not find the edge config: ecfg-1', + }, + }), + { status: 404, headers: { 'content-type': 'application/json' } }, + ); + + await expect(getMultiple(['foo', 'bar'])).rejects.toThrow( + '@vercel/edge-config: Edge Config not found', + ); + + 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: 'no-store', + }, + ); + }); + }); + + describe('when the network fails', () => { + it('should throw a Network error', async () => { + fetchMock.mockReject(new Error('Unexpected fetch error')); + + await expect(getAll()).rejects.toThrow('Unexpected fetch error'); + + 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: 'no-store', + }); + }); + }); + + describe('when an unexpected status code is returned', () => { + it('should throw a Unexpected error on 500', async () => { + fetchMock.mockResponse('', { status: 500 }); + + await expect(getAll()).rejects.toThrow( + '@vercel/edge-config: Unexpected error due to response with status code 500', + ); + + 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: 'no-store', + }); + }); + }); + }); + + describe('has(key)', () => { + describe('when item exists', () => { + it('should return true', async () => { + fetchMock.mockResponse('', { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); + + 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: 'no-store', + }, + ); + }); + }); + + describe('when the item does not exist', () => { + it('should return false', async () => { + fetchMock.mockResponse( + JSON.stringify({ + error: { + code: 'edge_config_item_not_found', + message: 'Could not find the edge config item: foo', + }, + }), + { + status: 404, + headers: { + 'content-type': 'application/json', + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + }, + }, + ); + + await expect(has('foo')).resolves.toEqual(false); + + 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: 'no-store', + }, + ); + }); + }); + + describe('when the edge config does not exist', () => { + it('should return false', async () => { + fetchMock.mockResponse( + JSON.stringify({ + error: { + code: 'edge_config_not_found', + message: 'Could not find the edge config: ecfg-1', + }, + }), + { status: 404, headers: { 'content-type': 'application/json' } }, + ); + + await expect(has('foo')).rejects.toThrow( + '@vercel/edge-config: Edge Config not found', + ); + + 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: 'no-store', + }, + ); + }); + }); + }); }); diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 8ea7ea5f8..67042dcc2 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -103,6 +103,13 @@ export const createClient = trace( keys: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; digest: string } | T> { + // bypass when called without valid keys and without needing metadata + if ( + keys.every((k) => typeof k === 'string' && k.trim().length === 0) && + !localOptions?.metadata + ) + return {} as T; + const data = await controller.getMultiple(keys, localOptions); return localOptions?.metadata ? data : data.value; }, diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 6f37036b1..130948e26 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -127,9 +127,9 @@ export interface EdgeConfigClientOptions { /** * Configure for how long the SDK will return a stale value in case a fresh value could not be fetched. * - * @default Infinity + * @default One week */ - staleIfError?: number | false; + staleIfError?: 604800; /** * Configure the threshold for how long the SDK allows stale values to be From 56f6770a3406bf6c54cdce79a77b84eee2eeaf1b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 01:31:46 +0300 Subject: [PATCH 42/81] rename staleThreshold to maxStale --- packages/edge-config/src/controller.ts | 12 ++++++------ packages/edge-config/src/index.ts | 14 ++++++++++++-- packages/edge-config/src/types.ts | 4 ++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index a3bb8e56f..39e5f14df 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -43,12 +43,12 @@ function parseTs(updatedAt: string | null): number | null { function getCacheStatus( latestUpdate: number | undefined, updatedAt: number, - staleThreshold: number, + maxStale: number, ): CacheStatus { if (latestUpdate === undefined) return 'MISS'; if (latestUpdate <= updatedAt) return 'HIT'; // check if it is within the threshold - if (updatedAt >= latestUpdate - staleThreshold) return 'STALE'; + if (updatedAt >= latestUpdate - maxStale) return 'STALE'; return 'MISS'; } @@ -57,7 +57,7 @@ export class Controller { null; private itemCache = new Map(); private connection: Connection; - private staleThreshold: number; + private maxStale: number; private cacheMode: 'no-store' | 'force-cache'; private enableDevelopmentCache: boolean; private staleIfError: number; @@ -70,7 +70,7 @@ export class Controller { options: EdgeConfigClientOptions & { enableDevelopmentCache: boolean }, ) { this.connection = connection; - this.staleThreshold = options.staleThreshold ?? DEFAULT_STALE_THRESHOLD; + this.maxStale = options.maxStale ?? DEFAULT_STALE_THRESHOLD; this.staleIfError = options.staleIfError ?? 604800 * 1000 /* one week */; this.cacheMode = options.cache || 'no-store'; this.enableDevelopmentCache = options.enableDevelopmentCache; @@ -139,7 +139,7 @@ export class Controller { const cacheStatus = getCacheStatus( timestampOfLatestUpdate, cached.updatedAt, - this.staleThreshold, + this.maxStale, ); // HIT @@ -465,7 +465,7 @@ export class Controller { const cacheStatus = getCacheStatus( timestampOfLatestUpdate, this.edgeConfigCache.updatedAt, - this.staleThreshold, + this.maxStale, ); // HIT diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 67042dcc2..0ae0ba078 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -34,8 +34,18 @@ export const createClient = trace( function createClient( connectionString: string | undefined, options: EdgeConfigClientOptions = { - staleIfError: 604800 /* one week */, - staleThreshold: 60 /* 1 minute */, + /** + * Allows a stored response that is stale for N seconds to be served + * in case of an error. + */ + staleIfError: 604800, + /** + * Allows a stored response that is stale for N seconds to be served + * while a background refresh is performed to get the latest value. + * + * If the threshold is exceeded a blocking read will be performed. + */ + maxStale: 60, cache: 'no-store', }, ): EdgeConfigClient { diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 130948e26..8e0643712 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -157,7 +157,7 @@ export interface EdgeConfigClientOptions { * * @default 10 */ - staleThreshold?: number; + maxStale?: number; /** * In development, a stale-while-revalidate cache is employed as the default caching strategy. @@ -181,5 +181,5 @@ export interface EdgeConfigClientOptions { export type CacheStatus = | 'HIT' // value is cached and deemed fresh | 'STALE' // value is cached but we know it's outdated - | 'MISS' // value was fetched over network as the staleThreshold was exceeded + | 'MISS' // value was fetched over network as the maxStale was exceeded | 'BYPASS'; // value was fetched over the network as a consistent read was requested From 5ec324332b0e1b76eb9fd68ba25b45d0d52c9845 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 01:39:18 +0300 Subject: [PATCH 43/81] fix controller --- packages/edge-config/src/controller.test.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 39d5cabb1..c15250b9e 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -50,6 +50,8 @@ describe('lifecycle: reading a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -100,6 +102,8 @@ describe('lifecycle: reading a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '7000', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -148,6 +152,8 @@ describe('lifecycle: reading a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -191,6 +197,8 @@ describe('lifecycle: reading the full config', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -238,6 +246,8 @@ describe('lifecycle: reading the full config', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '7000', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -283,6 +293,8 @@ describe('lifecycle: reading the full config', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -327,6 +339,8 @@ describe('lifecycle: checking existence of a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -375,6 +389,8 @@ describe('lifecycle: checking existence of a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '7000', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -421,6 +437,8 @@ describe('lifecycle: checking existence of a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -649,6 +667,8 @@ describe('development cache: get', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'If-None-Match': '"digest2"', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); @@ -684,6 +704,8 @@ describe('development cache: get', () => { Authorization: 'Bearer fake-edge-config-token', // we query with the older etag we had in memory 'If-None-Match': '"digest2"', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', }), }, ); From b194a1e594760e1646cc15a4ea3975b4121f7ed5 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 08:31:09 +0300 Subject: [PATCH 44/81] rename and comment --- packages/edge-config/src/controller.ts | 7 ++++--- packages/edge-config/src/types.ts | 24 ++++++++++++++++-------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 39e5f14df..a03165c8b 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -13,7 +13,8 @@ import { ERRORS, isEmptyKey, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; import { createEnhancedFetch } from './utils/enhanced-fetch'; -const DEFAULT_STALE_THRESHOLD = 10_000; // 10 seconds +const DEFAULT_STALE_THRESHOLD = 10; // 10 seconds +const DEFAULT_STALE_IF_ERROR = 604800; // one week in seconds let timestampOfLatestUpdate: number | undefined; @@ -48,7 +49,7 @@ function getCacheStatus( if (latestUpdate === undefined) return 'MISS'; if (latestUpdate <= updatedAt) return 'HIT'; // check if it is within the threshold - if (updatedAt >= latestUpdate - maxStale) return 'STALE'; + if (updatedAt >= latestUpdate - maxStale * 1000) return 'STALE'; return 'MISS'; } @@ -71,7 +72,7 @@ export class Controller { ) { this.connection = connection; this.maxStale = options.maxStale ?? DEFAULT_STALE_THRESHOLD; - this.staleIfError = options.staleIfError ?? 604800 * 1000 /* one week */; + this.staleIfError = options.staleIfError ?? DEFAULT_STALE_IF_ERROR; this.cacheMode = options.cache || 'no-store'; this.enableDevelopmentCache = options.enableDevelopmentCache; this.enhancedFetch = createEnhancedFetch(); diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 8e0643712..0965e4bd5 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -125,20 +125,28 @@ export interface EdgeConfigFunctionsOptions { export interface EdgeConfigClientOptions { /** - * Configure for how long the SDK will return a stale value in case a fresh value could not be fetched. + * Configure the threshold (in seconds) for how long the SDK will return a + * stale value in case a fresh value could not be fetched before throwing an + * error. + * + * Unlike regular stale-if-error behavior where the SDK will return a stale + * value for a certain amount of time, this threshold configures the difference, + * in seconds, between when an update was made until the SDK will throw an error. * * @default One week */ staleIfError?: 604800; /** - * Configure the threshold for how long the SDK allows stale values to be - * served after they become outdated. The SDK will switch from refreshing - * in the background to performing a blocking fetch when this threshold is - * exceeded. - * - * The threshold configures the difference, in seconds, between when an update - * was made until the SDK will force fetch the latest value. + * Configure the threshold (in seconds) for how long the SDK allows stale + * values to be served after they become outdated. The SDK will switch from + * refreshing in the background to performing a blocking fetch when this + * threshold is exceeded. + * + * Unlike regular stale-if-error behavior where the SDK will return a stale + * value for a certain amount of time, this threshold configures the difference, + * in seconds, between when an update was made until the SDK will force fetch + * the latest value. * * Background refresh example: * If you set this value to 10 seconds, then reads within 10 From 649b6cda3cbdbab5ac8280a7d7fc7800a49c091b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 09:10:28 +0300 Subject: [PATCH 45/81] refactor --- packages/edge-config/src/controller.ts | 83 ++++++++++++++----- packages/edge-config/src/index.common.test.ts | 20 +++-- packages/edge-config/src/index.ts | 2 +- packages/edge-config/src/types.ts | 2 +- 4 files changed, 74 insertions(+), 33 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index a03165c8b..40e632923 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -14,7 +14,7 @@ import { consumeResponseBody } from './utils/consume-response-body'; import { createEnhancedFetch } from './utils/enhanced-fetch'; const DEFAULT_STALE_THRESHOLD = 10; // 10 seconds -const DEFAULT_STALE_IF_ERROR = 604800; // one week in seconds +// const DEFAULT_STALE_IF_ERROR = 604800; // one week in seconds let timestampOfLatestUpdate: number | undefined; @@ -61,7 +61,7 @@ export class Controller { private maxStale: number; private cacheMode: 'no-store' | 'force-cache'; private enableDevelopmentCache: boolean; - private staleIfError: number; + private staleIfError: boolean; // create an instance per controller so the caches are isolated private enhancedFetch: ReturnType; @@ -72,7 +72,7 @@ export class Controller { ) { this.connection = connection; this.maxStale = options.maxStale ?? DEFAULT_STALE_THRESHOLD; - this.staleIfError = options.staleIfError ?? DEFAULT_STALE_IF_ERROR; + this.staleIfError = options.staleIfError ?? true; this.cacheMode = options.cache || 'no-store'; this.enableDevelopmentCache = options.enableDevelopmentCache; this.enhancedFetch = createEnhancedFetch(); @@ -93,10 +93,20 @@ export class Controller { key, timestampOfLatestUpdate, localOptions, + true, ); } - return this.handleCachedRequest(key, 'GET', localOptions); + const cached = this.readCache(key, 'GET', localOptions); + if (cached) return cached; + + return this.fetchItem( + 'GET', + key, + timestampOfLatestUpdate, + localOptions, + true, + ); } /** @@ -120,18 +130,18 @@ export class Controller { } /** - * Generic handler for cached requests that implements the common cache logic + * Checks the cache and kicks off a background refresh if needed. */ - private async handleCachedRequest( + private readCache( key: string, method: 'GET' | 'HEAD', localOptions: EdgeConfigFunctionsOptions | undefined, - ): Promise<{ + ): { value: T | undefined; digest: string; cache: CacheStatus; exists: boolean; - }> { + } | null { // only use the cache if we have a timestamp of the latest update if (timestampOfLatestUpdate) { const cached = this.getCachedItem(key, method); @@ -155,6 +165,7 @@ export class Controller { key, timestampOfLatestUpdate, localOptions, + false, ).catch(() => null), ); @@ -164,14 +175,7 @@ export class Controller { } // MISS - const result = await this.fetchItem( - method, - key, - timestampOfLatestUpdate, - localOptions, - ); - - return result; + return null; } /** @@ -276,6 +280,7 @@ export class Controller { key: string, minUpdatedAt: number | undefined, localOptions?: EdgeConfigFunctionsOptions, + staleIfError?: boolean, ): Promise<{ value: T | undefined; digest: string; @@ -300,7 +305,20 @@ export class Controller { const digest = res.headers.get('x-edge-config-digest'); const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); - if (res.status === 500) throw new UnexpectedNetworkError(res); + if ( + res.status === 500 || + res.status === 502 || + res.status === 503 || + res.status === 504 + ) { + if (staleIfError) { + const cached = this.getCachedItem(key, method); + if (cached) return { ...cached, cache: 'STALE' }; + } + + throw new UnexpectedNetworkError(res); + } + if (!digest || !updatedAt) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); if (res.ok || (res.status === 304 && cachedRes)) { @@ -380,10 +398,25 @@ export class Controller { localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }> { if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { - return this.fetchItem('HEAD', key, timestampOfLatestUpdate, localOptions); + return this.fetchItem( + 'HEAD', + key, + timestampOfLatestUpdate, + localOptions, + true, + ); } - return this.handleCachedRequest(key, 'HEAD', localOptions); + const cached = this.readCache(key, 'HEAD', localOptions); + if (cached) return cached; + + return this.fetchItem( + 'HEAD', + key, + timestampOfLatestUpdate, + localOptions, + true, + ); } public async digest( @@ -420,25 +453,29 @@ export class Controller { return { value: {} as T, digest: '' }; } + // TODO getMultiple is not caching yet + const search = new URLSearchParams( keys .filter((key) => typeof key === 'string' && !isEmptyKey(key)) .map((key) => ['key', key] as [string, string]), ).toString(); - return fetch( + return this.enhancedFetch( `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, { headers: this.getHeaders(localOptions, timestampOfLatestUpdate), cache: this.cacheMode, }, - ).then<{ value: T; digest: string }>(async (res) => { - if (res.ok) { + ).then<{ value: T; digest: string }>(async ([res, cachedRes]) => { + if (res.ok || (res.status === 304 && cachedRes)) { const digest = res.headers.get('x-edge-config-digest'); if (!digest) { throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); } - const value = (await res.json()) as T; + const value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as T; return { value, digest }; } await consumeResponseBody(res); diff --git a/packages/edge-config/src/index.common.test.ts b/packages/edge-config/src/index.common.test.ts index cb28ff168..473087ab6 100644 --- a/packages/edge-config/src/index.common.test.ts +++ b/packages/edge-config/src/index.common.test.ts @@ -224,7 +224,7 @@ describe('stale-if-error semantics', () => { it('should reuse the cached/stale response', async () => { fetchMock.mockResponseOnce(JSON.stringify('bar'), { headers: { - ETag: 'a', + ETag: '"a"', 'x-edge-config-digest': 'fake', 'x-edge-config-updated-at': '1000', 'content-type': 'application/json', @@ -243,11 +243,11 @@ describe('stale-if-error semantics', () => { expect(fetchMock).toHaveBeenCalledWith( `${modifiedBaseUrl}/item/foo?version=1`, { + method: 'GET', 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', }, @@ -255,12 +255,12 @@ describe('stale-if-error semantics', () => { expect(fetchMock).toHaveBeenCalledWith( `${modifiedBaseUrl}/item/foo?version=1`, { + method: 'GET', 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', - 'If-None-Match': 'a', + 'If-None-Match': '"a"', }), cache: 'no-store', }, @@ -271,7 +271,12 @@ describe('stale-if-error semantics', () => { describe('when reading the same item twice but the second read throws a network error', () => { it('should reuse the cached/stale response', async () => { fetchMock.mockResponseOnce(JSON.stringify('bar'), { - headers: { ETag: 'a' }, + headers: { + ETag: '"a"', + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, }); await expect(edgeConfig.get('foo')).resolves.toEqual('bar'); @@ -287,10 +292,10 @@ describe('stale-if-error semantics', () => { `${modifiedBaseUrl}/item/foo?version=1`, { headers: new Headers({ + method: 'GET', 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', }, @@ -302,8 +307,7 @@ describe('stale-if-error semantics', () => { Authorization: 'Bearer token-2', 'x-edge-config-vercel-env': 'test', 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, - 'cache-control': 'stale-if-error=604800', - 'If-None-Match': 'a', + 'If-None-Match': '"a"', }), cache: 'no-store', }, diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 0ae0ba078..9d62261cb 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -38,7 +38,7 @@ export const createClient = trace( * Allows a stored response that is stale for N seconds to be served * in case of an error. */ - staleIfError: 604800, + staleIfError: true, /** * Allows a stored response that is stale for N seconds to be served * while a background refresh is performed to get the latest value. diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 0965e4bd5..7ddeb9006 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -135,7 +135,7 @@ export interface EdgeConfigClientOptions { * * @default One week */ - staleIfError?: 604800; + staleIfError?: boolean; /** * Configure the threshold (in seconds) for how long the SDK allows stale From e2ea72d7607a7647858b23f11aa5f8e8447e6944 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 10:01:33 +0300 Subject: [PATCH 46/81] wip --- packages/edge-config/src/controller.test.ts | 151 ++++++++++++++++++++ packages/edge-config/src/controller.ts | 107 ++++++++++++-- packages/edge-config/src/utils/index.ts | 4 +- 3 files changed, 247 insertions(+), 15 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index c15250b9e..b30e2f3cc 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -899,3 +899,154 @@ describe('lifecycle: mixing get, has and getAll', () => { expect(fetchMock).toHaveBeenCalledTimes(4); }); }); + +describe('lifecycle: reading multiple items', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('should MISS the cache initially', async () => { + setTimestampOfLatestUpdate(1000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1', key2: 'value2' }), + { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2' }, + digest: 'digest1', + cache: 'MISS', + exists: true, + updatedAt: 1000, + }); + }); + + it('should have performed a blocking fetch to resolve the cache MISS', () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/items?version=1&key=key1&key=key2', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'x-edge-config-min-updated-at': '1000', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + it('should HIT the cache if the timestamp has not changed', async () => { + await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2' }, + digest: 'digest1', + cache: 'HIT', + exists: true, + updatedAt: 1000, + }); + }); + + // it('should not fire off any background refreshes after the cache HIT', () => { + // expect(fetchMock).toHaveBeenCalledTimes(1); + // }); + + // it('should serve a stale value if the timestamp has changed but is within the threshold', async () => { + // setTimestampOfLatestUpdate(7000); + // fetchMock.mockResponseOnce(JSON.stringify('value2'), { + // headers: { + // 'x-edge-config-digest': 'digest2', + // 'x-edge-config-updated-at': '7000', + // etag: '"digest2"', + // 'content-type': 'application/json', + // }, + // }); + + // await expect(controller.get('key1')).resolves.toEqual({ + // value: 'value1', + // digest: 'digest1', + // cache: 'STALE', + // exists: true, + // updatedAt: 1000, + // }); + // }); + + // it('should trigger a background refresh after the STALE value', () => { + // expect(fetchMock).toHaveBeenCalledTimes(2); + // expect(fetchMock).toHaveBeenLastCalledWith( + // 'https://edge-config.vercel.com/item/key1?version=1', + // { + // method: 'GET', + // cache: 'no-store', + // headers: new Headers({ + // Authorization: 'Bearer fake-edge-config-token', + // // 'If-None-Match': '"digest1"', + // 'x-edge-config-min-updated-at': '7000', + // 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + // 'x-edge-config-vercel-env': 'test', + // }), + // }, + // ); + // }); + + // it('should serve the new value from cache after the background refresh completes', async () => { + // await expect(controller.get('key1')).resolves.toEqual({ + // value: 'value2', + // digest: 'digest2', + // cache: 'HIT', + // exists: true, + // updatedAt: 7000, + // }); + // }); + + // it('should not fire off any subsequent background refreshes', () => { + // expect(fetchMock).toHaveBeenCalledTimes(2); + // }); + + // it('should refresh when the stale threshold is exceeded', async () => { + // setTimestampOfLatestUpdate(17001); + // fetchMock.mockResponseOnce(JSON.stringify('value3'), { + // headers: { + // 'x-edge-config-digest': 'digest3', + // 'x-edge-config-updated-at': '17001', + // }, + // }); + + // await expect(controller.get('key1')).resolves.toEqual({ + // value: 'value3', + // digest: 'digest3', + // cache: 'MISS', + // exists: true, + // updatedAt: 17001, + // }); + // }); + + // it('should have done a blocking refresh after the stale threshold was exceeded', () => { + // expect(fetchMock).toHaveBeenCalledTimes(3); + // expect(fetchMock).toHaveBeenLastCalledWith( + // 'https://edge-config.vercel.com/item/key1?version=1', + // { + // method: 'GET', + // cache: 'no-store', + // headers: new Headers({ + // Authorization: 'Bearer fake-edge-config-token', + // // 'If-None-Match': '"digest1"', + // 'x-edge-config-min-updated-at': '17001', + // 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + // 'x-edge-config-vercel-env': 'test', + // }), + // }, + // ); + // }); +}); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 40e632923..1e21f953a 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -9,7 +9,7 @@ import type { CacheStatus, EdgeConfigItems, } from './types'; -import { ERRORS, isEmptyKey, UnexpectedNetworkError } from './utils'; +import { ERRORS, isEmptyKey, pick, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; import { createEnhancedFetch } from './utils/enhanced-fetch'; @@ -438,27 +438,96 @@ export class Controller { }); } - public async getMultiple( - keys: (keyof T)[], + public async getMultiple( + keys: string[], localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T; digest: string }> { + ): Promise<{ + value: T | undefined; + digest: string; + cache: CacheStatus; + exists: boolean; + }> { if (!Array.isArray(keys)) { throw new Error('@vercel/edge-config: keys must be an array'); } + const filteredKeys = keys.filter( + (key) => typeof key === 'string' && !isEmptyKey(key), + ); + // Return early if there are no keys to be read. // This is only possible if the digest is not required, or if we have a // cached digest (not implemented yet). - if (!localOptions?.metadata && keys.length === 0) { - return { value: {} as T, digest: '' }; + if (!localOptions?.metadata && filteredKeys.length === 0) { + return { + value: {} as T, + digest: '', + cache: 'HIT', + exists: false, + }; + } + + const items = filteredKeys.map((key) => this.getCachedItem(key, 'GET')); + const firstItem = items[0]; + + // check if the item cache is consistent and has all the requested items + const canUseItemCache = + firstItem && + items.every( + (item) => + item?.exists && + item.value !== undefined && + // ensure we only use the item cache if all items have the same updatedAt + item.updatedAt === firstItem.updatedAt, + ); + + // if the item cache is consistent and newer than the edge config cache, + // we can use it to serve the request + if ( + canUseItemCache && + (!this.edgeConfigCache || + this.edgeConfigCache.updatedAt < firstItem.updatedAt) && + ['STALE', 'HIT'].includes( + getCacheStatus( + timestampOfLatestUpdate, + firstItem.updatedAt, + this.maxStale, + ), + ) + ) { + return { + value: filteredKeys.reduce>((acc, key, index) => { + const item = items[index]; + acc[key as keyof T] = item?.value as T[keyof T]; + return acc; + }, {}) as T, + digest: firstItem.digest, + cache: 'HIT', + exists: true, + }; } - // TODO getMultiple is not caching yet + // if the edge config cache is filled we can fall back to using it + if ( + this.edgeConfigCache && + ['STALE', 'HIT'].includes( + getCacheStatus( + timestampOfLatestUpdate, + this.edgeConfigCache.updatedAt, + this.maxStale, + ), + ) + ) { + return { + value: pick(this.edgeConfigCache.items, filteredKeys) as T, + digest: this.edgeConfigCache.digest, + cache: 'HIT', + exists: true, + }; + } const search = new URLSearchParams( - keys - .filter((key) => typeof key === 'string' && !isEmptyKey(key)) - .map((key) => ['key', key] as [string, string]), + filteredKeys.map((key) => ['key', key] as [string, string]), ).toString(); return this.enhancedFetch( @@ -467,16 +536,28 @@ export class Controller { headers: this.getHeaders(localOptions, timestampOfLatestUpdate), cache: this.cacheMode, }, - ).then<{ value: T; digest: string }>(async ([res, cachedRes]) => { + ).then<{ + value: T; + digest: string; + cache: CacheStatus; + exists: boolean; + updatedAt: number; + }>(async ([res, cachedRes]) => { + const digest = res.headers.get('x-edge-config-digest'); + const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + + if (!updatedAt || !digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + if (res.ok || (res.status === 304 && cachedRes)) { - const digest = res.headers.get('x-edge-config-digest'); if (!digest) { throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); } const value = (await ( res.status === 304 && cachedRes ? cachedRes : res ).json()) as T; - return { value, digest }; + return { value, digest, updatedAt, cache: 'MISS', exists: true }; } await consumeResponseBody(res); diff --git a/packages/edge-config/src/utils/index.ts b/packages/edge-config/src/utils/index.ts index 0ffad0687..98697cc25 100644 --- a/packages/edge-config/src/utils/index.ts +++ b/packages/edge-config/src/utils/index.ts @@ -26,9 +26,9 @@ export function hasOwn( export function pick(obj: T, keys: K[]): Pick { const ret: Partial = {}; - keys.forEach((key) => { + for (const key of keys) { ret[key] = obj[key]; - }); + } return ret as Pick; } From 220bc43296df37366af331494e3fd1dc7aef4103 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 10:22:00 +0300 Subject: [PATCH 47/81] implement getMultiple --- packages/edge-config/src/controller.test.ts | 186 ++++++++++---------- packages/edge-config/src/controller.ts | 107 +++++++---- packages/edge-config/src/index.ts | 7 +- 3 files changed, 171 insertions(+), 129 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index b30e2f3cc..ae17420c7 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -958,95 +958,99 @@ describe('lifecycle: reading multiple items', () => { }); }); - // it('should not fire off any background refreshes after the cache HIT', () => { - // expect(fetchMock).toHaveBeenCalledTimes(1); - // }); - - // it('should serve a stale value if the timestamp has changed but is within the threshold', async () => { - // setTimestampOfLatestUpdate(7000); - // fetchMock.mockResponseOnce(JSON.stringify('value2'), { - // headers: { - // 'x-edge-config-digest': 'digest2', - // 'x-edge-config-updated-at': '7000', - // etag: '"digest2"', - // 'content-type': 'application/json', - // }, - // }); - - // await expect(controller.get('key1')).resolves.toEqual({ - // value: 'value1', - // digest: 'digest1', - // cache: 'STALE', - // exists: true, - // updatedAt: 1000, - // }); - // }); - - // it('should trigger a background refresh after the STALE value', () => { - // expect(fetchMock).toHaveBeenCalledTimes(2); - // expect(fetchMock).toHaveBeenLastCalledWith( - // 'https://edge-config.vercel.com/item/key1?version=1', - // { - // method: 'GET', - // cache: 'no-store', - // headers: new Headers({ - // Authorization: 'Bearer fake-edge-config-token', - // // 'If-None-Match': '"digest1"', - // 'x-edge-config-min-updated-at': '7000', - // 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', - // 'x-edge-config-vercel-env': 'test', - // }), - // }, - // ); - // }); - - // it('should serve the new value from cache after the background refresh completes', async () => { - // await expect(controller.get('key1')).resolves.toEqual({ - // value: 'value2', - // digest: 'digest2', - // cache: 'HIT', - // exists: true, - // updatedAt: 7000, - // }); - // }); - - // it('should not fire off any subsequent background refreshes', () => { - // expect(fetchMock).toHaveBeenCalledTimes(2); - // }); - - // it('should refresh when the stale threshold is exceeded', async () => { - // setTimestampOfLatestUpdate(17001); - // fetchMock.mockResponseOnce(JSON.stringify('value3'), { - // headers: { - // 'x-edge-config-digest': 'digest3', - // 'x-edge-config-updated-at': '17001', - // }, - // }); - - // await expect(controller.get('key1')).resolves.toEqual({ - // value: 'value3', - // digest: 'digest3', - // cache: 'MISS', - // exists: true, - // updatedAt: 17001, - // }); - // }); - - // it('should have done a blocking refresh after the stale threshold was exceeded', () => { - // expect(fetchMock).toHaveBeenCalledTimes(3); - // expect(fetchMock).toHaveBeenLastCalledWith( - // 'https://edge-config.vercel.com/item/key1?version=1', - // { - // method: 'GET', - // cache: 'no-store', - // headers: new Headers({ - // Authorization: 'Bearer fake-edge-config-token', - // // 'If-None-Match': '"digest1"', - // 'x-edge-config-min-updated-at': '17001', - // 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', - // 'x-edge-config-vercel-env': 'test', - // }), - // }, - // ); - // }); + it('should not fire off any background refreshes after the cache HIT', () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should serve a stale value if the timestamp has changed but is within the threshold', async () => { + setTimestampOfLatestUpdate(7000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'valueA', key2: 'valueB' }), + { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '7000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2' }, + digest: 'digest1', + cache: 'STALE', + exists: true, + updatedAt: 1000, + }); + }); + + it('should trigger a background refresh after the STALE value', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/items?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + // 'If-None-Match': '"digest1"', + 'x-edge-config-min-updated-at': '7000', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + it('should serve the new value from cache after the background refresh completes', async () => { + await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'valueA', key2: 'valueB' }, + digest: 'digest2', + cache: 'HIT', + exists: true, + updatedAt: 7000, + }); + }); + + it('should not fire off any subsequent background refreshes', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should refresh when the stale threshold is exceeded', async () => { + setTimestampOfLatestUpdate(17001); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'valueC', key2: 'valueD' }), + { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '17001', + }, + }, + ); + + await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'valueC', key2: 'valueD' }, + digest: 'digest3', + cache: 'MISS', + exists: true, + updatedAt: 17001, + }); + }); + + it('should have done a blocking refresh after the stale threshold was exceeded', () => { + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/items?version=1&key=key1&key=key2', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + // 'If-None-Match': '"digest1"', + 'x-edge-config-min-updated-at': '17001', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); }); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 1e21f953a..3a48b112d 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -86,6 +86,7 @@ export class Controller { digest: string; cache: CacheStatus; exists: boolean; + updatedAt: number; }> { if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { return this.fetchItem( @@ -141,6 +142,7 @@ export class Controller { digest: string; cache: CacheStatus; exists: boolean; + updatedAt: number; } | null { // only use the cache if we have a timestamp of the latest update if (timestampOfLatestUpdate) { @@ -166,7 +168,7 @@ export class Controller { timestampOfLatestUpdate, localOptions, false, - ).catch(() => null), + ).catch(), ); return { ...cached, cache: 'STALE' }; @@ -442,10 +444,11 @@ export class Controller { keys: string[], localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ - value: T | undefined; + value: T; digest: string; cache: CacheStatus; exists: boolean; + updatedAt: number; }> { if (!Array.isArray(keys)) { throw new Error('@vercel/edge-config: keys must be an array'); @@ -464,6 +467,7 @@ export class Controller { digest: '', cache: 'HIT', exists: false, + updatedAt: -1, }; } @@ -486,44 +490,64 @@ export class Controller { if ( canUseItemCache && (!this.edgeConfigCache || - this.edgeConfigCache.updatedAt < firstItem.updatedAt) && - ['STALE', 'HIT'].includes( - getCacheStatus( - timestampOfLatestUpdate, - firstItem.updatedAt, - this.maxStale, - ), - ) + this.edgeConfigCache.updatedAt < firstItem.updatedAt) ) { - return { - value: filteredKeys.reduce>((acc, key, index) => { - const item = items[index]; - acc[key as keyof T] = item?.value as T[keyof T]; - return acc; - }, {}) as T, - digest: firstItem.digest, - cache: 'HIT', - exists: true, - }; + const cacheStatus = getCacheStatus( + timestampOfLatestUpdate, + firstItem.updatedAt, + this.maxStale, + ); + + if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { + if (cacheStatus === 'STALE') { + // TODO refresh individual items only? + waitUntil( + this.fetchFullConfig(timestampOfLatestUpdate, localOptions).catch(), + ); + } + + return { + value: filteredKeys.reduce>((acc, key, index) => { + const item = items[index]; + acc[key as keyof T] = item?.value as T[keyof T]; + return acc; + }, {}) as T, + digest: firstItem.digest, + cache: cacheStatus, + exists: true, + updatedAt: firstItem.updatedAt, + }; + } } // if the edge config cache is filled we can fall back to using it - if ( - this.edgeConfigCache && - ['STALE', 'HIT'].includes( - getCacheStatus( - timestampOfLatestUpdate, - this.edgeConfigCache.updatedAt, - this.maxStale, - ), - ) - ) { - return { - value: pick(this.edgeConfigCache.items, filteredKeys) as T, - digest: this.edgeConfigCache.digest, - cache: 'HIT', - exists: true, - }; + if (this.edgeConfigCache) { + const cacheStatus = getCacheStatus( + timestampOfLatestUpdate, + this.edgeConfigCache.updatedAt, + this.maxStale, + ); + + if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { + if (cacheStatus === 'STALE') { + // TODO refresh individual items only? + waitUntil( + this.fetchFullConfig(timestampOfLatestUpdate, localOptions).catch(), + ); + } + + return { + value: pick(this.edgeConfigCache.items, filteredKeys) as T, + digest: this.edgeConfigCache.digest, + cache: getCacheStatus( + timestampOfLatestUpdate, + this.edgeConfigCache.updatedAt, + this.maxStale, + ), + exists: true, + updatedAt: this.edgeConfigCache.updatedAt, + }; + } } const search = new URLSearchParams( @@ -557,6 +581,17 @@ export class Controller { const value = (await ( res.status === 304 && cachedRes ? cachedRes : res ).json()) as T; + + // fill the itemCache with the new values + for (const key of filteredKeys) { + this.itemCache.set(key, { + value: value[key as keyof T], + updatedAt, + digest, + exists: value[key as keyof T] !== undefined, + }); + } + return { value, digest, updatedAt, cache: 'MISS', exists: true }; } await consumeResponseBody(res); diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 9d62261cb..663192dd0 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -109,7 +109,7 @@ export const createClient = trace( { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, ) as EdgeConfigClient['has'], getMultiple: trace( - async function getMultiple( + async function getMultiple( keys: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; digest: string } | T> { @@ -120,7 +120,10 @@ export const createClient = trace( ) return {} as T; - const data = await controller.getMultiple(keys, localOptions); + const data = await controller.getMultiple( + keys as string[], + localOptions, + ); return localOptions?.metadata ? data : data.value; }, { From fe09c29b0f44c5467d823bcd48b703102c7583f5 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 10:23:14 +0300 Subject: [PATCH 48/81] add note about missing tests --- packages/edge-config/src/controller.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index ae17420c7..bc44c2cfc 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -900,6 +900,10 @@ describe('lifecycle: mixing get, has and getAll', () => { }); }); +// TODO missing tests for hitting the full edge config cache +// TODO missing tests for when individual items have different updatedAt timestamps +// TODO missing tests for when the edge config cache is stale but the individual items are not +// TODO missing tests for when items are stale but the full cache is not describe('lifecycle: reading multiple items', () => { beforeAll(() => { fetchMock.resetMocks(); From 654a2757c0a32c4b134df7efe9104c069ec59ee8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 19:43:04 +0300 Subject: [PATCH 49/81] more tests --- packages/edge-config/src/controller.test.ts | 75 ++++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index bc44c2cfc..46c7566dc 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -900,11 +900,10 @@ describe('lifecycle: mixing get, has and getAll', () => { }); }); -// TODO missing tests for hitting the full edge config cache // TODO missing tests for when individual items have different updatedAt timestamps // TODO missing tests for when the edge config cache is stale but the individual items are not // TODO missing tests for when items are stale but the full cache is not -describe('lifecycle: reading multiple items', () => { +describe('lifecycle: reading multiple items without full edge config cache', () => { beforeAll(() => { fetchMock.resetMocks(); }); @@ -1058,3 +1057,75 @@ describe('lifecycle: reading multiple items', () => { ); }); }); + +describe('lifecycle: reading multiple items with full edge config cache', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('should MISS the cache initially', async () => { + setTimestampOfLatestUpdate(1000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1', key2: 'value2' }), + { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getMultiple(['key1'])).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2' }, + digest: 'digest1', + cache: 'MISS', + exists: true, + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should load the full edge config', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1', key2: 'value2' }), + { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2' }, + digest: 'digest1', + cache: 'MISS', + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should now be possible to read key2 with a cache HIT', async () => { + await expect(controller.getMultiple(['key2'])).resolves.toEqual({ + value: { key2: 'value2' }, + digest: 'digest1', + cache: 'HIT', + exists: true, + updatedAt: 1000, + }); + }); + + it('should not fire off any background refreshes after the cache HIT', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); From d037f114b897e758f806e2f88179fc6109e5e14e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 2 Aug 2025 19:54:49 +0300 Subject: [PATCH 50/81] add tests --- packages/edge-config/src/controller.test.ts | 179 +++++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 46c7566dc..2452c0df6 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -900,7 +900,6 @@ describe('lifecycle: mixing get, has and getAll', () => { }); }); -// TODO missing tests for when individual items have different updatedAt timestamps // TODO missing tests for when the edge config cache is stale but the individual items are not // TODO missing tests for when items are stale but the full cache is not describe('lifecycle: reading multiple items without full edge config cache', () => { @@ -1129,3 +1128,181 @@ describe('lifecycle: reading multiple items with full edge config cache', () => expect(fetchMock).toHaveBeenCalledTimes(2); }); }); + +describe('lifecycle: reading multiple items with different updatedAt timestamps', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('should MISS the cache initially and populate item cache with different timestamps', async () => { + setTimestampOfLatestUpdate(1000); + fetchMock.mockResponseOnce(JSON.stringify('value1'), { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + cache: 'MISS', + exists: true, + updatedAt: 1000, + }); + + // Fetch key2 with a different timestamp + setTimestampOfLatestUpdate(2000); + fetchMock.mockResponseOnce(JSON.stringify('value2'), { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '2000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.get('key2')).resolves.toEqual({ + value: 'value2', + digest: 'digest2', + cache: 'MISS', + exists: true, + updatedAt: 2000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should fetch from server when getting multiple items with different timestamps', async () => { + setTimestampOfLatestUpdate(3000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1a', key2: 'value2a' }), + { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '3000', + etag: '"digest3"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1a', key2: 'value2a' }, + digest: 'digest3', + cache: 'MISS', + exists: true, + updatedAt: 3000, + }); + + // Should have made a new request because items have different timestamps + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/items?version=1&key=key1&key=key2', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'x-edge-config-min-updated-at': '3000', + 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should update item cache with new unified timestamp after fetching multiple items', async () => { + // Now both items should have the same timestamp (3000) + await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1a', key2: 'value2a' }, + digest: 'digest3', + cache: 'HIT', + exists: true, + updatedAt: 3000, + }); + + // Should use cached items now that they have the same timestamp + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should handle stale items with different timestamps by fetching fresh data', async () => { + setTimestampOfLatestUpdate(15000); // Beyond stale threshold + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'valueA', key2: 'valueB' }), + { + headers: { + 'x-edge-config-digest': 'digest4', + 'x-edge-config-updated-at': '15000', + etag: '"digest4"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'valueA', key2: 'valueB' }, + digest: 'digest4', + cache: 'MISS', + exists: true, + updatedAt: 15000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('should handle partial cache hits when some items have different timestamps', async () => { + // Add a third item with yet another timestamp + setTimestampOfLatestUpdate(18000); + fetchMock.mockResponseOnce(JSON.stringify('value3'), { + headers: { + 'x-edge-config-digest': 'digest5', + 'x-edge-config-updated-at': '4000', + etag: '"digest5"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.get('key3')).resolves.toEqual({ + value: 'value3', + digest: 'digest5', + cache: 'MISS', + exists: true, + updatedAt: 4000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(5); + + // Now key1/key2 have timestamp 15000, key3 has timestamp 18000 + setTimestampOfLatestUpdate(19000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'valueX', key2: 'valueY', key3: 'valueZ' }), + { + headers: { + 'x-edge-config-digest': 'digest6', + 'x-edge-config-updated-at': '16000', + etag: '"digest6"', + 'content-type': 'application/json', + }, + }, + ); + + await expect( + controller.getMultiple(['key1', 'key2', 'key3']), + ).resolves.toEqual({ + value: { key1: 'valueX', key2: 'valueY', key3: 'valueZ' }, + digest: 'digest6', + cache: 'MISS', + exists: true, + updatedAt: 16000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(6); + }); +}); From 4e58e28bdda8181b9b9e4ca472482c332209b83f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 3 Aug 2025 10:24:30 +0300 Subject: [PATCH 51/81] add more tests --- packages/edge-config/src/controller.test.ts | 101 ++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 2452c0df6..9bd7d0cd9 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -1306,3 +1306,104 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' expect(fetchMock).toHaveBeenCalledTimes(6); }); }); + +describe('lifecycle: reading multiple items when edge config cache is stale but individual items are not', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('should fetch the full edge config initially', async () => { + setTimestampOfLatestUpdate(1000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1', key2: 'value2', key3: 'value3' }), + { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2', key3: 'value3' }, + digest: 'digest1', + cache: 'MISS', + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should fetch individual items', async () => { + setTimestampOfLatestUpdate(12000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1a', key2: 'value2a', key3: 'value3a' }), + { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '12000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + await expect( + controller.getMultiple(['key1', 'key2', 'key3']), + ).resolves.toEqual({ + value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, + digest: 'digest1', + cache: 'MISS', + exists: true, + updatedAt: 12000, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should HIT the item cache if the timestamp has not changed', async () => { + await expect( + controller.getMultiple(['key1', 'key2', 'key3']), + ).resolves.toEqual({ + value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, + digest: 'digest1', + cache: 'HIT', + updatedAt: 12000, + exists: true, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should serve STALE values from the item cache if the timestamp has changed but is within the threshold', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1b', key2: 'value2b', key3: 'value3b' }), + { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '13000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + setTimestampOfLatestUpdate(13000); + await expect( + controller.getMultiple(['key1', 'key2', 'key3']), + ).resolves.toEqual({ + value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, + cache: 'STALE', + updatedAt: 12000, + exists: true, + digest: 'digest1', + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); From 503a2fa3e3bda9d9cc047d105709a3ac2b4e42b5 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 3 Aug 2025 10:29:51 +0300 Subject: [PATCH 52/81] do not return exists from getMultiple --- packages/edge-config/src/controller.test.ts | 14 -------------- packages/edge-config/src/controller.ts | 7 +------ 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 9bd7d0cd9..6bf1545a5 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -929,7 +929,6 @@ describe('lifecycle: reading multiple items without full edge config cache', () value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'MISS', - exists: true, updatedAt: 1000, }); }); @@ -955,7 +954,6 @@ describe('lifecycle: reading multiple items without full edge config cache', () value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'HIT', - exists: true, updatedAt: 1000, }); }); @@ -982,7 +980,6 @@ describe('lifecycle: reading multiple items without full edge config cache', () value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'STALE', - exists: true, updatedAt: 1000, }); }); @@ -1009,7 +1006,6 @@ describe('lifecycle: reading multiple items without full edge config cache', () value: { key1: 'valueA', key2: 'valueB' }, digest: 'digest2', cache: 'HIT', - exists: true, updatedAt: 7000, }); }); @@ -1034,7 +1030,6 @@ describe('lifecycle: reading multiple items without full edge config cache', () value: { key1: 'valueC', key2: 'valueD' }, digest: 'digest3', cache: 'MISS', - exists: true, updatedAt: 17001, }); }); @@ -1084,7 +1079,6 @@ describe('lifecycle: reading multiple items with full edge config cache', () => value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'MISS', - exists: true, updatedAt: 1000, }); @@ -1119,7 +1113,6 @@ describe('lifecycle: reading multiple items with full edge config cache', () => value: { key2: 'value2' }, digest: 'digest1', cache: 'HIT', - exists: true, updatedAt: 1000, }); }); @@ -1197,7 +1190,6 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' value: { key1: 'value1a', key2: 'value2a' }, digest: 'digest3', cache: 'MISS', - exists: true, updatedAt: 3000, }); @@ -1224,7 +1216,6 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' value: { key1: 'value1a', key2: 'value2a' }, digest: 'digest3', cache: 'HIT', - exists: true, updatedAt: 3000, }); @@ -1250,7 +1241,6 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' value: { key1: 'valueA', key2: 'valueB' }, digest: 'digest4', cache: 'MISS', - exists: true, updatedAt: 15000, }); @@ -1299,7 +1289,6 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' value: { key1: 'valueX', key2: 'valueY', key3: 'valueZ' }, digest: 'digest6', cache: 'MISS', - exists: true, updatedAt: 16000, }); @@ -1360,7 +1349,6 @@ describe('lifecycle: reading multiple items when edge config cache is stale but value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, digest: 'digest1', cache: 'MISS', - exists: true, updatedAt: 12000, }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -1374,7 +1362,6 @@ describe('lifecycle: reading multiple items when edge config cache is stale but digest: 'digest1', cache: 'HIT', updatedAt: 12000, - exists: true, }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -1400,7 +1387,6 @@ describe('lifecycle: reading multiple items when edge config cache is stale but value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, cache: 'STALE', updatedAt: 12000, - exists: true, digest: 'digest1', }); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 3a48b112d..47c6198e1 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -447,7 +447,6 @@ export class Controller { value: T; digest: string; cache: CacheStatus; - exists: boolean; updatedAt: number; }> { if (!Array.isArray(keys)) { @@ -466,7 +465,6 @@ export class Controller { value: {} as T, digest: '', cache: 'HIT', - exists: false, updatedAt: -1, }; } @@ -514,7 +512,6 @@ export class Controller { }, {}) as T, digest: firstItem.digest, cache: cacheStatus, - exists: true, updatedAt: firstItem.updatedAt, }; } @@ -544,7 +541,6 @@ export class Controller { this.edgeConfigCache.updatedAt, this.maxStale, ), - exists: true, updatedAt: this.edgeConfigCache.updatedAt, }; } @@ -564,7 +560,6 @@ export class Controller { value: T; digest: string; cache: CacheStatus; - exists: boolean; updatedAt: number; }>(async ([res, cachedRes]) => { const digest = res.headers.get('x-edge-config-digest'); @@ -592,7 +587,7 @@ export class Controller { }); } - return { value, digest, updatedAt, cache: 'MISS', exists: true }; + return { value, digest, updatedAt, cache: 'MISS' }; } await consumeResponseBody(res); From a82af8fbfcdce800b280af10ce91ec57e1faf32a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 3 Aug 2025 10:47:47 +0300 Subject: [PATCH 53/81] add edge config cache test --- packages/edge-config/src/controller.test.ts | 144 +++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 6bf1545a5..ce64f950f 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -900,8 +900,6 @@ describe('lifecycle: mixing get, has and getAll', () => { }); }); -// TODO missing tests for when the edge config cache is stale but the individual items are not -// TODO missing tests for when items are stale but the full cache is not describe('lifecycle: reading multiple items without full edge config cache', () => { beforeAll(() => { fetchMock.resetMocks(); @@ -1393,3 +1391,145 @@ describe('lifecycle: reading multiple items when edge config cache is stale but expect(fetchMock).toHaveBeenCalledTimes(3); }); }); + +describe('lifecycle: reading multiple items when the item cache is stale but the edge config cache is not', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('should fetch multiple items', async () => { + setTimestampOfLatestUpdate(1000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1', key2: 'value2' }), + { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2' }, + digest: 'digest1', + cache: 'MISS', + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should fetch the full edge config even if there are fresh items in the item cache', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1', key2: 'value2', key3: 'value3' }), + { + headers: { + 'x-edge-config-digest': 'digest1', + 'x-edge-config-updated-at': '1000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2', key3: 'value3' }, + digest: 'digest1', + cache: 'MISS', + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should HIT the cache if the timestamp has not changed when reading individual items', async () => { + await expect( + controller.getMultiple(['key1', 'key2', 'key3']), + ).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2', key3: 'value3' }, + digest: 'digest1', + cache: 'HIT', + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should HIT the cache if the timestamp has not changed when reading the full config', async () => { + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2', key3: 'value3' }, + digest: 'digest1', + cache: 'HIT', + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should serve STALE values when the edge config changes', async () => { + setTimestampOfLatestUpdate(2000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1b', key2: 'value2b', key3: 'value3b' }), + { + headers: { + 'x-edge-config-digest': 'digestB', + 'x-edge-config-updated-at': '2000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2', key3: 'value3' }, + digest: 'digest1', + cache: 'STALE', + updatedAt: 1000, + }); + + // background refresh + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should hit the cache on subsequent reads', async () => { + await expect(controller.getAll()).resolves.toEqual({ + value: { key1: 'value1b', key2: 'value2b', key3: 'value3b' }, + digest: 'digestB', + cache: 'HIT', + updatedAt: 2000, + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should serve STALE values from the edge config cache', async () => { + setTimestampOfLatestUpdate(3000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1c', key2: 'value2c', key3: 'value3c' }), + { + headers: { + 'x-edge-config-digest': 'digestC', + 'x-edge-config-updated-at': '3000', + etag: '"digestC"', + 'content-type': 'application/json', + }, + }, + ); + + await expect( + controller.getMultiple(['key1', 'key2', 'key3']), + ).resolves.toEqual({ + value: { key1: 'value1b', key2: 'value2b', key3: 'value3b' }, + digest: 'digestB', + cache: 'STALE', + updatedAt: 2000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); From 6a1e6559708237a9511bc9f229524f44c16e50a0 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 3 Aug 2025 19:04:19 +0300 Subject: [PATCH 54/81] add preloading --- packages/edge-config/src/controller.test.ts | 43 +++++- packages/edge-config/src/controller.ts | 123 ++++++++++-------- packages/edge-config/src/types.ts | 1 + .../utils/get-build-container-edge-config.ts | 41 ------ .../edge-config/src/utils/mockable-import.ts | 3 + 5 files changed, 114 insertions(+), 97 deletions(-) delete mode 100644 packages/edge-config/src/utils/get-build-container-edge-config.ts create mode 100644 packages/edge-config/src/utils/mockable-import.ts diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index ce64f950f..a4f6fc37e 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -1,6 +1,13 @@ import fetchMock from 'jest-fetch-mock'; import { Controller, setTimestampOfLatestUpdate } from './controller'; import type { Connection } from './types'; +import { mockableImport } from './utils/mockable-import'; + +jest.mock('./utils/mockable-import', () => ({ + mockableImport: jest.fn(() => { + throw new Error('not implemented'); + }), +})); const connection: Connection = { baseUrl: 'https://edge-config.vercel.com', @@ -523,7 +530,7 @@ describe('bypassing dedupe when the timestamp changes', () => { enableDevelopmentCache: false, }); - it('should only fetch once given the same request', async () => { + it('should fetch twice when the timestamp changes', async () => { setTimestampOfLatestUpdate(1000); const read1 = Promise.withResolvers(); const read2 = Promise.withResolvers(); @@ -1533,3 +1540,37 @@ describe('lifecycle: reading multiple items when the item cache is stale but the expect(fetchMock).toHaveBeenCalledTimes(4); }); }); + +describe('preloading', () => { + beforeAll(() => { + (mockableImport as jest.Mock).mockReset(); + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + it('should use the preloaded value', async () => { + (mockableImport as jest.Mock).mockImplementationOnce(() => { + return Promise.resolve({ + default: { + items: { key1: 'value-preloaded' }, + updatedAt: 1000, + digest: 'digest-preloaded', + }, + }); + }); + + setTimestampOfLatestUpdate(1000); + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value-preloaded', + digest: 'digest-preloaded', + cache: 'HIT', + exists: true, + updatedAt: 1000, + }); + expect(fetchMock).toHaveBeenCalledTimes(0); + expect(mockableImport).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 47c6198e1..e19d6d71e 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -12,6 +12,7 @@ import type { import { ERRORS, isEmptyKey, pick, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; import { createEnhancedFetch } from './utils/enhanced-fetch'; +import { mockableImport } from './utils/mockable-import'; const DEFAULT_STALE_THRESHOLD = 10; // 10 seconds // const DEFAULT_STALE_IF_ERROR = 604800; // one week in seconds @@ -54,14 +55,13 @@ function getCacheStatus( } export class Controller { - private edgeConfigCache: (EmbeddedEdgeConfig & { updatedAt: number }) | null = - null; + private edgeConfigCache: EmbeddedEdgeConfig | null = null; private itemCache = new Map(); private connection: Connection; private maxStale: number; private cacheMode: 'no-store' | 'force-cache'; private enableDevelopmentCache: boolean; - private staleIfError: boolean; + private preloaded: 'init' | 'loading' | 'loaded' = 'init'; // create an instance per controller so the caches are isolated private enhancedFetch: ReturnType; @@ -72,12 +72,35 @@ export class Controller { ) { this.connection = connection; this.maxStale = options.maxStale ?? DEFAULT_STALE_THRESHOLD; - this.staleIfError = options.staleIfError ?? true; this.cacheMode = options.cache || 'no-store'; this.enableDevelopmentCache = options.enableDevelopmentCache; this.enhancedFetch = createEnhancedFetch(); } + private async preload(): Promise { + if (this.preloaded !== 'init') return; + if (this.connection.type !== 'vercel') return; + // the folder won't exist in development, only when deployed + if (process.env.NODE_ENV === 'development') return; + + this.preloaded = 'loading'; + + try { + await mockableImport<{ default: EmbeddedEdgeConfig }>( + `/tmp/edge-config/${this.connection.id}.json`, + ) + .then((mod) => { + this.edgeConfigCache = mod.default; + }) + .catch() + .finally(() => { + this.preloaded = 'loaded'; + }); + } catch { + /* do nothing */ + } + } + public async get( key: string, localOptions?: EdgeConfigFunctionsOptions, @@ -88,26 +111,18 @@ export class Controller { exists: boolean; updatedAt: number; }> { - if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { - return this.fetchItem( - 'GET', - key, - timestampOfLatestUpdate, - localOptions, - true, - ); + // hold a reference to the timestamp to avoid race conditions + const ts = timestampOfLatestUpdate; + if (this.enableDevelopmentCache || !ts) { + return this.fetchItem('GET', key, ts, localOptions, true); } - const cached = this.readCache(key, 'GET', localOptions); + await this.preload(); + + const cached = this.readCache(key, 'GET', ts, localOptions); if (cached) return cached; - return this.fetchItem( - 'GET', - key, - timestampOfLatestUpdate, - localOptions, - true, - ); + return this.fetchItem('GET', key, ts, localOptions, true); } /** @@ -136,6 +151,7 @@ export class Controller { private readCache( key: string, method: 'GET' | 'HEAD', + timestamp: number | undefined, localOptions: EdgeConfigFunctionsOptions | undefined, ): { value: T | undefined; @@ -145,12 +161,12 @@ export class Controller { updatedAt: number; } | null { // only use the cache if we have a timestamp of the latest update - if (timestampOfLatestUpdate) { + if (timestamp) { const cached = this.getCachedItem(key, method); if (cached) { const cacheStatus = getCacheStatus( - timestampOfLatestUpdate, + timestamp, cached.updatedAt, this.maxStale, ); @@ -165,7 +181,7 @@ export class Controller { this.fetchItem( method, key, - timestampOfLatestUpdate, + timestamp, localOptions, false, ).catch(), @@ -399,35 +415,32 @@ export class Controller { key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }> { - if (this.enableDevelopmentCache || !timestampOfLatestUpdate) { - return this.fetchItem( - 'HEAD', - key, - timestampOfLatestUpdate, - localOptions, - true, - ); + const ts = timestampOfLatestUpdate; + if (this.enableDevelopmentCache || !ts) { + return this.fetchItem('HEAD', key, ts, localOptions, true); } - const cached = this.readCache(key, 'HEAD', localOptions); - if (cached) return cached; + await this.preload(); - return this.fetchItem( - 'HEAD', + const cached = this.readCache( key, - timestampOfLatestUpdate, + 'HEAD', + ts, localOptions, - true, ); + if (cached) return cached; + + return this.fetchItem('HEAD', key, ts, localOptions, true); } public async digest( localOptions?: Pick, ): Promise { + const ts = timestampOfLatestUpdate; return fetch( `${this.connection.baseUrl}/digest?version=${this.connection.version}`, { - headers: this.getHeaders(localOptions, timestampOfLatestUpdate), + headers: this.getHeaders(localOptions, ts), cache: this.cacheMode, }, ).then(async (res) => { @@ -449,6 +462,7 @@ export class Controller { cache: CacheStatus; updatedAt: number; }> { + const ts = timestampOfLatestUpdate; if (!Array.isArray(keys)) { throw new Error('@vercel/edge-config: keys must be an array'); } @@ -469,6 +483,8 @@ export class Controller { }; } + await this.preload(); + const items = filteredKeys.map((key) => this.getCachedItem(key, 'GET')); const firstItem = items[0]; @@ -491,7 +507,7 @@ export class Controller { this.edgeConfigCache.updatedAt < firstItem.updatedAt) ) { const cacheStatus = getCacheStatus( - timestampOfLatestUpdate, + ts, firstItem.updatedAt, this.maxStale, ); @@ -499,9 +515,7 @@ export class Controller { if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { if (cacheStatus === 'STALE') { // TODO refresh individual items only? - waitUntil( - this.fetchFullConfig(timestampOfLatestUpdate, localOptions).catch(), - ); + waitUntil(this.fetchFullConfig(ts, localOptions).catch()); } return { @@ -520,7 +534,7 @@ export class Controller { // if the edge config cache is filled we can fall back to using it if (this.edgeConfigCache) { const cacheStatus = getCacheStatus( - timestampOfLatestUpdate, + ts, this.edgeConfigCache.updatedAt, this.maxStale, ); @@ -528,16 +542,14 @@ export class Controller { if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { if (cacheStatus === 'STALE') { // TODO refresh individual items only? - waitUntil( - this.fetchFullConfig(timestampOfLatestUpdate, localOptions).catch(), - ); + waitUntil(this.fetchFullConfig(ts, localOptions).catch()); } return { value: pick(this.edgeConfigCache.items, filteredKeys) as T, digest: this.edgeConfigCache.digest, cache: getCacheStatus( - timestampOfLatestUpdate, + ts, this.edgeConfigCache.updatedAt, this.maxStale, ), @@ -553,7 +565,7 @@ export class Controller { return this.enhancedFetch( `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, { - headers: this.getHeaders(localOptions, timestampOfLatestUpdate), + headers: this.getHeaders(localOptions, ts), cache: this.cacheMode, }, ).then<{ @@ -609,10 +621,13 @@ export class Controller { cache: CacheStatus; updatedAt: number; }> { - // if we have the items and they - if (timestampOfLatestUpdate && this.edgeConfigCache) { + const ts = timestampOfLatestUpdate; + // TODO development mode? + await this.preload(); + + if (ts && this.edgeConfigCache) { const cacheStatus = getCacheStatus( - timestampOfLatestUpdate, + ts, this.edgeConfigCache.updatedAt, this.maxStale, ); @@ -629,9 +644,7 @@ export class Controller { if (cacheStatus === 'STALE') { // background refresh - waitUntil( - this.fetchFullConfig(timestampOfLatestUpdate, localOptions).catch(), - ); + waitUntil(this.fetchFullConfig(ts, localOptions).catch()); return { value: this.edgeConfigCache.items as T, digest: this.edgeConfigCache.digest, @@ -641,7 +654,7 @@ export class Controller { } } - return this.fetchFullConfig(timestampOfLatestUpdate, localOptions); + return this.fetchFullConfig(ts, localOptions); } private getHeaders( diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 7ddeb9006..3650c5e09 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -1,6 +1,7 @@ export interface EmbeddedEdgeConfig { digest: string; items: Record; + updatedAt: number; } /** diff --git a/packages/edge-config/src/utils/get-build-container-edge-config.ts b/packages/edge-config/src/utils/get-build-container-edge-config.ts deleted file mode 100644 index e1d2da01a..000000000 --- a/packages/edge-config/src/utils/get-build-container-edge-config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Connection, EmbeddedEdgeConfig } from '../types'; -import { trace } from './tracing'; - -/** - * Reads an Edge Config from the local file system using an async import. - * This is used at runtime on serverless functions. - */ -export const getBuildContainerEdgeConfig = trace( - async function getBuildContainerEdgeConfig( - connection: Connection, - ): Promise { - // can't optimize non-vercel hosted edge configs - if (connection.type !== 'vercel') return null; - - // the folder won't exist in development, only when deployed - if (process.env.NODE_ENV === 'development') return null; - - /** - * Check if running in Vercel build environment - */ - const isVercelBuild = - process.env.VERCEL === '1' && - process.env.CI === '1' && - !process.env.VERCEL_URL; // VERCEL_URL is only available at runtime - - // can only be used during builds - if (!isVercelBuild) return null; - - try { - const edgeConfig = (await import( - /* webpackIgnore: true */ `/tmp/edge-config/${connection.id}.json` - )) as { default: EmbeddedEdgeConfig }; - return edgeConfig.default; - } catch { - return null; - } - }, - { - name: 'getBuildContainerEdgeConfig', - }, -); diff --git a/packages/edge-config/src/utils/mockable-import.ts b/packages/edge-config/src/utils/mockable-import.ts new file mode 100644 index 000000000..936bd217f --- /dev/null +++ b/packages/edge-config/src/utils/mockable-import.ts @@ -0,0 +1,3 @@ +export function mockableImport(path: string): Promise { + return import(path) as Promise; +} From d80061a1e954da3848f838702b4c1f5f72e5dfab Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 3 Aug 2025 19:08:32 +0300 Subject: [PATCH 55/81] imporve cli --- packages/edge-config/src/cli.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index 01d93419a..137dd914b 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -1,5 +1,12 @@ #!/usr/bin/env node +/* + * Reads all connected Edge Configs and emits them to /tmp/edge-config/$id.json + * + * 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, mkdir } from 'node:fs/promises'; import type { Connection, EmbeddedEdgeConfig } from './types'; import { parseConnectionString } from './utils'; @@ -23,17 +30,24 @@ async function main(): Promise { await Promise.all( connections.map(async (connection) => { - const data = await fetch(connection.baseUrl, { + const { data, updatedAt } = await fetch(connection.baseUrl, { headers: { authorization: `Bearer ${connection.token}`, // consistentRead 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, }, - }).then((res) => res.json() as Promise); + }).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, + }; + }); await writeFile( `/tmp/edge-config/${connection.id}.json`, - JSON.stringify(data), + JSON.stringify({ ...data, updatedAt }), ); }), ); From d650bf3bbed0d68f18c3e4d5338a1641bdce5a84 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 4 Aug 2025 12:00:50 +0300 Subject: [PATCH 56/81] rename methods --- packages/edge-config/src/controller.test.ts | 70 +++++++++------------ packages/edge-config/src/controller.ts | 4 +- packages/edge-config/src/index.node.test.ts | 20 +++--- packages/edge-config/src/index.ts | 40 +++++------- packages/edge-config/src/types.ts | 4 +- 5 files changed, 60 insertions(+), 78 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index a4f6fc37e..1abc1b1f0 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -187,7 +187,7 @@ describe('lifecycle: reading the full config', () => { }, }); - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1' }, digest: 'digest1', cache: 'MISS', @@ -212,7 +212,7 @@ describe('lifecycle: reading the full config', () => { }); it('should HIT the cache if the timestamp has not changed', async () => { - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1' }, digest: 'digest1', cache: 'HIT', @@ -235,7 +235,7 @@ describe('lifecycle: reading the full config', () => { }, }); - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1' }, digest: 'digest1', cache: 'STALE', @@ -261,7 +261,7 @@ describe('lifecycle: reading the full config', () => { }); it('should serve the new value from cache after the background refresh completes', async () => { - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value2' }, digest: 'digest2', cache: 'HIT', @@ -282,7 +282,7 @@ describe('lifecycle: reading the full config', () => { }, }); - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value3' }, digest: 'digest3', cache: 'MISS', @@ -784,7 +784,7 @@ describe('development cache: has', () => { }); }); -describe('lifecycle: mixing get, has and getAll', () => { +describe('lifecycle: mixing get, has and all', () => { beforeAll(() => { fetchMock.resetMocks(); }); @@ -930,7 +930,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () }, ); - await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'MISS', @@ -955,7 +955,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () }); it('should HIT the cache if the timestamp has not changed', async () => { - await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'HIT', @@ -981,7 +981,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () }, ); - await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'STALE', @@ -1007,7 +1007,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () }); it('should serve the new value from cache after the background refresh completes', async () => { - await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'valueA', key2: 'valueB' }, digest: 'digest2', cache: 'HIT', @@ -1031,7 +1031,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () }, ); - await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'valueC', key2: 'valueD' }, digest: 'digest3', cache: 'MISS', @@ -1080,7 +1080,7 @@ describe('lifecycle: reading multiple items with full edge config cache', () => }, ); - await expect(controller.getMultiple(['key1'])).resolves.toEqual({ + await expect(controller.mget(['key1'])).resolves.toEqual({ value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'MISS', @@ -1103,7 +1103,7 @@ describe('lifecycle: reading multiple items with full edge config cache', () => }, ); - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'MISS', @@ -1114,7 +1114,7 @@ describe('lifecycle: reading multiple items with full edge config cache', () => }); it('should now be possible to read key2 with a cache HIT', async () => { - await expect(controller.getMultiple(['key2'])).resolves.toEqual({ + await expect(controller.mget(['key2'])).resolves.toEqual({ value: { key2: 'value2' }, digest: 'digest1', cache: 'HIT', @@ -1191,7 +1191,7 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' }, ); - await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'value1a', key2: 'value2a' }, digest: 'digest3', cache: 'MISS', @@ -1217,7 +1217,7 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' it('should update item cache with new unified timestamp after fetching multiple items', async () => { // Now both items should have the same timestamp (3000) - await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'value1a', key2: 'value2a' }, digest: 'digest3', cache: 'HIT', @@ -1242,7 +1242,7 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' }, ); - await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'valueA', key2: 'valueB' }, digest: 'digest4', cache: 'MISS', @@ -1288,9 +1288,7 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' }, ); - await expect( - controller.getMultiple(['key1', 'key2', 'key3']), - ).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'valueX', key2: 'valueY', key3: 'valueZ' }, digest: 'digest6', cache: 'MISS', @@ -1324,7 +1322,7 @@ describe('lifecycle: reading multiple items when edge config cache is stale but }, ); - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1', key2: 'value2', key3: 'value3' }, digest: 'digest1', cache: 'MISS', @@ -1348,9 +1346,7 @@ describe('lifecycle: reading multiple items when edge config cache is stale but }, ); - await expect( - controller.getMultiple(['key1', 'key2', 'key3']), - ).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, digest: 'digest1', cache: 'MISS', @@ -1360,9 +1356,7 @@ describe('lifecycle: reading multiple items when edge config cache is stale but }); it('should HIT the item cache if the timestamp has not changed', async () => { - await expect( - controller.getMultiple(['key1', 'key2', 'key3']), - ).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, digest: 'digest1', cache: 'HIT', @@ -1386,9 +1380,7 @@ describe('lifecycle: reading multiple items when edge config cache is stale but ); setTimestampOfLatestUpdate(13000); - await expect( - controller.getMultiple(['key1', 'key2', 'key3']), - ).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, cache: 'STALE', updatedAt: 12000, @@ -1422,7 +1414,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }, ); - await expect(controller.getMultiple(['key1', 'key2'])).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', cache: 'MISS', @@ -1445,7 +1437,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }, ); - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1', key2: 'value2', key3: 'value3' }, digest: 'digest1', cache: 'MISS', @@ -1456,9 +1448,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }); it('should HIT the cache if the timestamp has not changed when reading individual items', async () => { - await expect( - controller.getMultiple(['key1', 'key2', 'key3']), - ).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'value1', key2: 'value2', key3: 'value3' }, digest: 'digest1', cache: 'HIT', @@ -1469,7 +1459,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }); it('should HIT the cache if the timestamp has not changed when reading the full config', async () => { - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1', key2: 'value2', key3: 'value3' }, digest: 'digest1', cache: 'HIT', @@ -1493,7 +1483,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }, ); - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1', key2: 'value2', key3: 'value3' }, digest: 'digest1', cache: 'STALE', @@ -1505,7 +1495,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }); it('should hit the cache on subsequent reads', async () => { - await expect(controller.getAll()).resolves.toEqual({ + await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1b', key2: 'value2b', key3: 'value3b' }, digest: 'digestB', cache: 'HIT', @@ -1528,9 +1518,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }, ); - await expect( - controller.getMultiple(['key1', 'key2', 'key3']), - ).resolves.toEqual({ + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'value1b', key2: 'value2b', key3: 'value3b' }, digest: 'digestB', cache: 'STALE', diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index e19d6d71e..4a6c966fa 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -453,7 +453,7 @@ export class Controller { }); } - public async getMultiple( + public async mget( keys: string[], localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ @@ -613,7 +613,7 @@ export class Controller { }); } - public async getAll( + public async all( localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; diff --git a/packages/edge-config/src/index.node.test.ts b/packages/edge-config/src/index.node.test.ts index 7796a19b5..37dd60bde 100644 --- a/packages/edge-config/src/index.node.test.ts +++ b/packages/edge-config/src/index.node.test.ts @@ -1,6 +1,6 @@ import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; -import { get, has, getAll, getMultiple } from './index'; +import { get, has, all, mget } from './index'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; @@ -188,7 +188,7 @@ describe('default Edge Config', () => { }); }); - describe('getAll()', () => { + describe('all()', () => { it('should return all items', async () => { fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' }), { headers: { @@ -198,7 +198,7 @@ describe('default Edge Config', () => { }, }); - await expect(getAll()).resolves.toEqual({ foo: 'foo1' }); + await expect(all()).resolves.toEqual({ foo: 'foo1' }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { @@ -212,7 +212,7 @@ describe('default Edge Config', () => { }); }); - describe('getMultiple(keys)', () => { + describe('mget(keys)', () => { describe('when called with keys', () => { it('should return the selected items', async () => { fetchMock.mockResponse(JSON.stringify({ foo: 'foo1', bar: 'bar1' }), { @@ -223,7 +223,7 @@ describe('default Edge Config', () => { }, }); - await expect(getMultiple(['foo', 'bar'])).resolves.toEqual({ + await expect(mget(['foo', 'bar'])).resolves.toEqual({ foo: 'foo1', bar: 'bar1', }); @@ -245,7 +245,7 @@ describe('default Edge Config', () => { describe('when called with an empty string key', () => { it('should return the selected items', async () => { - await expect(getMultiple([''])).resolves.toEqual({}); + await expect(mget([''])).resolves.toEqual({}); expect(fetchMock).toHaveBeenCalledTimes(0); }); }); @@ -259,7 +259,7 @@ describe('default Edge Config', () => { 'content-type': 'application/json', }, }); - await expect(getMultiple(['foo', ''])).resolves.toEqual({ + await expect(mget(['foo', ''])).resolves.toEqual({ foo: 'foo1', }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -278,7 +278,7 @@ describe('default Edge Config', () => { { status: 404, headers: { 'content-type': 'application/json' } }, ); - await expect(getMultiple(['foo', 'bar'])).rejects.toThrow( + await expect(mget(['foo', 'bar'])).rejects.toThrow( '@vercel/edge-config: Edge Config not found', ); @@ -301,7 +301,7 @@ describe('default Edge Config', () => { it('should throw a Network error', async () => { fetchMock.mockReject(new Error('Unexpected fetch error')); - await expect(getAll()).rejects.toThrow('Unexpected fetch error'); + await expect(all()).rejects.toThrow('Unexpected fetch error'); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { @@ -319,7 +319,7 @@ describe('default Edge Config', () => { it('should throw a Unexpected error on 500', async () => { fetchMock.mockResponse('', { status: 500 }); - await expect(getAll()).rejects.toThrow( + await expect(all()).rejects.toThrow( '@vercel/edge-config: Unexpected error due to response with status code 500', ); diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 663192dd0..89141aab9 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -75,10 +75,7 @@ export const createClient = trace( const edgeConfigId = connection.id; - const methods: Pick< - EdgeConfigClient, - 'get' | 'has' | 'getMultiple' | 'getAll' - > = { + const methods: Pick = { get: trace( async function get( key: string, @@ -108,8 +105,8 @@ export const createClient = trace( }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, ) as EdgeConfigClient['has'], - getMultiple: trace( - async function getMultiple( + mget: trace( + async function mget( keys: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; digest: string } | T> { @@ -120,26 +117,23 @@ export const createClient = trace( ) return {} as T; - const data = await controller.getMultiple( - keys as string[], - localOptions, - ); + const data = await controller.mget(keys as string[], localOptions); return localOptions?.metadata ? data : data.value; }, { - name: 'getMultiple', + name: 'mget', isVerboseTrace: false, attributes: { edgeConfigId }, }, ), - getAll: trace( - async function getAll( + all: trace( + async function all( localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; digest: string } | T> { - const data = await controller.getAll(localOptions); + const data = await controller.all(localOptions); return localOptions?.metadata ? data : data.value; }, - { name: 'getAll', isVerboseTrace: false, attributes: { edgeConfigId } }, + { name: 'all', isVerboseTrace: false, attributes: { edgeConfigId } }, ), }; @@ -180,28 +174,28 @@ export const get: EdgeConfigClient['get'] = (...args) => { * Reads all items from the default Edge Config. * * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getAll()`. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).all()`. * - * @see {@link EdgeConfigClient.getAll} + * @see {@link EdgeConfigClient.all} */ -export const getAll: EdgeConfigClient['getAll'] = (...args) => { +export const all: EdgeConfigClient['all'] = (...args) => { init(); - return defaultEdgeConfigClient.getAll(...args); + return defaultEdgeConfigClient.all(...args); }; /** * Reads multiple items from the default Edge Config. * * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getMultiple()`. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).mget()`. * - * @see {@link EdgeConfigClient.getMultiple} + * @see {@link EdgeConfigClient.mget} * @param keys - the keys to read * @returns the values stored under the given keys, or undefined */ -export const getMultiple: EdgeConfigClient['getMultiple'] = (...args) => { +export const mget: EdgeConfigClient['mget'] = (...args) => { init(); - return defaultEdgeConfigClient.getMultiple(...args); + return defaultEdgeConfigClient.mget(...args); }; /** diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 3650c5e09..10af45b54 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -57,7 +57,7 @@ export interface EdgeConfigClient { * @param keys - the keys to read * @returns Returns entries matching the given keys. */ - getMultiple: { + mget: { ( keys: (keyof T)[], options: EdgeConfigFunctionsOptions & { metadata: true }, @@ -75,7 +75,7 @@ export interface EdgeConfigClient { * * @returns Returns all entries. */ - getAll: { + all: { ( options: EdgeConfigFunctionsOptions & { metadata: true }, ): Promise<{ value: T; digest: string }>; From 82e0e8f53641db83d818a5d2dd08506c3845336d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 4 Aug 2025 13:56:10 +0300 Subject: [PATCH 57/81] deal with proxy not sending headers for 304s# --- packages/edge-config/src/controller.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 4a6c966fa..a15b4fe4a 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -257,8 +257,12 @@ export class Controller { }, ).then<{ value: T; digest: string; cache: CacheStatus; updatedAt: number }>( async ([res, cachedRes]) => { - const digest = res.headers.get('x-edge-config-digest'); - const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + // on 304s we currently don't get the cached headers back from proxy, + // so we need to check the original response headers + const digest = (cachedRes ?? res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes ?? res).headers.get('x-edge-config-updated-at'), + ); if (res.status === 500) throw new UnexpectedNetworkError(res); @@ -320,8 +324,12 @@ export class Controller { exists: boolean; updatedAt: number; }>(async ([res, cachedRes]) => { - const digest = res.headers.get('x-edge-config-digest'); - const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + // on 304s we currently don't get the cached headers back from proxy, + // so we need to check the original response headers + const digest = (cachedRes || res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes || res).headers.get('x-edge-config-updated-at'), + ); if ( res.status === 500 || @@ -574,8 +582,12 @@ export class Controller { cache: CacheStatus; updatedAt: number; }>(async ([res, cachedRes]) => { - const digest = res.headers.get('x-edge-config-digest'); - const updatedAt = parseTs(res.headers.get('x-edge-config-updated-at')); + // on 304s we currently don't get the cached headers back from proxy, + // so we need to check the original response headers + const digest = (cachedRes || res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes || res).headers.get('x-edge-config-updated-at'), + ); if (!updatedAt || !digest) { throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); From 87b7b14f44532be6d1380ca302f90aa115fcbc2a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 4 Aug 2025 14:18:56 +0300 Subject: [PATCH 58/81] use the real getUpdatedAt --- packages/edge-config/src/controller.test.ts | 19 ++++++++- packages/edge-config/src/controller.ts | 43 +++++++++++++-------- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 1abc1b1f0..fe7599955 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -1,5 +1,5 @@ import fetchMock from 'jest-fetch-mock'; -import { Controller, setTimestampOfLatestUpdate } from './controller'; +import { Controller } from './controller'; import type { Connection } from './types'; import { mockableImport } from './utils/mockable-import'; @@ -17,6 +17,23 @@ const connection: Connection = { type: 'vercel', }; +// Helper function to mock the privateEdgeConfigSymbol in globalThis +function setTimestampOfLatestUpdate( + timestamp: number | null | undefined, +): void { + const privateEdgeConfigSymbol = Symbol.for('privateEdgeConfig'); + + if (timestamp === null || timestamp === undefined) { + Reflect.set(globalThis, privateEdgeConfigSymbol, { + getUpdatedAt: (_id: string) => null, + }); + } else { + Reflect.set(globalThis, privateEdgeConfigSymbol, { + getUpdatedAt: (_id: string) => timestamp, + }); + } +} + // the "it" tests in the lifecycle are run sequentially, so their order matters describe('lifecycle: reading a single item', () => { beforeAll(() => { diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index a15b4fe4a..00d73a77c 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -17,7 +17,22 @@ import { mockableImport } from './utils/mockable-import'; const DEFAULT_STALE_THRESHOLD = 10; // 10 seconds // const DEFAULT_STALE_IF_ERROR = 604800; // one week in seconds -let timestampOfLatestUpdate: number | undefined; +const privateEdgeConfigSymbol = Symbol.for('privateEdgeConfig'); + +/** + * Will return an embedded Edge Config object from memory, + * but only when the `privateEdgeConfigSymbol` is in global scope. + */ +function getUpdatedAt(connection: Connection): number | null { + const privateEdgeConfig = Reflect.get(globalThis, privateEdgeConfigSymbol) as + | { getUpdatedAt: (id: string) => number | null } + | undefined; + + return typeof privateEdgeConfig === 'object' && + typeof privateEdgeConfig.getUpdatedAt === 'function' + ? privateEdgeConfig.getUpdatedAt(connection.id) + : null; +} /** * Unified cache entry that stores both the value and existence information @@ -29,12 +44,6 @@ interface CacheEntry { exists: boolean; } -export function setTimestampOfLatestUpdate( - timestamp: number | undefined, -): void { - timestampOfLatestUpdate = timestamp; -} - function parseTs(updatedAt: string | null): number | null { if (!updatedAt) return null; const parsed = Number.parseInt(updatedAt, 10); @@ -43,11 +52,11 @@ function parseTs(updatedAt: string | null): number | null { } function getCacheStatus( - latestUpdate: number | undefined, + latestUpdate: number | null, updatedAt: number, maxStale: number, ): CacheStatus { - if (latestUpdate === undefined) return 'MISS'; + if (latestUpdate === null) return 'MISS'; if (latestUpdate <= updatedAt) return 'HIT'; // check if it is within the threshold if (updatedAt >= latestUpdate - maxStale * 1000) return 'STALE'; @@ -112,7 +121,7 @@ export class Controller { updatedAt: number; }> { // hold a reference to the timestamp to avoid race conditions - const ts = timestampOfLatestUpdate; + const ts = getUpdatedAt(this.connection); if (this.enableDevelopmentCache || !ts) { return this.fetchItem('GET', key, ts, localOptions, true); } @@ -241,7 +250,7 @@ export class Controller { } private async fetchFullConfig>( - minUpdatedAt: number | undefined, + minUpdatedAt: number | null, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; @@ -300,7 +309,7 @@ export class Controller { private async fetchItem( method: 'GET' | 'HEAD', key: string, - minUpdatedAt: number | undefined, + minUpdatedAt: number | null, localOptions?: EdgeConfigFunctionsOptions, staleIfError?: boolean, ): Promise<{ @@ -423,7 +432,7 @@ export class Controller { key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }> { - const ts = timestampOfLatestUpdate; + const ts = getUpdatedAt(this.connection); if (this.enableDevelopmentCache || !ts) { return this.fetchItem('HEAD', key, ts, localOptions, true); } @@ -444,7 +453,7 @@ export class Controller { public async digest( localOptions?: Pick, ): Promise { - const ts = timestampOfLatestUpdate; + const ts = getUpdatedAt(this.connection); return fetch( `${this.connection.baseUrl}/digest?version=${this.connection.version}`, { @@ -470,7 +479,7 @@ export class Controller { cache: CacheStatus; updatedAt: number; }> { - const ts = timestampOfLatestUpdate; + const ts = getUpdatedAt(this.connection); if (!Array.isArray(keys)) { throw new Error('@vercel/edge-config: keys must be an array'); } @@ -633,7 +642,7 @@ export class Controller { cache: CacheStatus; updatedAt: number; }> { - const ts = timestampOfLatestUpdate; + const ts = getUpdatedAt(this.connection); // TODO development mode? await this.preload(); @@ -671,7 +680,7 @@ export class Controller { private getHeaders( localOptions: EdgeConfigFunctionsOptions | undefined, - minUpdatedAt: number | undefined, + minUpdatedAt: number | null, ): Headers { const headers: Record = { Authorization: `Bearer ${this.connection.token}`, From a6387d2470fd57f3a00c6e14d3338b369c94337a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 4 Aug 2025 14:48:43 +0300 Subject: [PATCH 59/81] upgrade tests --- packages/edge-config/src/controller.ts | 189 ++++++++++-------- packages/edge-config/src/index.common.test.ts | 50 ++--- packages/edge-config/src/index.node.test.ts | 8 +- 3 files changed, 123 insertions(+), 124 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 00d73a77c..5f5b576b7 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -326,106 +326,127 @@ export class Controller { headers: this.getHeaders(localOptions, minUpdatedAt), cache: this.cacheMode, }, - ).then<{ - value: T | undefined; - digest: string; - cache: CacheStatus; - exists: boolean; - updatedAt: number; - }>(async ([res, cachedRes]) => { - // on 304s we currently don't get the cached headers back from proxy, - // so we need to check the original response headers - const digest = (cachedRes || res).headers.get('x-edge-config-digest'); - const updatedAt = parseTs( - (cachedRes || res).headers.get('x-edge-config-updated-at'), - ); - - if ( - res.status === 500 || - res.status === 502 || - res.status === 503 || - res.status === 504 - ) { - if (staleIfError) { - const cached = this.getCachedItem(key, method); - if (cached) return { ...cached, cache: 'STALE' }; - } - - throw new UnexpectedNetworkError(res); + ).then< + { + value: T | undefined; + digest: string; + cache: CacheStatus; + exists: boolean; + updatedAt: number; + }, + { + value: T | undefined; + digest: string; + cache: CacheStatus; + exists: boolean; + updatedAt: number; } + >( + async ([res, cachedRes]) => { + // on 304s we currently don't get the cached headers back from proxy, + // so we need to check the original response headers + const digest = (cachedRes || res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes || res).headers.get('x-edge-config-updated-at'), + ); - if (!digest || !updatedAt) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + if ( + res.status === 500 || + res.status === 502 || + res.status === 503 || + res.status === 504 + ) { + if (staleIfError) { + const cached = this.getCachedItem(key, method); + if (cached) return { ...cached, cache: 'STALE' }; + } - if (res.ok || (res.status === 304 && cachedRes)) { - // avoid undici memory leaks by consuming response bodies - if (method === 'HEAD') { - waitUntil( - Promise.all([ - consumeResponseBody(res), - cachedRes ? consumeResponseBody(cachedRes) : null, - ]), - ); - } else if (res.status === 304) { - waitUntil(consumeResponseBody(res)); + throw new UnexpectedNetworkError(res); } - let value: T | undefined; - if (method === 'GET') { - value = (await ( - res.status === 304 && cachedRes ? cachedRes : res - ).json()) as T; - } + if (!digest || !updatedAt) + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - if (updatedAt) { - const existing = this.itemCache.get(key); - if (!existing || existing.updatedAt < updatedAt) { - this.itemCache.set(key, { - value, - updatedAt, - digest, - exists: - method === 'GET' ? value !== undefined : res.status !== 404, - }); + if (res.ok || (res.status === 304 && cachedRes)) { + // avoid undici memory leaks by consuming response bodies + if (method === 'HEAD') { + waitUntil( + Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]), + ); + } else if (res.status === 304) { + waitUntil(consumeResponseBody(res)); } - } - return { - value, - digest, - cache: 'MISS', - exists: res.status !== 404, - updatedAt, - }; - } - await Promise.all([ - consumeResponseBody(res), - cachedRes ? consumeResponseBody(cachedRes) : null, - ]); + let value: T | undefined; + if (method === 'GET') { + value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as T; + } - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - if (res.status === 404) { - if (digest && updatedAt) { - const existing = this.itemCache.get(key); - if (!existing || existing.updatedAt < updatedAt) { - this.itemCache.set(key, { - value: undefined, - updatedAt, - digest, - exists: false, - }); + if (updatedAt) { + const existing = this.itemCache.get(key); + if (!existing || existing.updatedAt < updatedAt) { + this.itemCache.set(key, { + value, + updatedAt, + digest, + exists: + method === 'GET' ? value !== undefined : res.status !== 404, + }); + } } return { - value: undefined, + value, digest, cache: 'MISS', - exists: false, + exists: res.status !== 404, updatedAt, }; } - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - throw new UnexpectedNetworkError(res); - }); + + await Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + if (res.status === 404) { + if (digest && updatedAt) { + const existing = this.itemCache.get(key); + if (!existing || existing.updatedAt < updatedAt) { + this.itemCache.set(key, { + value: undefined, + updatedAt, + digest, + exists: false, + }); + } + return { + value: undefined, + digest, + cache: 'MISS', + exists: false, + updatedAt, + }; + } + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + throw new UnexpectedNetworkError(res); + }, + (reason) => { + // catch when the fetch call itself throws an error, and handle similar + // to receiving a 5xx response + if (staleIfError) { + const cached = this.getCachedItem(key, method); + if (cached) return Promise.resolve({ ...cached, cache: 'STALE' }); + } + throw reason; + }, + ); } public async has( diff --git a/packages/edge-config/src/index.common.test.ts b/packages/edge-config/src/index.common.test.ts index 473087ab6..df37a3809 100644 --- a/packages/edge-config/src/index.common.test.ts +++ b/packages/edge-config/src/index.common.test.ts @@ -279,20 +279,20 @@ describe('stale-if-error semantics', () => { }, }); - await expect(edgeConfig.get('foo')).resolves.toEqual('bar'); + await expect(edgeConfig.get('foo4934')).resolves.toEqual('bar'); // pretend there was a network error which led to fetch throwing fetchMock.mockAbortOnce(); // second call should reuse earlier response - await expect(edgeConfig.get('foo')).resolves.toEqual('bar'); + await expect(edgeConfig.get('foo4934')).resolves.toEqual('bar'); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledWith( - `${modifiedBaseUrl}/item/foo?version=1`, + `${modifiedBaseUrl}/item/foo4934?version=1`, { + method: 'GET', headers: new Headers({ - method: 'GET', Authorization: 'Bearer token-2', 'x-edge-config-vercel-env': 'test', 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, @@ -301,8 +301,9 @@ describe('stale-if-error semantics', () => { }, ); expect(fetchMock).toHaveBeenCalledWith( - `${modifiedBaseUrl}/item/foo?version=1`, + `${modifiedBaseUrl}/item/foo4934?version=1`, { + method: 'GET', headers: new Headers({ Authorization: 'Bearer token-2', 'x-edge-config-vercel-env': 'test', @@ -346,7 +347,13 @@ describe('connectionStrings', () => { describe('get', () => { describe('when item exists', () => { it('should fetch using information from the passed token', async () => { - fetchMock.mockResponse(JSON.stringify('bar')); + fetchMock.mockResponse(JSON.stringify('bar'), { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); await expect(edgeConfig.get('foo')).resolves.toEqual('bar'); @@ -354,11 +361,11 @@ describe('connectionStrings', () => { expect(fetchMock).toHaveBeenCalledWith( 'https://example.com/ecfg-2/item/foo?version=1', { + method: 'GET', 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', }, @@ -368,32 +375,3 @@ describe('connectionStrings', () => { }); }); }); - -describe('in-memory cache with swr behaviour', () => { - const originalEnv = process.env.NODE_ENV; - - beforeAll(() => { - process.env.NODE_ENV = 'development'; - }); - - afterAll(() => { - process.env.NODE_ENV = originalEnv; - }); - - it('use in-memory cache', async () => { - fetchMock.mockResponse(JSON.stringify({ foo: 'bar' })); - - const edgeConfig = pkg.createClient( - 'https://edge-config.vercel.com/ecfg-2?token=token-2', - ); - expect(await edgeConfig.get('foo')).toBe('bar'); - - fetchMock.mockResponse(JSON.stringify({ foo: 'bar2' })); - expect(await edgeConfig.get('foo')).toBe('bar'); // 1st call goes to the cache - - await new Promise((res) => { - setTimeout(res, 100); - }); - expect(await edgeConfig.get('foo')).toBe('bar2'); // 2nd call is the updated one - }); -}); diff --git a/packages/edge-config/src/index.node.test.ts b/packages/edge-config/src/index.node.test.ts index 37dd60bde..4ee6cddd9 100644 --- a/packages/edge-config/src/index.node.test.ts +++ b/packages/edge-config/src/index.node.test.ts @@ -145,11 +145,11 @@ describe('default Edge Config', () => { it('should throw a Network error', async () => { fetchMock.mockReject(new Error('Unexpected fetch error')); - await expect(get('foo')).rejects.toThrow('Unexpected fetch error'); + await expect(get('foo256')).rejects.toThrow('Unexpected fetch error'); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - `${baseUrl}/item/foo?version=1`, + `${baseUrl}/item/foo256?version=1`, { method: 'GET', headers: new Headers({ @@ -167,13 +167,13 @@ describe('default Edge Config', () => { it('should throw a Unexpected error on 500', async () => { fetchMock.mockResponse('', { status: 500 }); - await expect(get('foo')).rejects.toThrow( + await expect(get('foo500')).rejects.toThrow( '@vercel/edge-config: Unexpected error due to response with status code 500', ); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - `${baseUrl}/item/foo?version=1`, + `${baseUrl}/item/foo500?version=1`, { method: 'GET', headers: new Headers({ From 82141e386bac8f9d2c9f891c369eaaa93377ba0e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 4 Aug 2025 15:32:57 +0300 Subject: [PATCH 60/81] fix embedding --- packages/edge-config/package.json | 2 +- packages/edge-config/src/controller.test.ts | 40 +++++++++---------- packages/edge-config/src/controller.ts | 6 +-- .../edge-config/src/utils/mockable-import.ts | 4 +- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 39ec386d7..911bbfa46 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/edge-config", - "version": "1.4.0", + "version": "2.0.0-beta.1", "description": "Ultra-low latency data at the edge", "homepage": "https://vercel.com", "repository": { diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index fe7599955..7608c8986 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -1,10 +1,10 @@ import fetchMock from 'jest-fetch-mock'; import { Controller } from './controller'; import type { Connection } from './types'; -import { mockableImport } from './utils/mockable-import'; +import { readLocalEdgeConfig } from './utils/mockable-import'; jest.mock('./utils/mockable-import', () => ({ - mockableImport: jest.fn(() => { + readLocalEdgeConfig: jest.fn(() => { throw new Error('not implemented'); }), })); @@ -74,7 +74,7 @@ describe('lifecycle: reading a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -126,7 +126,7 @@ describe('lifecycle: reading a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -176,7 +176,7 @@ describe('lifecycle: reading a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -221,7 +221,7 @@ describe('lifecycle: reading the full config', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -270,7 +270,7 @@ describe('lifecycle: reading the full config', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -317,7 +317,7 @@ describe('lifecycle: reading the full config', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -363,7 +363,7 @@ describe('lifecycle: checking existence of a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -413,7 +413,7 @@ describe('lifecycle: checking existence of a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -461,7 +461,7 @@ describe('lifecycle: checking existence of a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -691,7 +691,7 @@ describe('development cache: get', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'If-None-Match': '"digest2"', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -728,7 +728,7 @@ describe('development cache: get', () => { Authorization: 'Bearer fake-edge-config-token', // we query with the older etag we had in memory 'If-None-Match': '"digest2"', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -964,7 +964,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -1016,7 +1016,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -1066,7 +1066,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -1224,7 +1224,7 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '3000', - 'x-edge-config-sdk': '@vercel/edge-config@1.4.0', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', 'x-edge-config-vercel-env': 'test', }), }, @@ -1548,7 +1548,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the describe('preloading', () => { beforeAll(() => { - (mockableImport as jest.Mock).mockReset(); + (readLocalEdgeConfig as jest.Mock).mockReset(); fetchMock.resetMocks(); }); @@ -1557,7 +1557,7 @@ describe('preloading', () => { }); it('should use the preloaded value', async () => { - (mockableImport as jest.Mock).mockImplementationOnce(() => { + (readLocalEdgeConfig as jest.Mock).mockImplementationOnce(() => { return Promise.resolve({ default: { items: { key1: 'value-preloaded' }, @@ -1576,6 +1576,6 @@ describe('preloading', () => { updatedAt: 1000, }); expect(fetchMock).toHaveBeenCalledTimes(0); - expect(mockableImport).toHaveBeenCalledTimes(1); + expect(readLocalEdgeConfig).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 5f5b576b7..143160b9a 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -12,7 +12,7 @@ import type { import { ERRORS, isEmptyKey, pick, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; import { createEnhancedFetch } from './utils/enhanced-fetch'; -import { mockableImport } from './utils/mockable-import'; +import { readLocalEdgeConfig } from './utils/mockable-import'; const DEFAULT_STALE_THRESHOLD = 10; // 10 seconds // const DEFAULT_STALE_IF_ERROR = 604800; // one week in seconds @@ -95,8 +95,8 @@ export class Controller { this.preloaded = 'loading'; try { - await mockableImport<{ default: EmbeddedEdgeConfig }>( - `/tmp/edge-config/${this.connection.id}.json`, + await readLocalEdgeConfig<{ default: EmbeddedEdgeConfig }>( + this.connection.id, ) .then((mod) => { this.edgeConfigCache = mod.default; diff --git a/packages/edge-config/src/utils/mockable-import.ts b/packages/edge-config/src/utils/mockable-import.ts index 936bd217f..91cd527b1 100644 --- a/packages/edge-config/src/utils/mockable-import.ts +++ b/packages/edge-config/src/utils/mockable-import.ts @@ -1,3 +1,3 @@ -export function mockableImport(path: string): Promise { - return import(path) as Promise; +export function readLocalEdgeConfig(id: string): Promise { + return import(`/tmp/edge-config/${id}.json`) as Promise; } From b20e007ffaf706c9f94654857c18b782c445c7b8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 4 Aug 2025 15:33:36 +0300 Subject: [PATCH 61/81] increase beta --- packages/edge-config/package.json | 2 +- packages/edge-config/src/controller.test.ts | 30 ++++++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 911bbfa46..162d0432c 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/edge-config", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Ultra-low latency data at the edge", "homepage": "https://vercel.com", "repository": { diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 7608c8986..be1aa6790 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -74,7 +74,7 @@ describe('lifecycle: reading a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -126,7 +126,7 @@ describe('lifecycle: reading a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -176,7 +176,7 @@ describe('lifecycle: reading a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -221,7 +221,7 @@ describe('lifecycle: reading the full config', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -270,7 +270,7 @@ describe('lifecycle: reading the full config', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -317,7 +317,7 @@ describe('lifecycle: reading the full config', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -363,7 +363,7 @@ describe('lifecycle: checking existence of a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -413,7 +413,7 @@ describe('lifecycle: checking existence of a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -461,7 +461,7 @@ describe('lifecycle: checking existence of a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -691,7 +691,7 @@ describe('development cache: get', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'If-None-Match': '"digest2"', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -728,7 +728,7 @@ describe('development cache: get', () => { Authorization: 'Bearer fake-edge-config-token', // we query with the older etag we had in memory 'If-None-Match': '"digest2"', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -964,7 +964,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -1016,7 +1016,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -1066,7 +1066,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, @@ -1224,7 +1224,7 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '3000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.1', + 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', 'x-edge-config-vercel-env': 'test', }), }, From 4af59393ddfebf20f748060de444d81ba6e7f6e3 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 5 Aug 2025 11:03:15 +0300 Subject: [PATCH 62/81] fix tests --- packages/edge-config/package.json | 2 +- packages/edge-config/src/controller.test.ts | 194 +++++++++++++------- packages/edge-config/src/controller.ts | 10 +- 3 files changed, 135 insertions(+), 71 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 162d0432c..402dff909 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/edge-config", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.4", "description": "Ultra-low latency data at the edge", "homepage": "https://vercel.com", "repository": { diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index be1aa6790..5942175ac 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -1,8 +1,13 @@ import fetchMock from 'jest-fetch-mock'; +import { version } from '../package.json'; import { Controller } from './controller'; import type { Connection } from './types'; import { readLocalEdgeConfig } from './utils/mockable-import'; +const packageVersion = `@vercel/edge-config@${version}`; + +jest.useFakeTimers(); + jest.mock('./utils/mockable-import', () => ({ readLocalEdgeConfig: jest.fn(() => { throw new Error('not implemented'); @@ -45,6 +50,7 @@ describe('lifecycle: reading a single item', () => { }); it('should MISS the cache initially', async () => { + jest.setSystemTime(1000); setTimestampOfLatestUpdate(1000); fetchMock.mockResponseOnce(JSON.stringify('value1'), { headers: { @@ -74,7 +80,7 @@ describe('lifecycle: reading a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -82,6 +88,7 @@ describe('lifecycle: reading a single item', () => { }); it('should HIT the cache if the timestamp has not changed', async () => { + jest.setSystemTime(1100); await expect(controller.get('key1')).resolves.toEqual({ value: 'value1', digest: 'digest1', @@ -96,6 +103,7 @@ describe('lifecycle: reading a single item', () => { }); it('should serve a stale value if the timestamp has changed but is within the threshold', async () => { + jest.setSystemTime(7100); setTimestampOfLatestUpdate(7000); fetchMock.mockResponseOnce(JSON.stringify('value2'), { headers: { @@ -126,7 +134,7 @@ describe('lifecycle: reading a single item', () => { Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -148,11 +156,12 @@ describe('lifecycle: reading a single item', () => { }); it('should refresh when the stale threshold is exceeded', async () => { - setTimestampOfLatestUpdate(17001); + jest.setSystemTime(18001); + setTimestampOfLatestUpdate(8000); fetchMock.mockResponseOnce(JSON.stringify('value3'), { headers: { 'x-edge-config-digest': 'digest3', - 'x-edge-config-updated-at': '17001', + 'x-edge-config-updated-at': '8000', }, }); @@ -161,7 +170,7 @@ describe('lifecycle: reading a single item', () => { digest: 'digest3', cache: 'MISS', exists: true, - updatedAt: 17001, + updatedAt: 8000, }); }); @@ -175,8 +184,8 @@ describe('lifecycle: reading a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', - 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-min-updated-at': '8000', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -194,6 +203,7 @@ describe('lifecycle: reading the full config', () => { }); it('should MISS the cache initially', async () => { + jest.setSystemTime(1100); setTimestampOfLatestUpdate(1000); fetchMock.mockResponseOnce(JSON.stringify({ key1: 'value1' }), { headers: { @@ -221,7 +231,7 @@ describe('lifecycle: reading the full config', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -229,6 +239,7 @@ describe('lifecycle: reading the full config', () => { }); it('should HIT the cache if the timestamp has not changed', async () => { + jest.setSystemTime(20000); await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1' }, digest: 'digest1', @@ -242,11 +253,13 @@ describe('lifecycle: reading the full config', () => { }); it('should serve a stale value if the timestamp has changed but is within the threshold', async () => { - setTimestampOfLatestUpdate(7000); + // latest update was less than 10 seconds ago, so we can serve stale value + jest.setSystemTime(27000); + setTimestampOfLatestUpdate(20000); fetchMock.mockResponseOnce(JSON.stringify({ key1: 'value2' }), { headers: { 'x-edge-config-digest': 'digest2', - 'x-edge-config-updated-at': '7000', + 'x-edge-config-updated-at': '20000', etag: '"digest2"', 'content-type': 'application/json', }, @@ -269,8 +282,8 @@ describe('lifecycle: reading the full config', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', - 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-min-updated-at': '20000', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -282,7 +295,7 @@ describe('lifecycle: reading the full config', () => { value: { key1: 'value2' }, digest: 'digest2', cache: 'HIT', - updatedAt: 7000, + updatedAt: 20000, }); }); @@ -291,11 +304,12 @@ describe('lifecycle: reading the full config', () => { }); it('should refresh when the stale threshold is exceeded', async () => { - setTimestampOfLatestUpdate(17001); + jest.setSystemTime(30002); + setTimestampOfLatestUpdate(20001); fetchMock.mockResponseOnce(JSON.stringify({ key1: 'value3' }), { headers: { 'x-edge-config-digest': 'digest3', - 'x-edge-config-updated-at': '17001', + 'x-edge-config-updated-at': '20001', }, }); @@ -303,7 +317,7 @@ describe('lifecycle: reading the full config', () => { value: { key1: 'value3' }, digest: 'digest3', cache: 'MISS', - updatedAt: 17001, + updatedAt: 20001, }); }); @@ -316,8 +330,8 @@ describe('lifecycle: reading the full config', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', - 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-min-updated-at': '20001', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -335,6 +349,7 @@ describe('lifecycle: checking existence of a single item', () => { }); it('should MISS the cache initially', async () => { + jest.setSystemTime(1100); setTimestampOfLatestUpdate(1000); fetchMock.mockResponseOnce('', { headers: { @@ -363,7 +378,7 @@ describe('lifecycle: checking existence of a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -371,6 +386,7 @@ describe('lifecycle: checking existence of a single item', () => { }); it('should HIT the cache if the timestamp has not changed', async () => { + jest.setSystemTime(20000); await expect(controller.has('key1')).resolves.toEqual({ exists: true, digest: 'digest1', @@ -383,13 +399,16 @@ describe('lifecycle: checking existence of a single item', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('should serve a stale exitence value if the timestamp has changed but is within the threshold', async () => { - setTimestampOfLatestUpdate(7000); + it('should serve a stale value if the timestamp has changed but is within the threshold', async () => { + jest.setSystemTime(27000); + setTimestampOfLatestUpdate(20000); + + // pretend key1 does not exist anymore so we can check has() uses the stale value fetchMock.mockResponseOnce('', { status: 404, headers: { 'x-edge-config-digest': 'digest2', - 'x-edge-config-updated-at': '7000', + 'x-edge-config-updated-at': '20000', etag: '"digest2"', 'content-type': 'application/json', }, @@ -412,8 +431,8 @@ describe('lifecycle: checking existence of a single item', () => { cache: 'no-store', headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', - 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-min-updated-at': '20000', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -425,7 +444,8 @@ describe('lifecycle: checking existence of a single item', () => { exists: false, digest: 'digest2', cache: 'HIT', - updatedAt: 7000, + updatedAt: 20000, + value: undefined, }); }); @@ -434,11 +454,12 @@ describe('lifecycle: checking existence of a single item', () => { }); it('should refresh when the stale threshold is exceeded', async () => { - setTimestampOfLatestUpdate(17001); + jest.setSystemTime(40000); + setTimestampOfLatestUpdate(21000); fetchMock.mockResponseOnce('', { headers: { 'x-edge-config-digest': 'digest3', - 'x-edge-config-updated-at': '17001', + 'x-edge-config-updated-at': '21000', }, }); @@ -446,7 +467,7 @@ describe('lifecycle: checking existence of a single item', () => { exists: true, digest: 'digest3', cache: 'MISS', - updatedAt: 17001, + updatedAt: 21000, }); }); @@ -460,8 +481,8 @@ describe('lifecycle: checking existence of a single item', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', - 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-min-updated-at': '21000', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -691,7 +712,7 @@ describe('development cache: get', () => { headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'If-None-Match': '"digest2"', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -728,7 +749,7 @@ describe('development cache: get', () => { Authorization: 'Bearer fake-edge-config-token', // we query with the older etag we had in memory 'If-None-Match': '"digest2"', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -934,6 +955,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () }); it('should MISS the cache initially', async () => { + jest.setSystemTime(1100); setTimestampOfLatestUpdate(1000); fetchMock.mockResponseOnce( JSON.stringify({ key1: 'value1', key2: 'value2' }), @@ -964,7 +986,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '1000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -972,6 +994,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () }); it('should HIT the cache if the timestamp has not changed', async () => { + jest.setSystemTime(1200); await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ value: { key1: 'value1', key2: 'value2' }, digest: 'digest1', @@ -985,13 +1008,14 @@ describe('lifecycle: reading multiple items without full edge config cache', () }); it('should serve a stale value if the timestamp has changed but is within the threshold', async () => { - setTimestampOfLatestUpdate(7000); + jest.setSystemTime(20000); + setTimestampOfLatestUpdate(15000); fetchMock.mockResponseOnce( JSON.stringify({ key1: 'valueA', key2: 'valueB' }), { headers: { 'x-edge-config-digest': 'digest2', - 'x-edge-config-updated-at': '7000', + 'x-edge-config-updated-at': '15000', etag: '"digest2"', 'content-type': 'application/json', }, @@ -1015,8 +1039,8 @@ describe('lifecycle: reading multiple items without full edge config cache', () headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', - 'x-edge-config-min-updated-at': '7000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-min-updated-at': '15000', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -1028,7 +1052,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () value: { key1: 'valueA', key2: 'valueB' }, digest: 'digest2', cache: 'HIT', - updatedAt: 7000, + updatedAt: 15000, }); }); @@ -1037,13 +1061,14 @@ describe('lifecycle: reading multiple items without full edge config cache', () }); it('should refresh when the stale threshold is exceeded', async () => { - setTimestampOfLatestUpdate(17001); + jest.setSystemTime(40000); + setTimestampOfLatestUpdate(29000); fetchMock.mockResponseOnce( JSON.stringify({ key1: 'valueC', key2: 'valueD' }), { headers: { 'x-edge-config-digest': 'digest3', - 'x-edge-config-updated-at': '17001', + 'x-edge-config-updated-at': '29000', }, }, ); @@ -1052,7 +1077,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () value: { key1: 'valueC', key2: 'valueD' }, digest: 'digest3', cache: 'MISS', - updatedAt: 17001, + updatedAt: 29000, }); }); @@ -1065,8 +1090,8 @@ describe('lifecycle: reading multiple items without full edge config cache', () headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', // 'If-None-Match': '"digest1"', - 'x-edge-config-min-updated-at': '17001', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-min-updated-at': '29000', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -1224,7 +1249,7 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' headers: new Headers({ Authorization: 'Bearer fake-edge-config-token', 'x-edge-config-min-updated-at': '3000', - 'x-edge-config-sdk': '@vercel/edge-config@2.0.0-beta.2', + 'x-edge-config-sdk': packageVersion, 'x-edge-config-vercel-env': 'test', }), }, @@ -1326,6 +1351,7 @@ describe('lifecycle: reading multiple items when edge config cache is stale but }); it('should fetch the full edge config initially', async () => { + jest.setSystemTime(1100); setTimestampOfLatestUpdate(1000); fetchMock.mockResponseOnce( JSON.stringify({ key1: 'value1', key2: 'value2', key3: 'value3' }), @@ -1350,13 +1376,14 @@ describe('lifecycle: reading multiple items when edge config cache is stale but }); it('should fetch individual items', async () => { - setTimestampOfLatestUpdate(12000); + jest.setSystemTime(20000); + setTimestampOfLatestUpdate(5000); fetchMock.mockResponseOnce( JSON.stringify({ key1: 'value1a', key2: 'value2a', key3: 'value3a' }), { headers: { - 'x-edge-config-digest': 'digest1', - 'x-edge-config-updated-at': '12000', + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '5000', etag: '"digest1"', 'content-type': 'application/json', }, @@ -1365,9 +1392,9 @@ describe('lifecycle: reading multiple items when edge config cache is stale but await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, - digest: 'digest1', + digest: 'digest2', cache: 'MISS', - updatedAt: 12000, + updatedAt: 5000, }); expect(fetchMock).toHaveBeenCalledTimes(2); }); @@ -1375,36 +1402,61 @@ describe('lifecycle: reading multiple items when edge config cache is stale but it('should HIT the item cache if the timestamp has not changed', async () => { await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, - digest: 'digest1', + digest: 'digest2', cache: 'HIT', - updatedAt: 12000, + updatedAt: 5000, }); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('should serve STALE values from the item cache if the timestamp has changed but is within the threshold', async () => { + jest.setSystemTime(31000); + setTimestampOfLatestUpdate(30000); fetchMock.mockResponseOnce( JSON.stringify({ key1: 'value1b', key2: 'value2b', key3: 'value3b' }), { headers: { - 'x-edge-config-digest': 'digest1', - 'x-edge-config-updated-at': '13000', - etag: '"digest1"', + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '30000', + etag: '"digest3"', 'content-type': 'application/json', }, }, ); - setTimestampOfLatestUpdate(13000); await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, cache: 'STALE', - updatedAt: 12000, - digest: 'digest1', + updatedAt: 5000, + digest: 'digest2', }); + }); + it('should trigger a full background refresh after the STALE value', () => { expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://edge-config.vercel.com/items?version=1', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'x-edge-config-min-updated-at': '30000', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + it('should hit the cache if no more updates were made', async () => { + jest.setSystemTime(32000); + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ + value: { key1: 'value1b', key2: 'value2b', key3: 'value3b' }, + cache: 'HIT', + updatedAt: 30000, + digest: 'digest3', + }); }); }); @@ -1418,6 +1470,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }); it('should fetch multiple items', async () => { + jest.setSystemTime(1100); setTimestampOfLatestUpdate(1000); fetchMock.mockResponseOnce( JSON.stringify({ key1: 'value1', key2: 'value2' }), @@ -1487,14 +1540,15 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }); it('should serve STALE values when the edge config changes', async () => { - setTimestampOfLatestUpdate(2000); + jest.setSystemTime(25000); + setTimestampOfLatestUpdate(20000); fetchMock.mockResponseOnce( JSON.stringify({ key1: 'value1b', key2: 'value2b', key3: 'value3b' }), { headers: { - 'x-edge-config-digest': 'digestB', - 'x-edge-config-updated-at': '2000', - etag: '"digest1"', + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '20000', + etag: '"digest2"', 'content-type': 'application/json', }, }, @@ -1514,22 +1568,23 @@ describe('lifecycle: reading multiple items when the item cache is stale but the it('should hit the cache on subsequent reads', async () => { await expect(controller.all()).resolves.toEqual({ value: { key1: 'value1b', key2: 'value2b', key3: 'value3b' }, - digest: 'digestB', + digest: 'digest2', cache: 'HIT', - updatedAt: 2000, + updatedAt: 20000, }); expect(fetchMock).toHaveBeenCalledTimes(3); }); it('should serve STALE values from the edge config cache', async () => { - setTimestampOfLatestUpdate(3000); + jest.setSystemTime(24000); + setTimestampOfLatestUpdate(23000); fetchMock.mockResponseOnce( JSON.stringify({ key1: 'value1c', key2: 'value2c', key3: 'value3c' }), { headers: { - 'x-edge-config-digest': 'digestC', - 'x-edge-config-updated-at': '3000', - etag: '"digestC"', + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '23000', + etag: '"digest3"', 'content-type': 'application/json', }, }, @@ -1537,9 +1592,9 @@ describe('lifecycle: reading multiple items when the item cache is stale but the await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ value: { key1: 'value1b', key2: 'value2b', key3: 'value3b' }, - digest: 'digestB', + digest: 'digest2', cache: 'STALE', - updatedAt: 2000, + updatedAt: 20000, }); expect(fetchMock).toHaveBeenCalledTimes(4); @@ -1567,6 +1622,7 @@ describe('preloading', () => { }); }); + jest.setSystemTime(1100); setTimestampOfLatestUpdate(1000); await expect(controller.get('key1')).resolves.toEqual({ value: 'value-preloaded', diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 143160b9a..e3a3a789d 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -59,7 +59,8 @@ function getCacheStatus( if (latestUpdate === null) return 'MISS'; if (latestUpdate <= updatedAt) return 'HIT'; // check if it is within the threshold - if (updatedAt >= latestUpdate - maxStale * 1000) return 'STALE'; + if (latestUpdate >= Date.now() - maxStale * 1000) return 'STALE'; + // if (updatedAt >= latestUpdate - maxStale * 1000) return 'STALE'; return 'MISS'; } @@ -99,6 +100,13 @@ export class Controller { this.connection.id, ) .then((mod) => { + const hasNewerEntry = + this.edgeConfigCache && + this.edgeConfigCache.updatedAt > mod.default.updatedAt; + + // skip updating the local cache if there is a newer cache entry already + if (hasNewerEntry) return; + this.edgeConfigCache = mod.default; }) .catch() From 24780950bf4d2f544ca2123414c6478a8f4d660d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 5 Aug 2025 11:32:42 +0300 Subject: [PATCH 63/81] return cache status --- packages/edge-config/package.json | 2 +- packages/edge-config/src/index.ts | 43 +++++++++++++++++++++++++------ packages/edge-config/src/types.ts | 8 +++--- pnpm-lock.yaml | 2 -- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 402dff909..b86c7c7a0 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/edge-config", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "description": "Ultra-low latency data at the edge", "homepage": "https://vercel.com", "repository": { diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 89141aab9..b85afa1a7 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -6,6 +6,7 @@ import type { EmbeddedEdgeConfig, EdgeConfigFunctionsOptions, EdgeConfigClientOptions, + CacheStatus, } from './types'; import { trace } from './utils/tracing'; import { Controller } from './controller'; @@ -80,13 +81,19 @@ export const createClient = trace( async function get( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise { + ): Promise< + | T + | undefined + | { value: T | undefined; digest: string; cache: CacheStatus } + > { assertIsKey(key); if (isEmptyKey(key)) { throw new Error('@vercel/edge-config: Can not read empty key'); } const data = await controller.get(key, localOptions); - return localOptions?.metadata ? data : data.value; + return localOptions?.metadata + ? { value: data.value, digest: data.digest, cache: data.cache } + : data.value; }, { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, ), @@ -94,14 +101,22 @@ export const createClient = trace( async function has( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise { + ): Promise< + boolean | { exists: boolean; digest: string; cache: CacheStatus } + > { assertIsKey(key); if (isEmptyKey(key)) { throw new Error('@vercel/edge-config: Can not read empty key'); } const data = await controller.has(key, localOptions); - return localOptions?.metadata ? data : data.exists; + return localOptions?.metadata + ? { + exists: data.exists, + digest: data.digest, + cache: data.cache, + } + : data.exists; }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, ) as EdgeConfigClient['has'], @@ -109,7 +124,7 @@ export const createClient = trace( async function mget( keys: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T; digest: string } | T> { + ): Promise<{ value: T; digest: string; cache: CacheStatus } | T> { // bypass when called without valid keys and without needing metadata if ( keys.every((k) => typeof k === 'string' && k.trim().length === 0) && @@ -118,7 +133,13 @@ export const createClient = trace( return {} as T; const data = await controller.mget(keys as string[], localOptions); - return localOptions?.metadata ? data : data.value; + return localOptions?.metadata + ? { + value: data.value, + digest: data.digest, + cache: data.cache, + } + : data.value; }, { name: 'mget', @@ -129,9 +150,15 @@ export const createClient = trace( all: trace( async function all( localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ value: T; digest: string } | T> { + ): Promise<{ value: T; digest: string; cache: CacheStatus } | T> { const data = await controller.all(localOptions); - return localOptions?.metadata ? data : data.value; + return localOptions?.metadata + ? { + value: data.value, + digest: data.digest, + cache: data.cache, + } + : data.value; }, { name: 'all', isVerboseTrace: false, attributes: { edgeConfigId } }, ), diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 10af45b54..36a49f4bc 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -43,7 +43,7 @@ export interface EdgeConfigClient { ( key: string, options: EdgeConfigFunctionsOptions & { metadata: true }, - ): Promise<{ value: T | undefined; digest: string }>; + ): Promise<{ value: T | undefined; digest: string; cache: CacheStatus }>; ( key: string, options?: EdgeConfigFunctionsOptions, @@ -61,7 +61,7 @@ export interface EdgeConfigClient { ( keys: (keyof T)[], options: EdgeConfigFunctionsOptions & { metadata: true }, - ): Promise<{ value: T; digest: string }>; + ): Promise<{ value: T; digest: string; cache: CacheStatus }>; ( keys: (keyof T)[], options?: EdgeConfigFunctionsOptions, @@ -78,7 +78,7 @@ export interface EdgeConfigClient { all: { ( options: EdgeConfigFunctionsOptions & { metadata: true }, - ): Promise<{ value: T; digest: string }>; + ): Promise<{ value: T; digest: string; cache: CacheStatus }>; (options?: EdgeConfigFunctionsOptions): Promise; }; @@ -92,7 +92,7 @@ export interface EdgeConfigClient { ( key: string, options: EdgeConfigFunctionsOptions & { metadata: true }, - ): Promise<{ exists: boolean; digest: string }>; + ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }>; (key: string, options?: EdgeConfigFunctionsOptions): Promise; }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d07f59b5c..ee43bff39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,8 +155,6 @@ importers: specifier: 5.7.3 version: 5.7.3 - packages/edge-config-fs: {} - packages/kv: dependencies: '@upstash/redis': From 461b41cbae7cbf0f1b26ebb8deb7ed33da64771b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 6 Aug 2025 18:51:48 +0300 Subject: [PATCH 64/81] fix preload usage --- packages/edge-config/src/controller.test.ts | 101 ++++++++++++++++++-- packages/edge-config/src/controller.ts | 21 +++- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 5942175ac..8e103f763 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -1602,16 +1602,61 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }); describe('preloading', () => { - beforeAll(() => { + beforeEach(() => { (readLocalEdgeConfig as jest.Mock).mockReset(); fetchMock.resetMocks(); }); - const controller = new Controller(connection, { - enableDevelopmentCache: false, + it('should use the preloaded value is up to date', async () => { + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + // most recent update was only 1s ago, so we can serve the preloaded value + // as we are within the maxStale threshold + jest.setSystemTime(21000); + setTimestampOfLatestUpdate(20000); + + (readLocalEdgeConfig as jest.Mock).mockImplementationOnce(() => { + return Promise.resolve({ + default: { + items: { key1: 'value-preloaded' }, + updatedAt: 20000, + digest: 'digest-preloaded', + }, + }); + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value-preloaded', + digest: 'digest-preloaded', + cache: 'HIT', + exists: true, + updatedAt: 20000, + }); + expect(fetchMock).toHaveBeenCalledTimes(0); + expect(readLocalEdgeConfig).toHaveBeenCalledTimes(1); }); - it('should use the preloaded value', async () => { + it('should use the preloaded value if stale within the maxStale threshold', async () => { + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + // most recent update was only 1s ago, so we can serve the preloaded value + // as we are within the maxStale threshold + jest.setSystemTime(21000); + setTimestampOfLatestUpdate(20000); + + fetchMock.mockResponseOnce(JSON.stringify('value2'), { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '20000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }); + (readLocalEdgeConfig as jest.Mock).mockImplementationOnce(() => { return Promise.resolve({ default: { @@ -1622,16 +1667,56 @@ describe('preloading', () => { }); }); - jest.setSystemTime(1100); - setTimestampOfLatestUpdate(1000); await expect(controller.get('key1')).resolves.toEqual({ value: 'value-preloaded', digest: 'digest-preloaded', - cache: 'HIT', + cache: 'STALE', exists: true, updatedAt: 1000, }); - expect(fetchMock).toHaveBeenCalledTimes(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(readLocalEdgeConfig).toHaveBeenCalledTimes(1); + }); + + it('should not use the preloaded value if the cache is expired', async () => { + // most recent update was 11s ago, so we need to fetch fresh data + // as we are outside the maxStale threshold + jest.setSystemTime(31000); + setTimestampOfLatestUpdate(20000); + + const controller = new Controller(connection, { + enableDevelopmentCache: false, + }); + + (readLocalEdgeConfig as jest.Mock).mockImplementationOnce(() => { + return Promise.resolve({ + default: { + items: { keyA: 'value1' }, + // more than 10s old, with a newer update available that's only 1s old + updatedAt: 1000, + digest: 'digest1', + }, + }); + }); + + fetchMock.mockResponseOnce(JSON.stringify('value2'), { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '20000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.get('keyA')).resolves.toEqual({ + value: 'value2', + digest: 'digest2', + cache: 'MISS', + exists: true, + updatedAt: 20000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); expect(readLocalEdgeConfig).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index e3a3a789d..e71eab301 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -52,15 +52,26 @@ function parseTs(updatedAt: string | null): number | null { } function getCacheStatus( + /** Timestamp of the latest update to Edge Config */ latestUpdate: number | null, + /** Timestamp of the cache value we are looking at */ updatedAt: number, + /** Maximum propagation delay we are willing to tolerate. After this we will fetch fresh data. */ maxStale: number, ): CacheStatus { if (latestUpdate === null) return 'MISS'; if (latestUpdate <= updatedAt) return 'HIT'; - // check if it is within the threshold - if (latestUpdate >= Date.now() - maxStale * 1000) return 'STALE'; - // if (updatedAt >= latestUpdate - maxStale * 1000) return 'STALE'; + + // Our cached value is outdated (updatedAt < latestUpdate) + // Check if the latest update happened within the maxStale threshold + const now = Date.now(); + const maxStaleMs = maxStale * 1000; + + // If the latest update was within the maxStale window, we can serve STALE content + // (meaning we can serve the cached value while refreshing in background) + if (now - latestUpdate <= maxStaleMs) return 'STALE'; + + // The latest update was too long ago, we need to fetch fresh data return 'MISS'; } @@ -181,6 +192,8 @@ export class Controller { if (timestamp) { const cached = this.getCachedItem(key, method); + // console.log('cached', cached); + if (cached) { const cacheStatus = getCacheStatus( timestamp, @@ -188,6 +201,8 @@ export class Controller { this.maxStale, ); + // console.log('cacheStatus', cacheStatus, timestamp, cached.updatedAt); + // HIT if (cacheStatus === 'HIT') return { ...cached, cache: 'HIT' }; From f0fe3a60b86d0a80323bef6c6be37420ffb89428 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 6 Aug 2025 23:29:06 +0300 Subject: [PATCH 65/81] after --- packages/edge-config/package.json | 2 +- packages/edge-config/src/controller.ts | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index b86c7c7a0..88b8b2284 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/edge-config", - "version": "2.0.0-beta.5", + "version": "2.0.0-beta.7", "description": "Ultra-low latency data at the edge", "homepage": "https://vercel.com", "repository": { diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index e71eab301..8c59ec046 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -19,6 +19,14 @@ const DEFAULT_STALE_THRESHOLD = 10; // 10 seconds const privateEdgeConfigSymbol = Symbol.for('privateEdgeConfig'); +function after(fn: () => Promise): void { + waitUntil( + new Promise((resolve) => { + setTimeout(resolve, 0); + }).then(() => fn()), + ); +} + /** * Will return an embedded Edge Config object from memory, * but only when the `privateEdgeConfigSymbol` is in global scope. @@ -209,7 +217,7 @@ export class Controller { // we're outdated, but we can still serve the STALE value if (cacheStatus === 'STALE') { // background refresh - waitUntil( + after(() => this.fetchItem( method, key, @@ -393,14 +401,14 @@ export class Controller { if (res.ok || (res.status === 304 && cachedRes)) { // avoid undici memory leaks by consuming response bodies if (method === 'HEAD') { - waitUntil( + after(() => Promise.all([ consumeResponseBody(res), cachedRes ? consumeResponseBody(cachedRes) : null, ]), ); } else if (res.status === 304) { - waitUntil(consumeResponseBody(res)); + after(() => consumeResponseBody(res)); } let value: T | undefined; @@ -576,7 +584,7 @@ export class Controller { if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { if (cacheStatus === 'STALE') { // TODO refresh individual items only? - waitUntil(this.fetchFullConfig(ts, localOptions).catch()); + after(() => this.fetchFullConfig(ts, localOptions).catch()); } return { @@ -603,7 +611,7 @@ export class Controller { if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { if (cacheStatus === 'STALE') { // TODO refresh individual items only? - waitUntil(this.fetchFullConfig(ts, localOptions).catch()); + after(() => this.fetchFullConfig(ts, localOptions).catch()); } return { @@ -709,7 +717,7 @@ export class Controller { if (cacheStatus === 'STALE') { // background refresh - waitUntil(this.fetchFullConfig(ts, localOptions).catch()); + after(() => this.fetchFullConfig(ts, localOptions).catch()); return { value: this.edgeConfigCache.items as T, digest: this.edgeConfigCache.digest, From 82aac1f0f4f0e13b4c5b982132a674d5418bb95b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 7 Aug 2025 13:24:06 +0300 Subject: [PATCH 66/81] add comment --- packages/edge-config/src/controller.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 8c59ec046..48e262021 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -77,6 +77,9 @@ function getCacheStatus( // If the latest update was within the maxStale window, we can serve STALE content // (meaning we can serve the cached value while refreshing in background) + // + // but this is problematic if we hit a new instance which will have a different + // very old inlined edge config if (now - latestUpdate <= maxStaleMs) return 'STALE'; // The latest update was too long ago, we need to fetch fresh data From f505f788634c616d4d1cafa0381206642cb3011d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 20 Aug 2025 07:41:26 +0300 Subject: [PATCH 67/81] emit to relative folder --- packages/edge-config/.gitignore | 1 + packages/edge-config/package.json | 2 +- packages/edge-config/src/cli.ts | 32 +++++++++++++++---- .../edge-config/src/utils/mockable-import.ts | 4 ++- packages/edge-config/stores/empty.json | 1 + packages/edge-config/tsup.config.js | 2 +- 6 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 packages/edge-config/.gitignore create mode 100644 packages/edge-config/stores/empty.json diff --git a/packages/edge-config/.gitignore b/packages/edge-config/.gitignore new file mode 100644 index 000000000..e37c0f642 --- /dev/null +++ b/packages/edge-config/.gitignore @@ -0,0 +1 @@ +stores/ecfg_*.json diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 88b8b2284..623e28973 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -18,7 +18,7 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "bin": { - "edge-config": "./dist/cli.cjs" + "edge-config": "./dist/cli.js" }, "files": [ "dist" diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts index 137dd914b..71fe1e44a 100755 --- a/packages/edge-config/src/cli.ts +++ b/packages/edge-config/src/cli.ts @@ -1,16 +1,31 @@ #!/usr/bin/env node /* - * Reads all connected Edge Configs and emits them to /tmp/edge-config/$id.json + * 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 { writeFile, mkdir } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { Connection, EmbeddedEdgeConfig } from './types'; import { parseConnectionString } from './utils'; +// 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/src/cli.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) => { @@ -26,7 +41,10 @@ async function main(): Promise { [], ); - await mkdir('/tmp/edge-config', { recursive: true }); + 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) => { @@ -45,16 +63,16 @@ async function main(): Promise { }; }); - await writeFile( - `/tmp/edge-config/${connection.id}.json`, - JSON.stringify({ ...data, updatedAt }), - ); + 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('@vercerl/edge-config: prepare failed', error); + console.error('@vercel/edge-config: prepare failed', error); process.exit(1); }); diff --git a/packages/edge-config/src/utils/mockable-import.ts b/packages/edge-config/src/utils/mockable-import.ts index 91cd527b1..6122e6960 100644 --- a/packages/edge-config/src/utils/mockable-import.ts +++ b/packages/edge-config/src/utils/mockable-import.ts @@ -1,3 +1,5 @@ export function readLocalEdgeConfig(id: string): Promise { - return import(`/tmp/edge-config/${id}.json`) as Promise; + // The build process creates a __glob mechanism that expects this relative path + // This works both in development and when installed as a dependency + return import(`../../stores/${id}.json`) as Promise; } diff --git a/packages/edge-config/stores/empty.json b/packages/edge-config/stores/empty.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/edge-config/stores/empty.json @@ -0,0 +1 @@ +{} diff --git a/packages/edge-config/tsup.config.js b/packages/edge-config/tsup.config.js index 6f2a022a1..aaf02235b 100644 --- a/packages/edge-config/tsup.config.js +++ b/packages/edge-config/tsup.config.js @@ -15,7 +15,7 @@ export default defineConfig([ }, { entry: ['src/cli.ts'], - format: ['cjs'], + format: ['esm'], splitting: true, sourcemap: true, minify: false, From 1ae7a7fd7a1c61d1dc15d9094d48a600f707a97c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 11 Sep 2025 12:14:32 +0300 Subject: [PATCH 68/81] wip --- packages/edge-config/package.json | 12 +++-- packages/edge-config/src/controller.ts | 48 ++++++++++--------- .../edge-config/src/utils/mockable-import.ts | 3 +- packages/edge-config/tsup.config.js | 2 +- 4 files changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 623e28973..dfd061654 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/edge-config", - "version": "2.0.0-beta.7", + "version": "2.0.0-beta.790", "description": "Ultra-low latency data at the edge", "homepage": "https://vercel.com", "repository": { @@ -12,8 +12,11 @@ "sideEffects": false, "type": "module", "exports": { - "import": "./dist/index.js", - "require": "./dist/index.cjs" + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./stores/*": "./stores/*" }, "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -21,7 +24,8 @@ "edge-config": "./dist/cli.js" }, "files": [ - "dist" + "dist", + "stores" ], "scripts": { "build": "tsup", diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 48e262021..9b4a7bf4c 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -110,33 +110,33 @@ export class Controller { } private async preload(): Promise { - if (this.preloaded !== 'init') return; + // if (this.preloaded !== 'init') return; if (this.connection.type !== 'vercel') return; // the folder won't exist in development, only when deployed - if (process.env.NODE_ENV === 'development') return; + // if (process.env.NODE_ENV === 'development') return; this.preloaded = 'loading'; try { - await readLocalEdgeConfig<{ default: EmbeddedEdgeConfig }>( + const mod = await readLocalEdgeConfig<{ default: EmbeddedEdgeConfig }>( this.connection.id, - ) - .then((mod) => { - const hasNewerEntry = - this.edgeConfigCache && - this.edgeConfigCache.updatedAt > mod.default.updatedAt; - - // skip updating the local cache if there is a newer cache entry already - if (hasNewerEntry) return; - - this.edgeConfigCache = mod.default; - }) - .catch() - .finally(() => { - this.preloaded = 'loaded'; - }); - } catch { + ); + + console.log('read', mod.default); + + const hasNewerEntry = + this.edgeConfigCache && + this.edgeConfigCache.updatedAt > mod.default.updatedAt; + + // skip updating the local cache if there is a newer cache entry already + if (hasNewerEntry) return; + + this.edgeConfigCache = mod.default; + } catch (e) { + console.log('caught', e); /* do nothing */ + } finally { + this.preloaded = 'loaded'; } } @@ -151,10 +151,12 @@ export class Controller { updatedAt: number; }> { // hold a reference to the timestamp to avoid race conditions - const ts = getUpdatedAt(this.connection); - if (this.enableDevelopmentCache || !ts) { - return this.fetchItem('GET', key, ts, localOptions, true); - } + // const ts = getUpdatedAt(this.connection); + // if (this.enableDevelopmentCache || !ts) { + // return this.fetchItem('GET', key, ts, localOptions, true); + // } + + const ts = 1754511966797; await this.preload(); diff --git a/packages/edge-config/src/utils/mockable-import.ts b/packages/edge-config/src/utils/mockable-import.ts index 6122e6960..f07d98ecd 100644 --- a/packages/edge-config/src/utils/mockable-import.ts +++ b/packages/edge-config/src/utils/mockable-import.ts @@ -1,5 +1,6 @@ export function readLocalEdgeConfig(id: string): Promise { + console.log('reading local edge config1', __dirname); // The build process creates a __glob mechanism that expects this relative path // This works both in development and when installed as a dependency - return import(`../../stores/${id}.json`) as Promise; + return import(`@vercel/edge-config/stores/${id}.json`) as Promise; } diff --git a/packages/edge-config/tsup.config.js b/packages/edge-config/tsup.config.js index aaf02235b..ac4c29bdf 100644 --- a/packages/edge-config/tsup.config.js +++ b/packages/edge-config/tsup.config.js @@ -11,7 +11,7 @@ export default defineConfig([ clean: true, skipNodeModulesBundle: true, dts: true, - external: ['node_modules'], + external: ['node_modules', '@vercel/edge-config/stores'], }, { entry: ['src/cli.ts'], From 22041f3eca84cb4c7609a382124a15f3bb5db77b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 26 Sep 2025 09:41:04 +0300 Subject: [PATCH 69/81] rename back to simplify diff --- packages/edge-config/src/controller.ts | 2 +- ...nhanced-fetch.test.ts => fetch-with-cached-response.test.ts} | 2 +- .../utils/{enhanced-fetch.ts => fetch-with-cached-response.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/edge-config/src/utils/{enhanced-fetch.test.ts => fetch-with-cached-response.test.ts} (98%) rename packages/edge-config/src/utils/{enhanced-fetch.ts => fetch-with-cached-response.ts} (100%) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 9b4a7bf4c..6e7486246 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -11,7 +11,7 @@ import type { } from './types'; import { ERRORS, isEmptyKey, pick, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; -import { createEnhancedFetch } from './utils/enhanced-fetch'; +import { createEnhancedFetch } from './utils/fetch-with-cached-response'; import { readLocalEdgeConfig } from './utils/mockable-import'; const DEFAULT_STALE_THRESHOLD = 10; // 10 seconds diff --git a/packages/edge-config/src/utils/enhanced-fetch.test.ts b/packages/edge-config/src/utils/fetch-with-cached-response.test.ts similarity index 98% rename from packages/edge-config/src/utils/enhanced-fetch.test.ts rename to packages/edge-config/src/utils/fetch-with-cached-response.test.ts index 503df058c..d19270db1 100644 --- a/packages/edge-config/src/utils/enhanced-fetch.test.ts +++ b/packages/edge-config/src/utils/fetch-with-cached-response.test.ts @@ -1,5 +1,5 @@ import fetchMock from 'jest-fetch-mock'; -import { createEnhancedFetch } from './enhanced-fetch'; +import { createEnhancedFetch } from './fetch-with-cached-response'; describe('enhancedFetch', () => { let enhancedFetch: ReturnType; diff --git a/packages/edge-config/src/utils/enhanced-fetch.ts b/packages/edge-config/src/utils/fetch-with-cached-response.ts similarity index 100% rename from packages/edge-config/src/utils/enhanced-fetch.ts rename to packages/edge-config/src/utils/fetch-with-cached-response.ts From 57d7f6ea50aa556cd826300fa9e808a6cfbb9b8f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 26 Sep 2025 12:26:05 +0300 Subject: [PATCH 70/81] wip --- packages/edge-config/package.json | 10 +- packages/edge-config/src/controller.ts | 22 ++- .../edge-config/src/utils/mockable-import.ts | 28 ++- pnpm-lock.yaml | 173 ++++++++++++++++-- 4 files changed, 208 insertions(+), 25 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index dfd061654..eaaf4ef48 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/edge-config", - "version": "2.0.0-beta.790", + "version": "2.0.0-beta.802", "description": "Ultra-low latency data at the edge", "homepage": "https://vercel.com", "repository": { @@ -16,7 +16,10 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./stores/*": "./stores/*" + "./stores": { + "import": "./stores", + "require": "./stores" + } }, "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -48,7 +51,8 @@ "testEnvironment": "node" }, "dependencies": { - "@vercel/functions": "^2.2.5" + "@vercel/functions": "^2.2.5", + "eventsource-client": "1.2.0" }, "devDependencies": { "@changesets/cli": "2.28.1", diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 6e7486246..e997edc59 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -1,5 +1,7 @@ import { waitUntil } from '@vercel/functions'; +import { createEventSource, type EventSourceClient } from 'eventsource-client'; import { name as sdkName, version as sdkVersion } from '../package.json'; +import { readLocalEdgeConfig } from './utils/mockable-import'; import type { EdgeConfigValue, EmbeddedEdgeConfig, @@ -12,7 +14,6 @@ import type { import { ERRORS, isEmptyKey, pick, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; import { createEnhancedFetch } from './utils/fetch-with-cached-response'; -import { readLocalEdgeConfig } from './utils/mockable-import'; const DEFAULT_STALE_THRESHOLD = 10; // 10 seconds // const DEFAULT_STALE_IF_ERROR = 604800; // one week in seconds @@ -94,6 +95,7 @@ export class Controller { private cacheMode: 'no-store' | 'force-cache'; private enableDevelopmentCache: boolean; private preloaded: 'init' | 'loading' | 'loaded' = 'init'; + private stream: EventSourceClient | null = null; // create an instance per controller so the caches are isolated private enhancedFetch: ReturnType; @@ -107,6 +109,18 @@ export class Controller { this.cacheMode = options.cache || 'no-store'; this.enableDevelopmentCache = options.enableDevelopmentCache; this.enhancedFetch = createEnhancedFetch(); + + if (options.enableDevelopmentCache && this.connection.type === 'vercel') { + this.stream = createEventSource({ + url: `https://api.vercel.com/v1/edge-config/${this.connection.id}/stream`, + headers: { + 'x-edge-config-token': this.connection.token, + }, + onMessage(event, ...rest) { + console.log('event', event, ...rest); + }, + }); + } } private async preload(): Promise { @@ -122,7 +136,7 @@ export class Controller { this.connection.id, ); - console.log('read', mod.default); + if (!mod) return; const hasNewerEntry = this.edgeConfigCache && @@ -133,8 +147,8 @@ export class Controller { this.edgeConfigCache = mod.default; } catch (e) { - console.log('caught', e); - /* do nothing */ + // eslint-disable-next-line no-console -- intentional error logging + console.error('@vercel/edge-config: Error reading local edge config', e); } finally { this.preloaded = 'loaded'; } diff --git a/packages/edge-config/src/utils/mockable-import.ts b/packages/edge-config/src/utils/mockable-import.ts index f07d98ecd..8702f8989 100644 --- a/packages/edge-config/src/utils/mockable-import.ts +++ b/packages/edge-config/src/utils/mockable-import.ts @@ -1,6 +1,24 @@ -export function readLocalEdgeConfig(id: string): Promise { - console.log('reading local edge config1', __dirname); - // The build process creates a __glob mechanism that expects this relative path - // This works both in development and when installed as a dependency - return import(`@vercel/edge-config/stores/${id}.json`) as Promise; +/** + * 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 readLocalEdgeConfig(id: string): Promise { + try { + return (await import( + /* webpackIgnore: true */ `@vercel/edge-config/stores/${id}.json` + )) 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/pnpm-lock.yaml b/pnpm-lock.yaml index ee43bff39..a93db2c25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 0.3.7 ts-jest: specifier: 29.2.6 - version: 29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) + version: 29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) turbo: specifier: 2.4.4 version: 2.4.4 @@ -108,6 +108,9 @@ importers: '@vercel/functions': specifier: ^2.2.5 version: 2.2.5 + eventsource-client: + specifier: 1.2.0 + version: 1.2.0 devDependencies: '@changesets/cli': specifier: 2.28.1 @@ -369,7 +372,7 @@ importers: version: 2.1.3 next: specifier: ^15.2.0 - version: 15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) postcss: specifier: ^8.5.3 version: 8.5.3 @@ -2977,6 +2980,14 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventsource-client@1.2.0: + resolution: {integrity: sha512-kDI75RSzO3TwyG/K9w1ap8XwqSPcwi6jaMkNulfVeZmSeUM49U8kUzk1s+vKNt0tGrXgK47i+620Yasn1ccFiw==} + engines: {node: '>=18.0.0'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4936,6 +4947,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -5825,26 +5837,56 @@ snapshots: dependencies: '@babel/types': 7.26.9 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5855,36 +5897,78 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -7022,10 +7106,10 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.0 - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 8.25.0(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/type-utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) @@ -7243,7 +7327,7 @@ snapshots: '@babel/core': 7.23.9 '@babel/eslint-parser': 7.23.10(@babel/core@7.23.9)(eslint@8.56.0) '@rushstack/eslint-patch': 1.7.2 - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) eslint-config-prettier: 9.1.0(eslint@8.56.0) eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1) @@ -7544,6 +7628,20 @@ snapshots: axobject-query@4.1.0: {} + babel-jest@29.7.0(@babel/core@7.23.9): + dependencies: + '@babel/core': 7.23.9 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.0 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.23.9) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + optional: true + babel-jest@29.7.0(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -7574,6 +7672,23 @@ snapshots: '@types/babel__core': 7.20.0 '@types/babel__traverse': 7.18.5 + babel-preset-current-node-syntax@1.0.1(@babel/core@7.23.9): + dependencies: + '@babel/core': 7.23.9 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.23.9) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.23.9) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.23.9) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.23.9) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.23.9) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.9) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.9) + optional: true + babel-preset-current-node-syntax@1.0.1(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -7590,6 +7705,13 @@ snapshots: '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.26.0) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.26.0) + babel-preset-jest@29.6.3(@babel/core@7.23.9): + dependencies: + '@babel/core': 7.23.9 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.9) + optional: true + babel-preset-jest@29.6.3(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -8388,7 +8510,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.15.0 eslint: 8.56.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 @@ -8426,7 +8548,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0): + eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -8453,7 +8575,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.56.0))(eslint@8.56.0) hasown: 2.0.1 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -8504,7 +8626,7 @@ snapshots: '@typescript-eslint/utils': 5.62.0(eslint@8.56.0)(typescript@5.7.3) eslint: 8.56.0 optionalDependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) transitivePeerDependencies: - supports-color @@ -8726,6 +8848,12 @@ snapshots: eventemitter3@5.0.1: {} + eventsource-client@1.2.0: + dependencies: + eventsource-parser: 3.0.6 + + eventsource-parser@3.0.6: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -10274,7 +10402,7 @@ snapshots: natural-compare@1.4.0: {} - next@15.2.0(@babel/core@7.23.9)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.2.0(@babel/core@7.26.0)(@opentelemetry/api@1.7.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.2.0 '@swc/counter': 0.1.3 @@ -10284,7 +10412,7 @@ snapshots: postcss: 8.4.31 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.23.9)(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) optionalDependencies: '@next/swc-darwin-arm64': 15.2.0 '@next/swc-darwin-x64': 15.2.0 @@ -11258,12 +11386,12 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(@babel/core@7.23.9)(react@19.0.0): + styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.0.0): dependencies: client-only: 0.0.1 react: 19.0.0 optionalDependencies: - '@babel/core': 7.23.9 + '@babel/core': 7.26.0 sucrase@3.35.0: dependencies: @@ -11419,6 +11547,25 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.26.0) esbuild: 0.24.2 + ts-jest@29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): + dependencies: + bs-logger: 0.2.6 + ejs: 3.1.10 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.1 + typescript: 5.7.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.23.9 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.23.9) + ts-jest@29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 From f301f87f1b5988f5edd0a562d62dc3ae3190a6bb Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 26 Sep 2025 20:44:38 +0300 Subject: [PATCH 71/81] wip --- packages/edge-config/src/controller.ts | 127 +++++++++++++++++++++---- packages/edge-config/src/index.ts | 11 +-- packages/edge-config/src/types.ts | 10 +- 3 files changed, 113 insertions(+), 35 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index e997edc59..cb30a2586 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -29,7 +29,7 @@ function after(fn: () => Promise): void { } /** - * Will return an embedded Edge Config object from memory, + * Will return the latest version of an Edge Config, * but only when the `privateEdgeConfigSymbol` is in global scope. */ function getUpdatedAt(connection: Connection): number | null { @@ -93,7 +93,7 @@ export class Controller { private connection: Connection; private maxStale: number; private cacheMode: 'no-store' | 'force-cache'; - private enableDevelopmentCache: boolean; + private enableDevelopmentStream: boolean; private preloaded: 'init' | 'loading' | 'loaded' = 'init'; private stream: EventSourceClient | null = null; @@ -102,27 +102,72 @@ export class Controller { constructor( connection: Connection, - options: EdgeConfigClientOptions & { enableDevelopmentCache: boolean }, + options: EdgeConfigClientOptions & { + enableDevelopmentStream: boolean; + }, ) { this.connection = connection; this.maxStale = options.maxStale ?? DEFAULT_STALE_THRESHOLD; this.cacheMode = options.cache || 'no-store'; - this.enableDevelopmentCache = options.enableDevelopmentCache; + this.enableDevelopmentStream = options.enableDevelopmentStream; this.enhancedFetch = createEnhancedFetch(); - if (options.enableDevelopmentCache && this.connection.type === 'vercel') { - this.stream = createEventSource({ - url: `https://api.vercel.com/v1/edge-config/${this.connection.id}/stream`, - headers: { - 'x-edge-config-token': this.connection.token, - }, - onMessage(event, ...rest) { - console.log('event', event, ...rest); - }, + if (options.enableDevelopmentStream && this.connection.type === 'vercel') { + void this.initStream().catch((error) => { + // eslint-disable-next-line no-console -- intentional error logging + console.error('@vercel/edge-config: Stream error', error); }); } } + private async initStream(): Promise { + this.stream = createEventSource({ + url: `https://api.vercel.com/v1/edge-config/${this.connection.id}/stream`, + headers: { + Authorization: `Bearer ${this.connection.token}`, + + // send the version we have in cache, so the stream endpoint only + // sends us a newer version if it exists + ...(this.edgeConfigCache?.updatedAt + ? { + 'x-edge-config-updated-at': String( + this.edgeConfigCache.updatedAt, + ), + } + : {}), + }, + }); + + for await (const { data, event } of this.stream) { + if (event === 'info' && data === 'token_invalidated') { + this.stream.close(); + return; + } + + if (event === 'embed') { + let edgeConfig: EmbeddedEdgeConfig | undefined; + try { + edgeConfig = JSON.parse(data) as EmbeddedEdgeConfig; + } catch (e) { + // eslint-disable-next-line no-console -- intentional error logging + console.error( + '@vercel/edge-config: Error parsing streamed edge config', + e, + ); + continue; + } + + this.updateEdgeConfigCache( + edgeConfig.items, + edgeConfig.updatedAt, + edgeConfig.digest, + ); + } + } + + this.stream.close(); + } + private async preload(): Promise { // if (this.preloaded !== 'init') return; if (this.connection.type !== 'vercel') return; @@ -164,13 +209,24 @@ export class Controller { exists: boolean; updatedAt: number; }> { + if (this.stream && this.edgeConfigCache) { + const cached = this.readCache( + key, + 'GET', + this.edgeConfigCache.updatedAt, + localOptions, + ); + if (cached) return cached; + } + + // TODO what does does the logic need to look like here if we have the dev stream // hold a reference to the timestamp to avoid race conditions - // const ts = getUpdatedAt(this.connection); - // if (this.enableDevelopmentCache || !ts) { - // return this.fetchItem('GET', key, ts, localOptions, true); - // } + const ts = getUpdatedAt(this.connection); + if (!ts) { + return this.fetchItem('GET', key, ts, localOptions, true); + } - const ts = 1754511966797; + // const ts = this.edgeConfigCache?.updatedAt; await this.preload(); @@ -206,7 +262,7 @@ export class Controller { private readCache( key: string, method: 'GET' | 'HEAD', - timestamp: number | undefined, + timestamp: number | undefined | null, localOptions: EdgeConfigFunctionsOptions | undefined, ): { value: T | undefined; @@ -503,8 +559,18 @@ export class Controller { key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }> { + if (this.stream && this.edgeConfigCache?.updatedAt) { + const cached = this.readCache( + key, + 'HEAD', + this.edgeConfigCache.updatedAt, + localOptions, + ); + if (cached) return cached; + } + const ts = getUpdatedAt(this.connection); - if (this.enableDevelopmentCache || !ts) { + if (!ts) { return this.fetchItem('HEAD', key, ts, localOptions, true); } @@ -524,6 +590,11 @@ export class Controller { public async digest( localOptions?: Pick, ): Promise { + if (this.stream && this.edgeConfigCache) { + const cached = this.edgeConfigCache.digest; + if (cached) return cached; + } + const ts = getUpdatedAt(this.connection); return fetch( `${this.connection.baseUrl}/digest?version=${this.connection.version}`, @@ -550,7 +621,6 @@ export class Controller { cache: CacheStatus; updatedAt: number; }> { - const ts = getUpdatedAt(this.connection); if (!Array.isArray(keys)) { throw new Error('@vercel/edge-config: keys must be an array'); } @@ -559,6 +629,21 @@ export class Controller { (key) => typeof key === 'string' && !isEmptyKey(key), ); + if (this.stream && this.edgeConfigCache) { + return { + value: filteredKeys.reduce>((acc, key, index) => { + const item = items[index]; + acc[key as keyof T] = item?.value as T[keyof T]; + return acc; + }, {}) as T, + digest: this.edgeConfigCache.digest, + cache: 'HIT', + updatedAt: this.edgeConfigCache.updatedAt, + }; + } + + const ts = getUpdatedAt(this.connection); + // Return early if there are no keys to be read. // This is only possible if the digest is not required, or if we have a // cached digest (not implemented yet). diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index b85afa1a7..b14a59ce8 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -61,17 +61,16 @@ export const createClient = trace( ); /** - * While in development we use SWR-like behavior for the api client to - * reduce latency. + * While in development, stream updates from the Edge Config. */ - const shouldUseDevelopmentCache = - !options.disableDevelopmentCache && + const shouldUseDevelopmentStream = + !options.disableDevelopmentStream && process.env.NODE_ENV === 'development' && - process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; + process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_STREAM !== '1'; const controller = new Controller(connection, { ...options, - enableDevelopmentCache: shouldUseDevelopmentCache, + enableDevelopmentStream: shouldUseDevelopmentStream, }); const edgeConfigId = connection.id; diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 36a49f4bc..010606389 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -169,15 +169,9 @@ export interface EdgeConfigClientOptions { maxStale?: number; /** - * In development, a stale-while-revalidate cache is employed as the default caching strategy. - * - * This cache aims to deliver speedy Edge Config reads during development, though it comes - * at the cost of delayed visibility for updates to Edge Config. Typically, you may need to - * refresh twice to observe these changes as the stale value is replaced. - * - * This cache is not used in preview or production deployments as superior optimisations are applied there. + * In development, a stream is employed as the default streaming strategy. */ - disableDevelopmentCache?: boolean; + disableDevelopmentStream?: boolean; /** * Sets a `cache` option on the `fetch` call made by Edge Config. From 538e23142d0e55230f228bb8ceb5a2d2f6159ecd Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 27 Sep 2025 09:12:30 +0300 Subject: [PATCH 72/81] refactor? --- .claude/settings.local.json | 7 + packages/edge-config/src/controller.test.ts | 32 +- packages/edge-config/src/controller.ts | 962 ++++++++++---------- 3 files changed, 485 insertions(+), 516 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..4163ec6a9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": ["Bash(pnpm jest:*)"], + "deny": [], + "ask": [] + } +} diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 8e103f763..4a180fb3d 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -46,7 +46,7 @@ describe('lifecycle: reading a single item', () => { }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('should MISS the cache initially', async () => { @@ -199,7 +199,7 @@ describe('lifecycle: reading the full config', () => { }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('should MISS the cache initially', async () => { @@ -345,7 +345,7 @@ describe('lifecycle: checking existence of a single item', () => { }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('should MISS the cache initially', async () => { @@ -495,7 +495,7 @@ describe('deduping within a version', () => { fetchMock.resetMocks(); }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); // let promisedValue1: ReturnType; @@ -565,7 +565,7 @@ describe('bypassing dedupe when the timestamp changes', () => { fetchMock.resetMocks(); }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('should fetch twice when the timestamp changes', async () => { @@ -621,7 +621,7 @@ describe('bypassing dedupe when the timestamp changes', () => { describe('development cache: get', () => { const controller = new Controller(connection, { - enableDevelopmentCache: true, + enableDevelopmentStream: true, }); beforeAll(() => { fetchMock.resetMocks(); @@ -761,7 +761,7 @@ describe('development cache: get', () => { describe('development cache: has', () => { const controller = new Controller(connection, { - enableDevelopmentCache: true, + enableDevelopmentStream: true, }); beforeAll(() => { fetchMock.resetMocks(); @@ -828,7 +828,7 @@ describe('lifecycle: mixing get, has and all', () => { }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('get(key1) should MISS the cache initially', async () => { @@ -951,7 +951,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('should MISS the cache initially', async () => { @@ -1105,7 +1105,7 @@ describe('lifecycle: reading multiple items with full edge config cache', () => }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('should MISS the cache initially', async () => { @@ -1175,7 +1175,7 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('should MISS the cache initially and populate item cache with different timestamps', async () => { @@ -1347,7 +1347,7 @@ describe('lifecycle: reading multiple items when edge config cache is stale but }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('should fetch the full edge config initially', async () => { @@ -1466,7 +1466,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); it('should fetch multiple items', async () => { @@ -1609,7 +1609,7 @@ describe('preloading', () => { it('should use the preloaded value is up to date', async () => { const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); // most recent update was only 1s ago, so we can serve the preloaded value @@ -1640,7 +1640,7 @@ describe('preloading', () => { it('should use the preloaded value if stale within the maxStale threshold', async () => { const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); // most recent update was only 1s ago, so we can serve the preloaded value @@ -1685,7 +1685,7 @@ describe('preloading', () => { setTimestampOfLatestUpdate(20000); const controller = new Controller(connection, { - enableDevelopmentCache: false, + enableDevelopmentStream: false, }); (readLocalEdgeConfig as jest.Mock).mockImplementationOnce(() => { diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index cb30a2586..ed9ffdd3f 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -15,11 +15,16 @@ import { ERRORS, isEmptyKey, pick, UnexpectedNetworkError } from './utils'; import { consumeResponseBody } from './utils/consume-response-body'; import { createEnhancedFetch } from './utils/fetch-with-cached-response'; -const DEFAULT_STALE_THRESHOLD = 10; // 10 seconds -// const DEFAULT_STALE_IF_ERROR = 604800; // one week in seconds - +const DEFAULT_STALE_THRESHOLD = 10; const privateEdgeConfigSymbol = Symbol.for('privateEdgeConfig'); +interface CacheEntry { + value: T | undefined; + updatedAt: number; + digest: string; + exists: boolean; +} + function after(fn: () => Promise): void { waitUntil( new Promise((resolve) => { @@ -28,10 +33,6 @@ function after(fn: () => Promise): void { ); } -/** - * Will return the latest version of an Edge Config, - * but only when the `privateEdgeConfigSymbol` is in global scope. - */ function getUpdatedAt(connection: Connection): number | null { const privateEdgeConfig = Reflect.get(globalThis, privateEdgeConfigSymbol) as | { getUpdatedAt: (id: string) => number | null } @@ -43,16 +44,6 @@ function getUpdatedAt(connection: Connection): number | null { : null; } -/** - * Unified cache entry that stores both the value and existence information - */ -interface CacheEntry { - value: T | undefined; - updatedAt: number; - digest: string; - exists: boolean; -} - function parseTs(updatedAt: string | null): number | null { if (!updatedAt) return null; const parsed = Number.parseInt(updatedAt, 10); @@ -61,79 +52,105 @@ function parseTs(updatedAt: string | null): number | null { } function getCacheStatus( - /** Timestamp of the latest update to Edge Config */ latestUpdate: number | null, - /** Timestamp of the cache value we are looking at */ updatedAt: number, - /** Maximum propagation delay we are willing to tolerate. After this we will fetch fresh data. */ maxStale: number, ): CacheStatus { if (latestUpdate === null) return 'MISS'; if (latestUpdate <= updatedAt) return 'HIT'; - // Our cached value is outdated (updatedAt < latestUpdate) - // Check if the latest update happened within the maxStale threshold const now = Date.now(); const maxStaleMs = maxStale * 1000; - // If the latest update was within the maxStale window, we can serve STALE content - // (meaning we can serve the cached value while refreshing in background) - // - // but this is problematic if we hit a new instance which will have a different - // very old inlined edge config if (now - latestUpdate <= maxStaleMs) return 'STALE'; - - // The latest update was too long ago, we need to fetch fresh data return 'MISS'; } -export class Controller { - private edgeConfigCache: EmbeddedEdgeConfig | null = null; +class CacheManager { private itemCache = new Map(); - private connection: Connection; - private maxStale: number; - private cacheMode: 'no-store' | 'force-cache'; - private enableDevelopmentStream: boolean; - private preloaded: 'init' | 'loading' | 'loaded' = 'init'; - private stream: EventSourceClient | null = null; + private edgeConfigCache: EmbeddedEdgeConfig | null = null; - // create an instance per controller so the caches are isolated - private enhancedFetch: ReturnType; + setItem( + key: string, + value: T | undefined, + updatedAt: number, + digest: string, + exists: boolean, + ): void { + const existing = this.itemCache.get(key); + if (existing && existing.updatedAt >= updatedAt) return; + this.itemCache.set(key, { value, updatedAt, digest, exists }); + } - constructor( - connection: Connection, - options: EdgeConfigClientOptions & { - enableDevelopmentStream: boolean; - }, - ) { - this.connection = connection; - this.maxStale = options.maxStale ?? DEFAULT_STALE_THRESHOLD; - this.cacheMode = options.cache || 'no-store'; - this.enableDevelopmentStream = options.enableDevelopmentStream; - this.enhancedFetch = createEnhancedFetch(); + setEdgeConfig(next: EmbeddedEdgeConfig): void { + if (!next.updatedAt) return; + const existing = this.edgeConfigCache; + if (existing && existing.updatedAt >= next.updatedAt) return; + this.edgeConfigCache = next; + } - if (options.enableDevelopmentStream && this.connection.type === 'vercel') { - void this.initStream().catch((error) => { - // eslint-disable-next-line no-console -- intentional error logging - console.error('@vercel/edge-config: Stream error', error); - }); + getItem( + key: string, + method: 'GET' | 'HEAD', + ): CacheEntry | null { + const item = this.itemCache.get(key); + const itemCacheEntry = + method === 'GET' && item?.exists && item.value === undefined + ? undefined + : item; + const cachedConfig = this.edgeConfigCache; + + if (itemCacheEntry && cachedConfig) { + return itemCacheEntry.updatedAt >= cachedConfig.updatedAt + ? (itemCacheEntry as CacheEntry) + : { + digest: cachedConfig.digest, + value: cachedConfig.items[key] as T, + updatedAt: cachedConfig.updatedAt, + exists: Object.hasOwn(cachedConfig.items, key), + }; } + + if (itemCacheEntry && !cachedConfig) { + return itemCacheEntry as CacheEntry; + } + + if (!itemCacheEntry && cachedConfig) { + return { + value: cachedConfig.items[key] as T, + updatedAt: cachedConfig.updatedAt, + digest: cachedConfig.digest, + exists: Object.hasOwn(cachedConfig.items, key), + }; + } + + return null; + } + + getEdgeConfig(): EmbeddedEdgeConfig | null { + return this.edgeConfigCache; } +} + +class StreamManager { + private stream: EventSourceClient | null = null; + private cacheManager: CacheManager; + private connection: Connection; + + constructor(cacheManager: CacheManager, connection: Connection) { + this.cacheManager = cacheManager; + this.connection = connection; + } + + async init(): Promise { + const currentEdgeConfig = this.cacheManager.getEdgeConfig(); - private async initStream(): Promise { this.stream = createEventSource({ url: `https://api.vercel.com/v1/edge-config/${this.connection.id}/stream`, headers: { Authorization: `Bearer ${this.connection.token}`, - - // send the version we have in cache, so the stream endpoint only - // sends us a newer version if it exists - ...(this.edgeConfigCache?.updatedAt - ? { - 'x-edge-config-updated-at': String( - this.edgeConfigCache.updatedAt, - ), - } + ...(currentEdgeConfig?.updatedAt + ? { 'x-edge-config-updated-at': String(currentEdgeConfig.updatedAt) } : {}), }, }); @@ -145,34 +162,291 @@ export class Controller { } if (event === 'embed') { - let edgeConfig: EmbeddedEdgeConfig | undefined; try { - edgeConfig = JSON.parse(data) as EmbeddedEdgeConfig; + const parsedEdgeConfig = JSON.parse(data) as EmbeddedEdgeConfig; + this.cacheManager.setEdgeConfig(parsedEdgeConfig); } catch (e) { // eslint-disable-next-line no-console -- intentional error logging console.error( '@vercel/edge-config: Error parsing streamed edge config', e, ); - continue; } + } + } + + this.stream.close(); + } + + close(): void { + this.stream?.close(); + } +} + +class NetworkClient { + private enhancedFetch: ReturnType; + private connection: Connection; + private cacheMode: 'no-store' | 'force-cache'; + + constructor(connection: Connection, cacheMode: 'no-store' | 'force-cache') { + this.connection = connection; + this.cacheMode = cacheMode; + this.enhancedFetch = createEnhancedFetch(); + } + + private getHeaders( + localOptions: EdgeConfigFunctionsOptions | undefined, + minUpdatedAt: number | null, + ): Headers { + const headers: Record = { + Authorization: `Bearer ${this.connection.token}`, + }; + const localHeaders = new Headers(headers); + + if (localOptions?.consistentRead || minUpdatedAt) { + localHeaders.set( + 'x-edge-config-min-updated-at', + `${localOptions?.consistentRead ? Number.MAX_SAFE_INTEGER : minUpdatedAt}`, + ); + } - this.updateEdgeConfigCache( - edgeConfig.items, - edgeConfig.updatedAt, - edgeConfig.digest, + if (process.env.VERCEL_ENV) { + localHeaders.set('x-edge-config-vercel-env', process.env.VERCEL_ENV); + } + + if (typeof sdkName === 'string' && typeof sdkVersion === 'string') { + localHeaders.set('x-edge-config-sdk', `${sdkName}@${sdkVersion}`); + } + + return localHeaders; + } + + async fetchItem( + method: 'GET' | 'HEAD', + key: string, + minUpdatedAt: number | null, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T | undefined; + digest: string; + exists: boolean; + updatedAt: number; + }> { + const [res, cachedRes] = await this.enhancedFetch( + `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, + { + method, + headers: this.getHeaders(localOptions, minUpdatedAt), + cache: this.cacheMode, + }, + ); + + const digest = (cachedRes || res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes || res).headers.get('x-edge-config-updated-at'), + ); + + if ( + res.status === 500 || + res.status === 502 || + res.status === 503 || + res.status === 504 + ) { + await Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]); + throw new UnexpectedNetworkError(res); + } + + if (!digest || !updatedAt) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + + if (res.ok || (res.status === 304 && cachedRes)) { + if (method === 'HEAD') { + after(() => + Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]), ); + } else if (res.status === 304) { + after(() => consumeResponseBody(res)); + } + + let value: T | undefined; + if (method === 'GET') { + value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as T; + } + + return { + value, + digest, + exists: res.status !== 404, + updatedAt, + }; + } + + await Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + if (res.status === 404) { + if (digest && updatedAt) { + return { + value: undefined, + digest, + exists: false, + updatedAt, + }; } + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); } + throw new UnexpectedNetworkError(res); + } - this.stream.close(); + async fetchFullConfig>( + minUpdatedAt: number | null, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise { + const [res, cachedRes] = await this.enhancedFetch( + `${this.connection.baseUrl}/items?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions, minUpdatedAt), + cache: this.cacheMode, + }, + ); + + const digest = (cachedRes ?? res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes ?? res).headers.get('x-edge-config-updated-at'), + ); + + if (res.status === 500) throw new UnexpectedNetworkError(res); + + if (!updatedAt || !digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + + if (res.status === 401) { + await consumeResponseBody(res); + throw new Error(ERRORS.UNAUTHORIZED); + } + + if (res.ok || (res.status === 304 && cachedRes)) { + const value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as ItemsType; + + if (res.status === 304) await consumeResponseBody(res); + + return { items: value, digest, updatedAt }; + } + + throw new UnexpectedNetworkError(res); + } + + async fetchMultipleItems( + keys: string[], + minUpdatedAt: number | null, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: ItemsType; + digest: string; + updatedAt: number; + }> { + const search = new URLSearchParams( + keys.map((key) => ['key', key] as [string, string]), + ).toString(); + + const [res, cachedRes] = await this.enhancedFetch( + `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, + { + headers: this.getHeaders(localOptions, minUpdatedAt), + cache: this.cacheMode, + }, + ); + + const digest = (cachedRes || res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes || res).headers.get('x-edge-config-updated-at'), + ); + + if (!updatedAt || !digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + + if (res.ok || (res.status === 304 && cachedRes)) { + if (!digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + const value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as ItemsType; + + return { value, digest, updatedAt }; + } + await consumeResponseBody(res); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + throw new UnexpectedNetworkError(res); + } + + async fetchDigest( + localOptions?: Pick, + ): Promise { + const ts = getUpdatedAt(this.connection); + const res = await fetch( + `${this.connection.baseUrl}/digest?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions, ts), + cache: this.cacheMode, + }, + ); + + if (res.ok) return res.json() as Promise; + await consumeResponseBody(res); + throw new UnexpectedNetworkError(res); + } +} + +export class Controller { + private cacheManager: CacheManager; + private networkClient: NetworkClient; + private streamManager: StreamManager | null = null; + private connection: Connection; + private maxStale: number; + private preloaded: 'init' | 'loading' | 'loaded' = 'init'; + + constructor( + connection: Connection, + options: EdgeConfigClientOptions & { + enableDevelopmentStream: boolean; + }, + ) { + this.connection = connection; + this.maxStale = options.maxStale ?? DEFAULT_STALE_THRESHOLD; + this.cacheManager = new CacheManager(); + this.networkClient = new NetworkClient( + connection, + options.cache || 'no-store', + ); + + if (options.enableDevelopmentStream && connection.type === 'vercel') { + this.streamManager = new StreamManager(this.cacheManager, connection); + void this.streamManager.init().catch((error) => { + // eslint-disable-next-line no-console -- intentional error logging + console.error('@vercel/edge-config: Stream error', error); + }); + } } private async preload(): Promise { - // if (this.preloaded !== 'init') return; if (this.connection.type !== 'vercel') return; - // the folder won't exist in development, only when deployed - // if (process.env.NODE_ENV === 'development') return; this.preloaded = 'loading'; @@ -183,14 +457,13 @@ export class Controller { if (!mod) return; + const edgeConfig = this.cacheManager.getEdgeConfig(); const hasNewerEntry = - this.edgeConfigCache && - this.edgeConfigCache.updatedAt > mod.default.updatedAt; + edgeConfig && edgeConfig.updatedAt > mod.default.updatedAt; - // skip updating the local cache if there is a newer cache entry already if (hasNewerEntry) return; - this.edgeConfigCache = mod.default; + this.cacheManager.setEdgeConfig(mod.default); } catch (e) { // eslint-disable-next-line no-console -- intentional error logging console.error('@vercel/edge-config: Error reading local edge config', e); @@ -209,56 +482,30 @@ export class Controller { exists: boolean; updatedAt: number; }> { - if (this.stream && this.edgeConfigCache) { + const edgeConfig = this.cacheManager.getEdgeConfig(); + if (this.streamManager && edgeConfig) { const cached = this.readCache( key, 'GET', - this.edgeConfigCache.updatedAt, + edgeConfig.updatedAt, localOptions, ); if (cached) return cached; } - // TODO what does does the logic need to look like here if we have the dev stream - // hold a reference to the timestamp to avoid race conditions const ts = getUpdatedAt(this.connection); if (!ts) { - return this.fetchItem('GET', key, ts, localOptions, true); + return this.fetchAndCacheItem('GET', key, ts, localOptions, true); } - // const ts = this.edgeConfigCache?.updatedAt; - await this.preload(); const cached = this.readCache(key, 'GET', ts, localOptions); if (cached) return cached; - return this.fetchItem('GET', key, ts, localOptions, true); + return this.fetchAndCacheItem('GET', key, ts, localOptions, true); } - /** - * Updates the edge config cache if the new data is newer - */ - private updateEdgeConfigCache( - items: Record, - updatedAt: number | null, - digest: string, - ): void { - if (updatedAt) { - const existing = this.edgeConfigCache; - if (!existing || existing.updatedAt < updatedAt) { - this.edgeConfigCache = { - items, - updatedAt, - digest, - }; - } - } - } - - /** - * Checks the cache and kicks off a background refresh if needed. - */ private readCache( key: string, method: 'GET' | 'HEAD', @@ -271,85 +518,30 @@ export class Controller { exists: boolean; updatedAt: number; } | null { - // only use the cache if we have a timestamp of the latest update - if (timestamp) { - const cached = this.getCachedItem(key, method); - - // console.log('cached', cached); + if (!timestamp) return null; - if (cached) { - const cacheStatus = getCacheStatus( - timestamp, - cached.updatedAt, - this.maxStale, - ); - - // console.log('cacheStatus', cacheStatus, timestamp, cached.updatedAt); - - // HIT - if (cacheStatus === 'HIT') return { ...cached, cache: 'HIT' }; - - // we're outdated, but we can still serve the STALE value - if (cacheStatus === 'STALE') { - // background refresh - after(() => - this.fetchItem( - method, - key, - timestamp, - localOptions, - false, - ).catch(), - ); - - return { ...cached, cache: 'STALE' }; - } - } - } - - // MISS - return null; - } + const cached = this.cacheManager.getItem(key, method); + if (!cached) return null; - /** - * Returns an item from the item cache or edge config cache, depending on - * which value is newer, or null if there is no cached value. - */ - private getCachedItem( - key: string, - method: 'GET' | 'HEAD', - ): CacheEntry | null { - const item = this.itemCache.get(key); - // treat cache entries where we don't know that they exist, but we don't - // know their value yet as a MISS when we are interested in the value - const itemCacheEntry = - method === 'GET' && item?.exists && item.value === undefined - ? undefined - : item; - const cachedConfig = this.edgeConfigCache; - - if (itemCacheEntry && cachedConfig) { - return itemCacheEntry.updatedAt >= cachedConfig.updatedAt - ? (itemCacheEntry as CacheEntry) - : { - digest: cachedConfig.digest, - value: cachedConfig.items[key] as T, - updatedAt: cachedConfig.updatedAt, - exists: Object.hasOwn(cachedConfig.items, key), - }; - } + const cacheStatus = getCacheStatus( + timestamp, + cached.updatedAt, + this.maxStale, + ); - if (itemCacheEntry && !cachedConfig) { - return itemCacheEntry as CacheEntry; - } + if (cacheStatus === 'HIT') return { ...cached, cache: 'HIT' }; - if (!itemCacheEntry && cachedConfig) { - return { - value: cachedConfig.items[key] as T, - updatedAt: cachedConfig.updatedAt, - digest: cachedConfig.digest, - exists: Object.hasOwn(cachedConfig.items, key), - }; + if (cacheStatus === 'STALE') { + after(() => + this.fetchAndCacheItem( + method, + key, + timestamp, + localOptions, + false, + ).catch(), + ); + return { ...cached, cache: 'STALE' }; } return null; @@ -364,55 +556,22 @@ export class Controller { cache: CacheStatus; updatedAt: number; }> { - return this.enhancedFetch( - `${this.connection.baseUrl}/items?version=${this.connection.version}`, - { - headers: this.getHeaders(localOptions, minUpdatedAt), - cache: this.cacheMode, - }, - ).then<{ value: T; digest: string; cache: CacheStatus; updatedAt: number }>( - async ([res, cachedRes]) => { - // on 304s we currently don't get the cached headers back from proxy, - // so we need to check the original response headers - const digest = (cachedRes ?? res).headers.get('x-edge-config-digest'); - const updatedAt = parseTs( - (cachedRes ?? res).headers.get('x-edge-config-updated-at'), - ); - - if (res.status === 500) throw new UnexpectedNetworkError(res); - - if (!updatedAt || !digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - - if (res.status === 401) { - await consumeResponseBody(res); - throw new Error(ERRORS.UNAUTHORIZED); - } - - if (!digest) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - - if (res.ok || (res.status === 304 && cachedRes)) { - const value = (await ( - res.status === 304 && cachedRes ? cachedRes : res - ).json()) as T; - - if (res.status === 304) await consumeResponseBody(res); + const result = await this.networkClient.fetchFullConfig( + minUpdatedAt, + localOptions, + ); - this.updateEdgeConfigCache(value, updatedAt, digest); + this.cacheManager.setEdgeConfig(result); - return { value, digest, cache: 'MISS', updatedAt }; - } - - throw new UnexpectedNetworkError(res); - }, - ); + return { + value: result.items as T, + digest: result.digest, + cache: 'MISS', + updatedAt: result.updatedAt, + }; } - /** - * Loads the item using a GET or check its existence using a HEAD request. - */ - private async fetchItem( + private async fetchAndCacheItem( method: 'GET' | 'HEAD', key: string, minUpdatedAt: number | null, @@ -425,145 +584,42 @@ export class Controller { exists: boolean; updatedAt: number; }> { - return this.enhancedFetch( - `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, - { + try { + const result = await this.networkClient.fetchItem( method, - headers: this.getHeaders(localOptions, minUpdatedAt), - cache: this.cacheMode, - }, - ).then< - { - value: T | undefined; - digest: string; - cache: CacheStatus; - exists: boolean; - updatedAt: number; - }, - { - value: T | undefined; - digest: string; - cache: CacheStatus; - exists: boolean; - updatedAt: number; - } - >( - async ([res, cachedRes]) => { - // on 304s we currently don't get the cached headers back from proxy, - // so we need to check the original response headers - const digest = (cachedRes || res).headers.get('x-edge-config-digest'); - const updatedAt = parseTs( - (cachedRes || res).headers.get('x-edge-config-updated-at'), - ); - - if ( - res.status === 500 || - res.status === 502 || - res.status === 503 || - res.status === 504 - ) { - if (staleIfError) { - const cached = this.getCachedItem(key, method); - if (cached) return { ...cached, cache: 'STALE' }; - } - - throw new UnexpectedNetworkError(res); - } + key, + minUpdatedAt, + localOptions, + ); - if (!digest || !updatedAt) - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - - if (res.ok || (res.status === 304 && cachedRes)) { - // avoid undici memory leaks by consuming response bodies - if (method === 'HEAD') { - after(() => - Promise.all([ - consumeResponseBody(res), - cachedRes ? consumeResponseBody(cachedRes) : null, - ]), - ); - } else if (res.status === 304) { - after(() => consumeResponseBody(res)); - } - - let value: T | undefined; - if (method === 'GET') { - value = (await ( - res.status === 304 && cachedRes ? cachedRes : res - ).json()) as T; - } - - if (updatedAt) { - const existing = this.itemCache.get(key); - if (!existing || existing.updatedAt < updatedAt) { - this.itemCache.set(key, { - value, - updatedAt, - digest, - exists: - method === 'GET' ? value !== undefined : res.status !== 404, - }); - } - } - return { - value, - digest, - cache: 'MISS', - exists: res.status !== 404, - updatedAt, - }; - } + this.cacheManager.setItem( + key, + result.value, + result.updatedAt, + result.digest, + result.exists, + ); - await Promise.all([ - consumeResponseBody(res), - cachedRes ? consumeResponseBody(cachedRes) : null, - ]); - - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - if (res.status === 404) { - if (digest && updatedAt) { - const existing = this.itemCache.get(key); - if (!existing || existing.updatedAt < updatedAt) { - this.itemCache.set(key, { - value: undefined, - updatedAt, - digest, - exists: false, - }); - } - return { - value: undefined, - digest, - cache: 'MISS', - exists: false, - updatedAt, - }; - } - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - throw new UnexpectedNetworkError(res); - }, - (reason) => { - // catch when the fetch call itself throws an error, and handle similar - // to receiving a 5xx response - if (staleIfError) { - const cached = this.getCachedItem(key, method); - if (cached) return Promise.resolve({ ...cached, cache: 'STALE' }); - } - throw reason; - }, - ); + return { ...result, cache: 'MISS' }; + } catch (error) { + if (staleIfError) { + const cached = this.cacheManager.getItem(key, method); + if (cached) return { ...cached, cache: 'STALE' }; + } + throw error; + } } public async has( key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }> { - if (this.stream && this.edgeConfigCache?.updatedAt) { + const edgeConfig = this.cacheManager.getEdgeConfig(); + if (this.streamManager && edgeConfig?.updatedAt) { const cached = this.readCache( key, 'HEAD', - this.edgeConfigCache.updatedAt, + edgeConfig.updatedAt, localOptions, ); if (cached) return cached; @@ -571,7 +627,7 @@ export class Controller { const ts = getUpdatedAt(this.connection); if (!ts) { - return this.fetchItem('HEAD', key, ts, localOptions, true); + return this.fetchAndCacheItem('HEAD', key, ts, localOptions, true); } await this.preload(); @@ -584,32 +640,18 @@ export class Controller { ); if (cached) return cached; - return this.fetchItem('HEAD', key, ts, localOptions, true); + return this.fetchAndCacheItem('HEAD', key, ts, localOptions, true); } public async digest( localOptions?: Pick, ): Promise { - if (this.stream && this.edgeConfigCache) { - const cached = this.edgeConfigCache.digest; - if (cached) return cached; + const edgeConfig = this.cacheManager.getEdgeConfig(); + if (this.streamManager && edgeConfig) { + return edgeConfig.digest; } - const ts = getUpdatedAt(this.connection); - return fetch( - `${this.connection.baseUrl}/digest?version=${this.connection.version}`, - { - headers: this.getHeaders(localOptions, ts), - cache: this.cacheMode, - }, - ).then(async (res) => { - if (res.ok) return res.json() as Promise; - await consumeResponseBody(res); - - // if (res.cachedResponseBody !== undefined) - // return res.cachedResponseBody as string; - throw new UnexpectedNetworkError(res); - }); + return this.networkClient.fetchDigest(localOptions); } public async mget( @@ -629,24 +671,18 @@ export class Controller { (key) => typeof key === 'string' && !isEmptyKey(key), ); - if (this.stream && this.edgeConfigCache) { + const edgeConfig = this.cacheManager.getEdgeConfig(); + if (this.streamManager && edgeConfig) { return { - value: filteredKeys.reduce>((acc, key, index) => { - const item = items[index]; - acc[key as keyof T] = item?.value as T[keyof T]; - return acc; - }, {}) as T, - digest: this.edgeConfigCache.digest, + value: pick(edgeConfig.items, filteredKeys) as T, + digest: edgeConfig.digest, cache: 'HIT', - updatedAt: this.edgeConfigCache.updatedAt, + updatedAt: edgeConfig.updatedAt, }; } const ts = getUpdatedAt(this.connection); - // Return early if there are no keys to be read. - // This is only possible if the digest is not required, or if we have a - // cached digest (not implemented yet). if (!localOptions?.metadata && filteredKeys.length === 0) { return { value: {} as T, @@ -658,26 +694,26 @@ export class Controller { await this.preload(); - const items = filteredKeys.map((key) => this.getCachedItem(key, 'GET')); + const items = filteredKeys.map((key) => + this.cacheManager.getItem(key, 'GET'), + ); const firstItem = items[0]; - // check if the item cache is consistent and has all the requested items const canUseItemCache = firstItem && items.every( (item) => item?.exists && item.value !== undefined && - // ensure we only use the item cache if all items have the same updatedAt item.updatedAt === firstItem.updatedAt, ); - // if the item cache is consistent and newer than the edge config cache, - // we can use it to serve the request + const currentEdgeConfig = this.cacheManager.getEdgeConfig(); + + // First try individual item cache if all items are consistent and newer if ( canUseItemCache && - (!this.edgeConfigCache || - this.edgeConfigCache.updatedAt < firstItem.updatedAt) + (!currentEdgeConfig || currentEdgeConfig.updatedAt < firstItem.updatedAt) ) { const cacheStatus = getCacheStatus( ts, @@ -687,7 +723,6 @@ export class Controller { if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { if (cacheStatus === 'STALE') { - // TODO refresh individual items only? after(() => this.fetchFullConfig(ts, localOptions).catch()); } @@ -704,90 +739,45 @@ export class Controller { } } - // if the edge config cache is filled we can fall back to using it - if (this.edgeConfigCache) { + // Fallback to edge config cache + if (currentEdgeConfig) { const cacheStatus = getCacheStatus( ts, - this.edgeConfigCache.updatedAt, + currentEdgeConfig.updatedAt, this.maxStale, ); if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { if (cacheStatus === 'STALE') { - // TODO refresh individual items only? after(() => this.fetchFullConfig(ts, localOptions).catch()); } return { - value: pick(this.edgeConfigCache.items, filteredKeys) as T, - digest: this.edgeConfigCache.digest, - cache: getCacheStatus( - ts, - this.edgeConfigCache.updatedAt, - this.maxStale, - ), - updatedAt: this.edgeConfigCache.updatedAt, + value: pick(currentEdgeConfig.items, filteredKeys) as T, + digest: currentEdgeConfig.digest, + cache: cacheStatus, + updatedAt: currentEdgeConfig.updatedAt, }; } } - const search = new URLSearchParams( - filteredKeys.map((key) => ['key', key] as [string, string]), - ).toString(); + const result = await this.networkClient.fetchMultipleItems( + filteredKeys, + ts, + localOptions, + ); - return this.enhancedFetch( - `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, - { - headers: this.getHeaders(localOptions, ts), - cache: this.cacheMode, - }, - ).then<{ - value: T; - digest: string; - cache: CacheStatus; - updatedAt: number; - }>(async ([res, cachedRes]) => { - // on 304s we currently don't get the cached headers back from proxy, - // so we need to check the original response headers - const digest = (cachedRes || res).headers.get('x-edge-config-digest'); - const updatedAt = parseTs( - (cachedRes || res).headers.get('x-edge-config-updated-at'), + for (const key of filteredKeys) { + this.cacheManager.setItem( + key, + result.value[key as keyof T], + result.updatedAt, + result.digest, + result.value[key as keyof T] !== undefined, ); + } - if (!updatedAt || !digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - - if (res.ok || (res.status === 304 && cachedRes)) { - if (!digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - const value = (await ( - res.status === 304 && cachedRes ? cachedRes : res - ).json()) as T; - - // fill the itemCache with the new values - for (const key of filteredKeys) { - this.itemCache.set(key, { - value: value[key as keyof T], - updatedAt, - digest, - exists: value[key as keyof T] !== undefined, - }); - } - - return { value, digest, updatedAt, cache: 'MISS' }; - } - await consumeResponseBody(res); - - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - // the /items endpoint never returns 404, so if we get a 404 - // it means the edge config itself did not exist - if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - // if (res.cachedResponseBody !== undefined) - // return res.cachedResponseBody as T; - throw new UnexpectedNetworkError(res); - }); + return { ...result, cache: 'MISS' }; } public async all( @@ -799,64 +789,36 @@ export class Controller { updatedAt: number; }> { const ts = getUpdatedAt(this.connection); - // TODO development mode? await this.preload(); - if (ts && this.edgeConfigCache) { + const edgeConfig = this.cacheManager.getEdgeConfig(); + if (ts && edgeConfig) { const cacheStatus = getCacheStatus( ts, - this.edgeConfigCache.updatedAt, + edgeConfig.updatedAt, this.maxStale, ); - // HIT if (cacheStatus === 'HIT') { return { - value: this.edgeConfigCache.items as T, - digest: this.edgeConfigCache.digest, + value: edgeConfig.items as T, + digest: edgeConfig.digest, cache: 'HIT', - updatedAt: this.edgeConfigCache.updatedAt, + updatedAt: edgeConfig.updatedAt, }; } if (cacheStatus === 'STALE') { - // background refresh after(() => this.fetchFullConfig(ts, localOptions).catch()); return { - value: this.edgeConfigCache.items as T, - digest: this.edgeConfigCache.digest, + value: edgeConfig.items as T, + digest: edgeConfig.digest, cache: 'STALE', - updatedAt: this.edgeConfigCache.updatedAt, + updatedAt: edgeConfig.updatedAt, }; } } return this.fetchFullConfig(ts, localOptions); } - - private getHeaders( - localOptions: EdgeConfigFunctionsOptions | undefined, - minUpdatedAt: number | null, - ): Headers { - const headers: Record = { - Authorization: `Bearer ${this.connection.token}`, - }; - const localHeaders = new Headers(headers); - - if (localOptions?.consistentRead || minUpdatedAt) { - localHeaders.set( - 'x-edge-config-min-updated-at', - `${localOptions?.consistentRead ? Number.MAX_SAFE_INTEGER : minUpdatedAt}`, - ); - } - - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- [@vercel/style-guide@5 migration] - if (typeof process !== 'undefined' && process.env.VERCEL_ENV) - localHeaders.set('x-edge-config-vercel-env', process.env.VERCEL_ENV); - - if (typeof sdkName === 'string' && typeof sdkVersion === 'string') - localHeaders.set('x-edge-config-sdk', `${sdkName}@${sdkVersion}`); - - return localHeaders; - } } From 42f0f9b4fff4ea4d348080bc11e92a3a4eebc48f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 27 Sep 2025 13:20:16 +0300 Subject: [PATCH 73/81] wip --- packages/edge-config/src/controller.ts | 194 ++++++++++++------------- 1 file changed, 93 insertions(+), 101 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index ed9ffdd3f..0b17e14ca 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -33,7 +33,11 @@ function after(fn: () => Promise): void { ); } -function getUpdatedAt(connection: Connection): number | null { +/** + * Reads the updatedAt timestamp of the most recent Edge Config update, + * so we can compare that to what we have in cache. + */ +function getMostRecentUpdateTimestamp(connection: Connection): number | null { const privateEdgeConfig = Reflect.get(globalThis, privateEdgeConfigSymbol) as | { getUpdatedAt: (id: string) => number | null } | undefined; @@ -399,7 +403,7 @@ class NetworkClient { async fetchDigest( localOptions?: Pick, ): Promise { - const ts = getUpdatedAt(this.connection); + const ts = getMostRecentUpdateTimestamp(this.connection); const res = await fetch( `${this.connection.baseUrl}/digest?version=${this.connection.version}`, { @@ -420,7 +424,7 @@ export class Controller { private streamManager: StreamManager | null = null; private connection: Connection; private maxStale: number; - private preloaded: 'init' | 'loading' | 'loaded' = 'init'; + private preloadPromise: Promise | null = null; constructor( connection: Connection, @@ -448,68 +452,45 @@ export class Controller { private async preload(): Promise { if (this.connection.type !== 'vercel') return; - this.preloaded = 'loading'; + // Return existing promise if already loading + if (this.preloadPromise) return this.preloadPromise; - try { - const mod = await readLocalEdgeConfig<{ default: EmbeddedEdgeConfig }>( - this.connection.id, - ); - - if (!mod) return; + // Create and store the promise to prevent concurrent calls + this.preloadPromise = (async () => { + try { + const mod = await readLocalEdgeConfig<{ default: EmbeddedEdgeConfig }>( + this.connection.id, + ); - const edgeConfig = this.cacheManager.getEdgeConfig(); - const hasNewerEntry = - edgeConfig && edgeConfig.updatedAt > mod.default.updatedAt; + if (!mod) return; - if (hasNewerEntry) return; + const edgeConfig = this.cacheManager.getEdgeConfig(); + const hasNewerEntry = + edgeConfig && edgeConfig.updatedAt > mod.default.updatedAt; - this.cacheManager.setEdgeConfig(mod.default); - } catch (e) { - // eslint-disable-next-line no-console -- intentional error logging - console.error('@vercel/edge-config: Error reading local edge config', e); - } finally { - this.preloaded = 'loaded'; - } - } + if (hasNewerEntry) return; - public async get( - key: string, - localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ - value: T | undefined; - digest: string; - cache: CacheStatus; - exists: boolean; - updatedAt: number; - }> { - const edgeConfig = this.cacheManager.getEdgeConfig(); - if (this.streamManager && edgeConfig) { - const cached = this.readCache( - key, - 'GET', - edgeConfig.updatedAt, - localOptions, - ); - if (cached) return cached; - } - - const ts = getUpdatedAt(this.connection); - if (!ts) { - return this.fetchAndCacheItem('GET', key, ts, localOptions, true); - } - - await this.preload(); - - const cached = this.readCache(key, 'GET', ts, localOptions); - if (cached) return cached; + this.cacheManager.setEdgeConfig(mod.default); + } catch (e) { + // eslint-disable-next-line no-console -- intentional error logging + console.error( + '@vercel/edge-config: Error reading local edge config', + e, + ); + } + })(); - return this.fetchAndCacheItem('GET', key, ts, localOptions, true); + return this.preloadPromise; } private readCache( - key: string, method: 'GET' | 'HEAD', - timestamp: number | undefined | null, + key: string, + /** + * The timestamp of the most recent update. + * Read from the headers through the bridge. + */ + mostRecentUpdateTs: number | undefined | null, localOptions: EdgeConfigFunctionsOptions | undefined, ): { value: T | undefined; @@ -518,13 +499,32 @@ export class Controller { exists: boolean; updatedAt: number; } | null { - if (!timestamp) return null; + // TODO we can only trust this if there is no ts, or if there is a ts and an updatedAt info + if (this.streamManager) { + const item = this.cacheManager.getItem(key, method); + // no need to fall back to anything else if there is a stream manager, + if (!item) return null; + + // Only use if no timestamp was given, or if we already have this or a newer entry. + // This prevents us from + if (!mostRecentUpdateTs || item.updatedAt >= mostRecentUpdateTs) { + return { + value: item.value, + digest: item.digest, + cache: 'HIT', + exists: item.exists, + updatedAt: item.updatedAt, + }; + } + } + + if (!mostRecentUpdateTs) return null; const cached = this.cacheManager.getItem(key, method); if (!cached) return null; const cacheStatus = getCacheStatus( - timestamp, + mostRecentUpdateTs, cached.updatedAt, this.maxStale, ); @@ -536,7 +536,7 @@ export class Controller { this.fetchAndCacheItem( method, key, - timestamp, + mostRecentUpdateTs, localOptions, false, ).catch(), @@ -547,7 +547,9 @@ export class Controller { return null; } - private async fetchFullConfig>( + private async fetchAndCacheFullConfig< + T extends Record, + >( minUpdatedAt: number | null, localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ @@ -610,48 +612,38 @@ export class Controller { } } - public async has( + public async get( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }> { - const edgeConfig = this.cacheManager.getEdgeConfig(); - if (this.streamManager && edgeConfig?.updatedAt) { - const cached = this.readCache( - key, - 'HEAD', - edgeConfig.updatedAt, - localOptions, - ); - if (cached) return cached; - } - - const ts = getUpdatedAt(this.connection); - if (!ts) { - return this.fetchAndCacheItem('HEAD', key, ts, localOptions, true); - } - + ): Promise<{ + value: T | undefined; + digest: string; + cache: CacheStatus; + exists: boolean; + updatedAt: number; + }> { await this.preload(); - - const cached = this.readCache( - key, - 'HEAD', - ts, - localOptions, - ); + const ts = getMostRecentUpdateTimestamp(this.connection); + const cached = this.readCache('GET', key, ts, localOptions); if (cached) return cached; - - return this.fetchAndCacheItem('HEAD', key, ts, localOptions, true); + return this.fetchAndCacheItem('GET', key, ts, localOptions, true); } - public async digest( - localOptions?: Pick, - ): Promise { - const edgeConfig = this.cacheManager.getEdgeConfig(); - if (this.streamManager && edgeConfig) { - return edgeConfig.digest; - } - - return this.networkClient.fetchDigest(localOptions); + public async has( + key: string, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T | undefined; + exists: boolean; + digest: string; + cache: CacheStatus; + updatedAt: number; + }> { + await this.preload(); + const ts = getMostRecentUpdateTimestamp(this.connection); + const cached = this.readCache('HEAD', key, ts, localOptions); + if (cached) return cached; + return this.fetchAndCacheItem('HEAD', key, ts, localOptions, true); } public async mget( @@ -681,7 +673,7 @@ export class Controller { }; } - const ts = getUpdatedAt(this.connection); + const ts = getMostRecentUpdateTimestamp(this.connection); if (!localOptions?.metadata && filteredKeys.length === 0) { return { @@ -723,7 +715,7 @@ export class Controller { if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { if (cacheStatus === 'STALE') { - after(() => this.fetchFullConfig(ts, localOptions).catch()); + after(() => this.fetchAndCacheFullConfig(ts, localOptions).catch()); } return { @@ -749,7 +741,7 @@ export class Controller { if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { if (cacheStatus === 'STALE') { - after(() => this.fetchFullConfig(ts, localOptions).catch()); + after(() => this.fetchAndCacheFullConfig(ts, localOptions).catch()); } return { @@ -788,8 +780,8 @@ export class Controller { cache: CacheStatus; updatedAt: number; }> { - const ts = getUpdatedAt(this.connection); await this.preload(); + const ts = getMostRecentUpdateTimestamp(this.connection); const edgeConfig = this.cacheManager.getEdgeConfig(); if (ts && edgeConfig) { @@ -809,7 +801,7 @@ export class Controller { } if (cacheStatus === 'STALE') { - after(() => this.fetchFullConfig(ts, localOptions).catch()); + after(() => this.fetchAndCacheFullConfig(ts, localOptions).catch()); return { value: edgeConfig.items as T, digest: edgeConfig.digest, @@ -819,6 +811,6 @@ export class Controller { } } - return this.fetchFullConfig(ts, localOptions); + return this.fetchAndCacheFullConfig(ts, localOptions); } } From 972365e84f46ece5ce0f2c798f6f4a9038883453 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 29 Sep 2025 11:27:12 +0300 Subject: [PATCH 74/81] refactor --- packages/edge-config/src/controller.test.ts | 2 +- packages/edge-config/src/controller.ts | 398 +++--------------- packages/edge-config/src/index.ts | 3 +- packages/edge-config/src/utils/after.ts | 9 + packages/edge-config/src/utils/errors.ts | 12 + .../src/utils/fetch-with-cached-response.ts | 14 +- packages/edge-config/src/utils/index.ts | 13 - .../edge-config/src/utils/mockable-import.ts | 4 +- .../edge-config/src/utils/network-client.ts | 244 +++++++++++ .../src/utils/pick-newest-edge-config.ts | 11 + packages/edge-config/src/utils/readers.ts | 46 ++ .../edge-config/src/utils/stream-manager.ts | 66 +++ packages/edge-config/src/utils/timestamps.ts | 27 ++ 13 files changed, 477 insertions(+), 372 deletions(-) create mode 100644 packages/edge-config/src/utils/after.ts create mode 100644 packages/edge-config/src/utils/errors.ts create mode 100644 packages/edge-config/src/utils/network-client.ts create mode 100644 packages/edge-config/src/utils/pick-newest-edge-config.ts create mode 100644 packages/edge-config/src/utils/readers.ts create mode 100644 packages/edge-config/src/utils/stream-manager.ts create mode 100644 packages/edge-config/src/utils/timestamps.ts diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 4a180fb3d..8c0aec392 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -1,6 +1,6 @@ import fetchMock from 'jest-fetch-mock'; import { version } from '../package.json'; -import { Controller } from './controller'; +import { Controller } from './controller2'; import type { Connection } from './types'; import { readLocalEdgeConfig } from './utils/mockable-import'; diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 0b17e14ca..15331edac 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -1,7 +1,3 @@ -import { waitUntil } from '@vercel/functions'; -import { createEventSource, type EventSourceClient } from 'eventsource-client'; -import { name as sdkName, version as sdkVersion } from '../package.json'; -import { readLocalEdgeConfig } from './utils/mockable-import'; import type { EdgeConfigValue, EmbeddedEdgeConfig, @@ -11,12 +7,17 @@ import type { CacheStatus, EdgeConfigItems, } from './types'; -import { ERRORS, isEmptyKey, pick, UnexpectedNetworkError } from './utils'; -import { consumeResponseBody } from './utils/consume-response-body'; -import { createEnhancedFetch } from './utils/fetch-with-cached-response'; +import { isEmptyKey, pick } from './utils'; +import { + getBuildEmbeddedEdgeConfig, + getLayeredEdgeConfig, +} from './utils/readers'; +import { after } from './utils/after'; +import { StreamManager } from './utils/stream-manager'; +import { getMostRecentUpdateTimestamp } from './utils/timestamps'; +import { NetworkClient } from './utils/network-client'; const DEFAULT_STALE_THRESHOLD = 10; -const privateEdgeConfigSymbol = Symbol.for('privateEdgeConfig'); interface CacheEntry { value: T | undefined; @@ -25,36 +26,6 @@ interface CacheEntry { exists: boolean; } -function after(fn: () => Promise): void { - waitUntil( - new Promise((resolve) => { - setTimeout(resolve, 0); - }).then(() => fn()), - ); -} - -/** - * Reads the updatedAt timestamp of the most recent Edge Config update, - * so we can compare that to what we have in cache. - */ -function getMostRecentUpdateTimestamp(connection: Connection): number | null { - const privateEdgeConfig = Reflect.get(globalThis, privateEdgeConfigSymbol) as - | { getUpdatedAt: (id: string) => number | null } - | undefined; - - return typeof privateEdgeConfig === 'object' && - typeof privateEdgeConfig.getUpdatedAt === 'function' - ? privateEdgeConfig.getUpdatedAt(connection.id) - : null; -} - -function parseTs(updatedAt: string | null): number | null { - if (!updatedAt) return null; - const parsed = Number.parseInt(updatedAt, 10); - if (Number.isNaN(parsed)) return null; - return parsed; -} - function getCacheStatus( latestUpdate: number | null, updatedAt: number, @@ -136,301 +107,17 @@ class CacheManager { } } -class StreamManager { - private stream: EventSourceClient | null = null; - private cacheManager: CacheManager; - private connection: Connection; - - constructor(cacheManager: CacheManager, connection: Connection) { - this.cacheManager = cacheManager; - this.connection = connection; - } - - async init(): Promise { - const currentEdgeConfig = this.cacheManager.getEdgeConfig(); - - this.stream = createEventSource({ - url: `https://api.vercel.com/v1/edge-config/${this.connection.id}/stream`, - headers: { - Authorization: `Bearer ${this.connection.token}`, - ...(currentEdgeConfig?.updatedAt - ? { 'x-edge-config-updated-at': String(currentEdgeConfig.updatedAt) } - : {}), - }, - }); - - for await (const { data, event } of this.stream) { - if (event === 'info' && data === 'token_invalidated') { - this.stream.close(); - return; - } - - if (event === 'embed') { - try { - const parsedEdgeConfig = JSON.parse(data) as EmbeddedEdgeConfig; - this.cacheManager.setEdgeConfig(parsedEdgeConfig); - } catch (e) { - // eslint-disable-next-line no-console -- intentional error logging - console.error( - '@vercel/edge-config: Error parsing streamed edge config', - e, - ); - } - } - } - - this.stream.close(); - } - - close(): void { - this.stream?.close(); - } -} - -class NetworkClient { - private enhancedFetch: ReturnType; - private connection: Connection; - private cacheMode: 'no-store' | 'force-cache'; - - constructor(connection: Connection, cacheMode: 'no-store' | 'force-cache') { - this.connection = connection; - this.cacheMode = cacheMode; - this.enhancedFetch = createEnhancedFetch(); - } - - private getHeaders( - localOptions: EdgeConfigFunctionsOptions | undefined, - minUpdatedAt: number | null, - ): Headers { - const headers: Record = { - Authorization: `Bearer ${this.connection.token}`, - }; - const localHeaders = new Headers(headers); - - if (localOptions?.consistentRead || minUpdatedAt) { - localHeaders.set( - 'x-edge-config-min-updated-at', - `${localOptions?.consistentRead ? Number.MAX_SAFE_INTEGER : minUpdatedAt}`, - ); - } - - if (process.env.VERCEL_ENV) { - localHeaders.set('x-edge-config-vercel-env', process.env.VERCEL_ENV); - } - - if (typeof sdkName === 'string' && typeof sdkVersion === 'string') { - localHeaders.set('x-edge-config-sdk', `${sdkName}@${sdkVersion}`); - } - - return localHeaders; - } - - async fetchItem( - method: 'GET' | 'HEAD', - key: string, - minUpdatedAt: number | null, - localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ - value: T | undefined; - digest: string; - exists: boolean; - updatedAt: number; - }> { - const [res, cachedRes] = await this.enhancedFetch( - `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, - { - method, - headers: this.getHeaders(localOptions, minUpdatedAt), - cache: this.cacheMode, - }, - ); - - const digest = (cachedRes || res).headers.get('x-edge-config-digest'); - const updatedAt = parseTs( - (cachedRes || res).headers.get('x-edge-config-updated-at'), - ); - - if ( - res.status === 500 || - res.status === 502 || - res.status === 503 || - res.status === 504 - ) { - await Promise.all([ - consumeResponseBody(res), - cachedRes ? consumeResponseBody(cachedRes) : null, - ]); - throw new UnexpectedNetworkError(res); - } - - if (!digest || !updatedAt) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - - if (res.ok || (res.status === 304 && cachedRes)) { - if (method === 'HEAD') { - after(() => - Promise.all([ - consumeResponseBody(res), - cachedRes ? consumeResponseBody(cachedRes) : null, - ]), - ); - } else if (res.status === 304) { - after(() => consumeResponseBody(res)); - } - - let value: T | undefined; - if (method === 'GET') { - value = (await ( - res.status === 304 && cachedRes ? cachedRes : res - ).json()) as T; - } - - return { - value, - digest, - exists: res.status !== 404, - updatedAt, - }; - } - - await Promise.all([ - consumeResponseBody(res), - cachedRes ? consumeResponseBody(cachedRes) : null, - ]); - - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - if (res.status === 404) { - if (digest && updatedAt) { - return { - value: undefined, - digest, - exists: false, - updatedAt, - }; - } - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - throw new UnexpectedNetworkError(res); - } - - async fetchFullConfig>( - minUpdatedAt: number | null, - localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const [res, cachedRes] = await this.enhancedFetch( - `${this.connection.baseUrl}/items?version=${this.connection.version}`, - { - headers: this.getHeaders(localOptions, minUpdatedAt), - cache: this.cacheMode, - }, - ); - - const digest = (cachedRes ?? res).headers.get('x-edge-config-digest'); - const updatedAt = parseTs( - (cachedRes ?? res).headers.get('x-edge-config-updated-at'), - ); - - if (res.status === 500) throw new UnexpectedNetworkError(res); - - if (!updatedAt || !digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - - if (res.status === 401) { - await consumeResponseBody(res); - throw new Error(ERRORS.UNAUTHORIZED); - } - - if (res.ok || (res.status === 304 && cachedRes)) { - const value = (await ( - res.status === 304 && cachedRes ? cachedRes : res - ).json()) as ItemsType; - - if (res.status === 304) await consumeResponseBody(res); - - return { items: value, digest, updatedAt }; - } - - throw new UnexpectedNetworkError(res); - } - - async fetchMultipleItems( - keys: string[], - minUpdatedAt: number | null, - localOptions?: EdgeConfigFunctionsOptions, - ): Promise<{ - value: ItemsType; - digest: string; - updatedAt: number; - }> { - const search = new URLSearchParams( - keys.map((key) => ['key', key] as [string, string]), - ).toString(); - - const [res, cachedRes] = await this.enhancedFetch( - `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, - { - headers: this.getHeaders(localOptions, minUpdatedAt), - cache: this.cacheMode, - }, - ); - - const digest = (cachedRes || res).headers.get('x-edge-config-digest'); - const updatedAt = parseTs( - (cachedRes || res).headers.get('x-edge-config-updated-at'), - ); - - if (!updatedAt || !digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - - if (res.ok || (res.status === 304 && cachedRes)) { - if (!digest) { - throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - } - const value = (await ( - res.status === 304 && cachedRes ? cachedRes : res - ).json()) as ItemsType; - - return { value, digest, updatedAt }; - } - await consumeResponseBody(res); - - if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); - if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); - throw new UnexpectedNetworkError(res); - } - - async fetchDigest( - localOptions?: Pick, - ): Promise { - const ts = getMostRecentUpdateTimestamp(this.connection); - const res = await fetch( - `${this.connection.baseUrl}/digest?version=${this.connection.version}`, - { - headers: this.getHeaders(localOptions, ts), - cache: this.cacheMode, - }, - ); - - if (res.ok) return res.json() as Promise; - await consumeResponseBody(res); - throw new UnexpectedNetworkError(res); - } -} - export class Controller { private cacheManager: CacheManager; private networkClient: NetworkClient; private streamManager: StreamManager | null = null; private connection: Connection; private maxStale: number; - private preloadPromise: Promise | null = null; + private preloadPromise: Promise | null = null; constructor( connection: Connection, - options: EdgeConfigClientOptions & { - enableDevelopmentStream: boolean; - }, + options: EdgeConfigClientOptions & { enableStream: boolean }, ) { this.connection = connection; this.maxStale = options.maxStale ?? DEFAULT_STALE_THRESHOLD; @@ -440,44 +127,53 @@ export class Controller { options.cache || 'no-store', ); - if (options.enableDevelopmentStream && connection.type === 'vercel') { - this.streamManager = new StreamManager(this.cacheManager, connection); - void this.streamManager.init().catch((error) => { - // eslint-disable-next-line no-console -- intentional error logging - console.error('@vercel/edge-config: Stream error', error); + if (options.enableStream && connection.type === 'vercel') { + this.streamManager = new StreamManager(connection, (edgeConfig) => { + this.cacheManager.setEdgeConfig(edgeConfig); }); + void this.streamManager + .init(this.preload(), () => this.cacheManager.getEdgeConfig()) + .catch((error) => { + // eslint-disable-next-line no-console -- intentional error logging + console.error('@vercel/edge-config: Stream error', error); + }); } } - private async preload(): Promise { - if (this.connection.type !== 'vercel') return; + /** + * Preloads the Edge Config from the build time embed or from the layer. + * + * Races the load of the embedded and layered Edge Configs, and also + * refreshes in the background in case there was a race winner. + * + * We basically try to return a valid result (ts) as early as possible, while + * also making sure we update to the later version if there is one. + */ + private async preload(): Promise { + if (this.connection.type !== 'vercel') return null; // Return existing promise if already loading if (this.preloadPromise) return this.preloadPromise; // Create and store the promise to prevent concurrent calls this.preloadPromise = (async () => { - try { - const mod = await readLocalEdgeConfig<{ default: EmbeddedEdgeConfig }>( - this.connection.id, - ); - - if (!mod) return; - - const edgeConfig = this.cacheManager.getEdgeConfig(); - const hasNewerEntry = - edgeConfig && edgeConfig.updatedAt > mod.default.updatedAt; - - if (hasNewerEntry) return; - - this.cacheManager.setEdgeConfig(mod.default); - } catch (e) { - // eslint-disable-next-line no-console -- intentional error logging - console.error( - '@vercel/edge-config: Error reading local edge config', - e, - ); + // The layered Edge Config is always going to be newer than the embedded one, + // so we check it first and only fall back to the embedded one. + const layeredEdgeConfig = await getLayeredEdgeConfig(this.connection.id); + if (layeredEdgeConfig) { + this.cacheManager.setEdgeConfig(layeredEdgeConfig); + return layeredEdgeConfig; } + + const buildEdgeConfig = await getBuildEmbeddedEdgeConfig( + this.connection.id, + ); + if (buildEdgeConfig) { + this.cacheManager.setEdgeConfig(buildEdgeConfig); + return buildEdgeConfig; + } + + return null; })(); return this.preloadPromise; diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index b14a59ce8..c6793d724 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -12,6 +12,7 @@ import { trace } from './utils/tracing'; import { Controller } from './controller'; export { setTracerProvider } from './utils/tracing'; +export { ERRORS, UnexpectedNetworkError } from './utils/errors'; export { parseConnectionString, @@ -70,7 +71,7 @@ export const createClient = trace( const controller = new Controller(connection, { ...options, - enableDevelopmentStream: shouldUseDevelopmentStream, + enableStream: shouldUseDevelopmentStream, }); const edgeConfigId = connection.id; diff --git a/packages/edge-config/src/utils/after.ts b/packages/edge-config/src/utils/after.ts new file mode 100644 index 000000000..5fee86981 --- /dev/null +++ b/packages/edge-config/src/utils/after.ts @@ -0,0 +1,9 @@ +import { waitUntil } from '@vercel/functions'; + +export function after(fn: () => Promise): void { + waitUntil( + new Promise((resolve) => { + setTimeout(resolve, 0); + }).then(() => fn()), + ); +} diff --git a/packages/edge-config/src/utils/errors.ts b/packages/edge-config/src/utils/errors.ts new file mode 100644 index 000000000..cf74e5d7f --- /dev/null +++ b/packages/edge-config/src/utils/errors.ts @@ -0,0 +1,12 @@ +export const ERRORS = { + UNAUTHORIZED: '@vercel/edge-config: Unauthorized', + EDGE_CONFIG_NOT_FOUND: '@vercel/edge-config: Edge Config not found', +}; + +export class UnexpectedNetworkError extends Error { + constructor(res: Response) { + super( + `@vercel/edge-config: Unexpected error due to response with status code ${res.status}`, + ); + } +} diff --git a/packages/edge-config/src/utils/fetch-with-cached-response.ts b/packages/edge-config/src/utils/fetch-with-cached-response.ts index dfa142265..a2d6872dd 100644 --- a/packages/edge-config/src/utils/fetch-with-cached-response.ts +++ b/packages/edge-config/src/utils/fetch-with-cached-response.ts @@ -11,14 +11,18 @@ function getDedupeCacheKey(url: string, init?: RequestInit): string { ? init.headers : new Headers(init?.headers); - return JSON.stringify({ + // should be faster than JSON.stringify + return [ url, - method: init?.method, - authorization: h.get('Authorization'), - minUpdatedAt: h.get('x-edge-config-min-updated-at'), - }); + init?.method?.toUpperCase() ?? 'GET', + h.get('Authorization') ?? '', + h.get('x-edge-config-min-updated-at') ?? '', + ].join('\n'); } +/** + * Like `fetch`, but with an http etag cache and deduplication. + */ export function createEnhancedFetch(): ( url: string, options?: RequestInit, diff --git a/packages/edge-config/src/utils/index.ts b/packages/edge-config/src/utils/index.ts index 98697cc25..8f868bc92 100644 --- a/packages/edge-config/src/utils/index.ts +++ b/packages/edge-config/src/utils/index.ts @@ -1,19 +1,6 @@ import type { Connection } from '../types'; import { trace } from './tracing'; -export const ERRORS = { - UNAUTHORIZED: '@vercel/edge-config: Unauthorized', - EDGE_CONFIG_NOT_FOUND: '@vercel/edge-config: Edge Config not found', -}; - -export class UnexpectedNetworkError extends Error { - constructor(res: Response) { - super( - `@vercel/edge-config: Unexpected error due to response with status code ${res.status}`, - ); - } -} - /** * Checks if an object has a property */ diff --git a/packages/edge-config/src/utils/mockable-import.ts b/packages/edge-config/src/utils/mockable-import.ts index 8702f8989..fb40d4f4c 100644 --- a/packages/edge-config/src/utils/mockable-import.ts +++ b/packages/edge-config/src/utils/mockable-import.ts @@ -3,7 +3,9 @@ * * We currently use webpackIgnore to avoid bundling the local edge config. */ -export async function readLocalEdgeConfig(id: string): Promise { +export async function readBuildEmbeddedEdgeConfig( + id: string, +): Promise { try { return (await import( /* webpackIgnore: true */ `@vercel/edge-config/stores/${id}.json` diff --git a/packages/edge-config/src/utils/network-client.ts b/packages/edge-config/src/utils/network-client.ts new file mode 100644 index 000000000..85ab6816f --- /dev/null +++ b/packages/edge-config/src/utils/network-client.ts @@ -0,0 +1,244 @@ +import { name as sdkName, version as sdkVersion } from '../../package.json'; +import type { + Connection, + EdgeConfigFunctionsOptions, + EdgeConfigItems, + EdgeConfigValue, + EmbeddedEdgeConfig, +} from '../types'; +import { consumeResponseBody } from './consume-response-body'; +import { createEnhancedFetch } from './fetch-with-cached-response'; +import { getMostRecentUpdateTimestamp, parseTs } from './timestamps'; +import { after } from './after'; +import { ERRORS, UnexpectedNetworkError } from './errors'; + +export class NetworkClient { + private enhancedFetch: ReturnType; + private connection: Connection; + private cacheMode: 'no-store' | 'force-cache'; + + constructor(connection: Connection, cacheMode: 'no-store' | 'force-cache') { + this.connection = connection; + this.cacheMode = cacheMode; + this.enhancedFetch = createEnhancedFetch(); + } + + private getHeaders( + localOptions: EdgeConfigFunctionsOptions | undefined, + minUpdatedAt: number | null, + ): Headers { + const headers: Record = { + Authorization: `Bearer ${this.connection.token}`, + }; + const localHeaders = new Headers(headers); + + if (localOptions?.consistentRead || minUpdatedAt) { + localHeaders.set( + 'x-edge-config-min-updated-at', + `${localOptions?.consistentRead ? Number.MAX_SAFE_INTEGER : minUpdatedAt}`, + ); + } + + if (process.env.VERCEL_ENV) { + localHeaders.set('x-edge-config-vercel-env', process.env.VERCEL_ENV); + } + + if (typeof sdkName === 'string' && typeof sdkVersion === 'string') { + localHeaders.set('x-edge-config-sdk', `${sdkName}@${sdkVersion}`); + } + + return localHeaders; + } + + async fetchItem( + method: 'GET' | 'HEAD', + key: string, + minUpdatedAt: number | null, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T | undefined; + digest: string; + exists: boolean; + updatedAt: number; + }> { + const [res, cachedRes] = await this.enhancedFetch( + `${this.connection.baseUrl}/item/${key}?version=${this.connection.version}`, + { + method, + headers: this.getHeaders(localOptions, minUpdatedAt), + cache: this.cacheMode, + }, + ); + + const digest = (cachedRes || res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes || res).headers.get('x-edge-config-updated-at'), + ); + + if ( + res.status === 500 || + res.status === 502 || + res.status === 503 || + res.status === 504 + ) { + await Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]); + throw new UnexpectedNetworkError(res); + } + + if (!digest || !updatedAt) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + + if (res.ok || (res.status === 304 && cachedRes)) { + if (method === 'HEAD') { + after(() => + Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]), + ); + } else if (res.status === 304) { + after(() => consumeResponseBody(res)); + } + + let value: T | undefined; + if (method === 'GET') { + value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as T; + } + + return { + value, + digest, + exists: res.status !== 404, + updatedAt, + }; + } + + await Promise.all([ + consumeResponseBody(res), + cachedRes ? consumeResponseBody(cachedRes) : null, + ]); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + if (res.status === 404) { + if (digest && updatedAt) { + return { + value: undefined, + digest, + exists: false, + updatedAt, + }; + } + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + throw new UnexpectedNetworkError(res); + } + + async fetchFullConfig>( + minUpdatedAt: number | null, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise { + const [res, cachedRes] = await this.enhancedFetch( + `${this.connection.baseUrl}/items?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions, minUpdatedAt), + cache: this.cacheMode, + }, + ); + + const digest = (cachedRes ?? res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes ?? res).headers.get('x-edge-config-updated-at'), + ); + + if (res.status === 500) throw new UnexpectedNetworkError(res); + + if (!updatedAt || !digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + + if (res.status === 401) { + await consumeResponseBody(res); + throw new Error(ERRORS.UNAUTHORIZED); + } + + if (res.ok || (res.status === 304 && cachedRes)) { + const value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as ItemsType; + + if (res.status === 304) await consumeResponseBody(res); + + return { items: value, digest, updatedAt }; + } + + throw new UnexpectedNetworkError(res); + } + + async fetchMultipleItems( + keys: string[], + minUpdatedAt: number | null, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: ItemsType; + digest: string; + updatedAt: number; + }> { + const search = new URLSearchParams( + keys.map((key) => ['key', key] as [string, string]), + ).toString(); + + const [res, cachedRes] = await this.enhancedFetch( + `${this.connection.baseUrl}/items?version=${this.connection.version}&${search}`, + { + headers: this.getHeaders(localOptions, minUpdatedAt), + cache: this.cacheMode, + }, + ); + + const digest = (cachedRes || res).headers.get('x-edge-config-digest'); + const updatedAt = parseTs( + (cachedRes || res).headers.get('x-edge-config-updated-at'), + ); + + if (!updatedAt || !digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + + if (res.ok || (res.status === 304 && cachedRes)) { + if (!digest) { + throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + } + const value = (await ( + res.status === 304 && cachedRes ? cachedRes : res + ).json()) as ItemsType; + + return { value, digest, updatedAt }; + } + await consumeResponseBody(res); + + if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED); + if (res.status === 404) throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND); + throw new UnexpectedNetworkError(res); + } + + async fetchDigest( + localOptions?: Pick, + ): Promise { + const ts = getMostRecentUpdateTimestamp(this.connection); + const res = await fetch( + `${this.connection.baseUrl}/digest?version=${this.connection.version}`, + { + headers: this.getHeaders(localOptions, ts), + cache: this.cacheMode, + }, + ); + + if (res.ok) return res.json() as Promise; + await consumeResponseBody(res); + throw new UnexpectedNetworkError(res); + } +} diff --git a/packages/edge-config/src/utils/pick-newest-edge-config.ts b/packages/edge-config/src/utils/pick-newest-edge-config.ts new file mode 100644 index 000000000..28cd1ab56 --- /dev/null +++ b/packages/edge-config/src/utils/pick-newest-edge-config.ts @@ -0,0 +1,11 @@ +import type { EmbeddedEdgeConfig } from '../types'; + +export function pickNewestEdgeConfig( + edgeConfigs: (EmbeddedEdgeConfig | null)[], +): EmbeddedEdgeConfig | null { + return edgeConfigs.reduce((acc, edgeConfig) => { + if (!edgeConfig) return acc; + if (!acc) return edgeConfig; + return edgeConfig.updatedAt > acc.updatedAt ? edgeConfig : acc; + }, null); +} diff --git a/packages/edge-config/src/utils/readers.ts b/packages/edge-config/src/utils/readers.ts new file mode 100644 index 000000000..5e41abff8 --- /dev/null +++ b/packages/edge-config/src/utils/readers.ts @@ -0,0 +1,46 @@ +// we can't use node:fs/promises here, for known reasons +// import { readFile } from 'node:fs'; +// import { promisify } from 'node:util'; +import type { EmbeddedEdgeConfig } from '../types'; +import { readBuildEmbeddedEdgeConfig } from './mockable-import'; + +/** + * Reads the Edge Config that got embedded at build time. + * + * Bundlers will often use a lazy strategy where including the module runs + * a JSON.parse on its content, so we need to be aware of the performance here. + */ +export async function getBuildEmbeddedEdgeConfig( + edgeConfigId: string, +): Promise { + try { + const mod = await readBuildEmbeddedEdgeConfig<{ + default: EmbeddedEdgeConfig; + }>(edgeConfigId); + return mod ? mod.default : null; + } catch (e) { + // eslint-disable-next-line no-console -- intentional error logging + console.error('@vercel/edge-config: Error reading local edge config', e); + return null; + } +} + +/** + * Reads the Edge Config through the bridge from the layer. + */ +export async function getLayeredEdgeConfig( + _edgeConfigId: string, +): Promise { + // TODO implement reading the fs + // const readFileAsync = promisify(readFile); + // try { + // const data = await readFileAsync( + // `/opt/edge-config/${edgeConfigId}.json`, + // 'utf-8', + // ); + // return JSON.parse(data) as EmbeddedEdgeConfig; + // } catch (e) { + // return null; + // } + return Promise.resolve(null); +} diff --git a/packages/edge-config/src/utils/stream-manager.ts b/packages/edge-config/src/utils/stream-manager.ts new file mode 100644 index 000000000..e985dbbf7 --- /dev/null +++ b/packages/edge-config/src/utils/stream-manager.ts @@ -0,0 +1,66 @@ +import { createEventSource, type EventSourceClient } from 'eventsource-client'; +import type { EmbeddedEdgeConfig, Connection } from '../types'; +import { pickNewestEdgeConfig } from './pick-newest-edge-config'; + +export class StreamManager { + private stream: EventSourceClient | null = null; + private connection: Connection; + private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void; + + constructor( + connection: Connection, + onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void, + ) { + this.connection = connection; + this.onEdgeConfig = onEdgeConfig; + } + + async init( + preloadPromise: Promise, + getEdgeConfig: () => EmbeddedEdgeConfig | null, + ): Promise { + const preloadedEdgeConfig = await preloadPromise; + const instanceEdgeConfig = getEdgeConfig(); + + const edgeConfig = pickNewestEdgeConfig([ + preloadedEdgeConfig, + instanceEdgeConfig, + ]); + + this.stream = createEventSource({ + url: `https://api.vercel.com/v1/edge-config/${this.connection.id}/stream`, + headers: { + Authorization: `Bearer ${this.connection.token}`, + ...(edgeConfig?.updatedAt + ? { 'x-edge-config-updated-at': String(edgeConfig.updatedAt) } + : {}), + }, + }); + + for await (const { data, event } of this.stream) { + if (event === 'info' && data === 'token_invalidated') { + this.stream.close(); + return; + } + + if (event === 'embed') { + try { + const parsedEdgeConfig = JSON.parse(data) as EmbeddedEdgeConfig; + this.onEdgeConfig(parsedEdgeConfig); + } catch (e) { + // eslint-disable-next-line no-console -- intentional error logging + console.error( + '@vercel/edge-config: Error parsing streamed edge config', + e, + ); + } + } + } + + this.stream.close(); + } + + close(): void { + this.stream?.close(); + } +} diff --git a/packages/edge-config/src/utils/timestamps.ts b/packages/edge-config/src/utils/timestamps.ts new file mode 100644 index 000000000..780798a0a --- /dev/null +++ b/packages/edge-config/src/utils/timestamps.ts @@ -0,0 +1,27 @@ +import type { Connection } from '../types'; + +const privateEdgeConfigSymbol = Symbol.for('privateEdgeConfig'); + +/** + * Reads the updatedAt timestamp of the most recent Edge Config update, + * so we can compare that to what we have in cache. + */ +export function getMostRecentUpdateTimestamp( + connection: Connection, +): number | null { + const privateEdgeConfig = Reflect.get(globalThis, privateEdgeConfigSymbol) as + | { getUpdatedAt: (id: string) => number | null } + | undefined; + + return typeof privateEdgeConfig === 'object' && + typeof privateEdgeConfig.getUpdatedAt === 'function' + ? privateEdgeConfig.getUpdatedAt(connection.id) + : null; +} + +export function parseTs(updatedAt: string | null): number | null { + if (!updatedAt) return null; + const parsed = Number.parseInt(updatedAt, 10); + if (Number.isNaN(parsed)) return null; + return parsed; +} From 08a945bbb006f2092fb7242901c7f806884ded65 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 29 Sep 2025 11:35:11 +0300 Subject: [PATCH 75/81] extract cache-manager --- packages/edge-config/src/controller.ts | 74 +------------------ .../edge-config/src/utils/cache-manager.ts | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+), 73 deletions(-) create mode 100644 packages/edge-config/src/utils/cache-manager.ts diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 15331edac..f6daeffa6 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -16,16 +16,10 @@ import { after } from './utils/after'; import { StreamManager } from './utils/stream-manager'; import { getMostRecentUpdateTimestamp } from './utils/timestamps'; import { NetworkClient } from './utils/network-client'; +import { CacheManager } from './utils/cache-manager'; const DEFAULT_STALE_THRESHOLD = 10; -interface CacheEntry { - value: T | undefined; - updatedAt: number; - digest: string; - exists: boolean; -} - function getCacheStatus( latestUpdate: number | null, updatedAt: number, @@ -41,72 +35,6 @@ function getCacheStatus( return 'MISS'; } -class CacheManager { - private itemCache = new Map(); - private edgeConfigCache: EmbeddedEdgeConfig | null = null; - - setItem( - key: string, - value: T | undefined, - updatedAt: number, - digest: string, - exists: boolean, - ): void { - const existing = this.itemCache.get(key); - if (existing && existing.updatedAt >= updatedAt) return; - this.itemCache.set(key, { value, updatedAt, digest, exists }); - } - - setEdgeConfig(next: EmbeddedEdgeConfig): void { - if (!next.updatedAt) return; - const existing = this.edgeConfigCache; - if (existing && existing.updatedAt >= next.updatedAt) return; - this.edgeConfigCache = next; - } - - getItem( - key: string, - method: 'GET' | 'HEAD', - ): CacheEntry | null { - const item = this.itemCache.get(key); - const itemCacheEntry = - method === 'GET' && item?.exists && item.value === undefined - ? undefined - : item; - const cachedConfig = this.edgeConfigCache; - - if (itemCacheEntry && cachedConfig) { - return itemCacheEntry.updatedAt >= cachedConfig.updatedAt - ? (itemCacheEntry as CacheEntry) - : { - digest: cachedConfig.digest, - value: cachedConfig.items[key] as T, - updatedAt: cachedConfig.updatedAt, - exists: Object.hasOwn(cachedConfig.items, key), - }; - } - - if (itemCacheEntry && !cachedConfig) { - return itemCacheEntry as CacheEntry; - } - - if (!itemCacheEntry && cachedConfig) { - return { - value: cachedConfig.items[key] as T, - updatedAt: cachedConfig.updatedAt, - digest: cachedConfig.digest, - exists: Object.hasOwn(cachedConfig.items, key), - }; - } - - return null; - } - - getEdgeConfig(): EmbeddedEdgeConfig | null { - return this.edgeConfigCache; - } -} - export class Controller { private cacheManager: CacheManager; private networkClient: NetworkClient; diff --git a/packages/edge-config/src/utils/cache-manager.ts b/packages/edge-config/src/utils/cache-manager.ts new file mode 100644 index 000000000..9df029bb4 --- /dev/null +++ b/packages/edge-config/src/utils/cache-manager.ts @@ -0,0 +1,74 @@ +import type { EdgeConfigValue, EmbeddedEdgeConfig } from '../types'; + +interface CacheEntry { + value: T | undefined; + updatedAt: number; + digest: string; + exists: boolean; +} + +export class CacheManager { + private itemCache = new Map(); + private edgeConfigCache: EmbeddedEdgeConfig | null = null; + + setItem( + key: string, + value: T | undefined, + updatedAt: number, + digest: string, + exists: boolean, + ): void { + const existing = this.itemCache.get(key); + if (existing && existing.updatedAt >= updatedAt) return; + this.itemCache.set(key, { value, updatedAt, digest, exists }); + } + + setEdgeConfig(next: EmbeddedEdgeConfig): void { + if (!next.updatedAt) return; + const existing = this.edgeConfigCache; + if (existing && existing.updatedAt >= next.updatedAt) return; + this.edgeConfigCache = next; + } + + getItem( + key: string, + method: 'GET' | 'HEAD', + ): CacheEntry | null { + const item = this.itemCache.get(key); + const itemCacheEntry = + method === 'GET' && item?.exists && item.value === undefined + ? undefined + : item; + const cachedConfig = this.edgeConfigCache; + + if (itemCacheEntry && cachedConfig) { + return itemCacheEntry.updatedAt >= cachedConfig.updatedAt + ? (itemCacheEntry as CacheEntry) + : { + digest: cachedConfig.digest, + value: cachedConfig.items[key] as T, + updatedAt: cachedConfig.updatedAt, + exists: Object.hasOwn(cachedConfig.items, key), + }; + } + + if (itemCacheEntry && !cachedConfig) { + return itemCacheEntry as CacheEntry; + } + + if (!itemCacheEntry && cachedConfig) { + return { + value: cachedConfig.items[key] as T, + updatedAt: cachedConfig.updatedAt, + digest: cachedConfig.digest, + exists: Object.hasOwn(cachedConfig.items, key), + }; + } + + return null; + } + + getEdgeConfig(): EmbeddedEdgeConfig | null { + return this.edgeConfigCache; + } +} From eb5c39cfa6cac4c68ddbf4da2ebed579d40d4fe4 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 30 Sep 2025 07:48:15 +0300 Subject: [PATCH 76/81] wip --- packages/edge-config/src/controller.test.ts | 20 ++++---- packages/edge-config/src/controller.ts | 25 ++++++++-- .../edge-config/src/utils/stream-manager.ts | 47 +++++++++++++++++-- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index 8c0aec392..a188939aa 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -1,15 +1,15 @@ import fetchMock from 'jest-fetch-mock'; import { version } from '../package.json'; -import { Controller } from './controller2'; +import { Controller } from './controller'; import type { Connection } from './types'; -import { readLocalEdgeConfig } from './utils/mockable-import'; +import { readBuildEmbeddedEdgeConfig } from './utils/mockable-import'; const packageVersion = `@vercel/edge-config@${version}`; jest.useFakeTimers(); jest.mock('./utils/mockable-import', () => ({ - readLocalEdgeConfig: jest.fn(() => { + readBuildEmbeddedEdgeConfig: jest.fn(() => { throw new Error('not implemented'); }), })); @@ -1603,7 +1603,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the describe('preloading', () => { beforeEach(() => { - (readLocalEdgeConfig as jest.Mock).mockReset(); + (readBuildEmbeddedEdgeConfig as jest.Mock).mockReset(); fetchMock.resetMocks(); }); @@ -1617,7 +1617,7 @@ describe('preloading', () => { jest.setSystemTime(21000); setTimestampOfLatestUpdate(20000); - (readLocalEdgeConfig as jest.Mock).mockImplementationOnce(() => { + (readBuildEmbeddedEdgeConfig as jest.Mock).mockImplementationOnce(() => { return Promise.resolve({ default: { items: { key1: 'value-preloaded' }, @@ -1635,7 +1635,7 @@ describe('preloading', () => { updatedAt: 20000, }); expect(fetchMock).toHaveBeenCalledTimes(0); - expect(readLocalEdgeConfig).toHaveBeenCalledTimes(1); + expect(readBuildEmbeddedEdgeConfig).toHaveBeenCalledTimes(1); }); it('should use the preloaded value if stale within the maxStale threshold', async () => { @@ -1657,7 +1657,7 @@ describe('preloading', () => { }, }); - (readLocalEdgeConfig as jest.Mock).mockImplementationOnce(() => { + (readBuildEmbeddedEdgeConfig as jest.Mock).mockImplementationOnce(() => { return Promise.resolve({ default: { items: { key1: 'value-preloaded' }, @@ -1675,7 +1675,7 @@ describe('preloading', () => { updatedAt: 1000, }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(readLocalEdgeConfig).toHaveBeenCalledTimes(1); + expect(readBuildEmbeddedEdgeConfig).toHaveBeenCalledTimes(1); }); it('should not use the preloaded value if the cache is expired', async () => { @@ -1688,7 +1688,7 @@ describe('preloading', () => { enableDevelopmentStream: false, }); - (readLocalEdgeConfig as jest.Mock).mockImplementationOnce(() => { + (readBuildEmbeddedEdgeConfig as jest.Mock).mockImplementationOnce(() => { return Promise.resolve({ default: { items: { keyA: 'value1' }, @@ -1717,6 +1717,6 @@ describe('preloading', () => { }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(readLocalEdgeConfig).toHaveBeenCalledTimes(1); + expect(readBuildEmbeddedEdgeConfig).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index f6daeffa6..94ea83bdb 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -42,6 +42,7 @@ export class Controller { private connection: Connection; private maxStale: number; private preloadPromise: Promise | null = null; + private streamAvailable = Promise.withResolvers(); constructor( connection: Connection, @@ -56,15 +57,27 @@ export class Controller { ); if (options.enableStream && connection.type === 'vercel') { - this.streamManager = new StreamManager(connection, (edgeConfig) => { - this.cacheManager.setEdgeConfig(edgeConfig); - }); + this.streamManager = new StreamManager( + connection, + (edgeConfig) => { + if (edgeConfig) this.cacheManager.setEdgeConfig(edgeConfig); + }, + (available) => { + // TODO available state can change after init + // TODO server needs to emit either the edge config or a signal that + // it is not necessary to send the edge config, so we know when + // we are fully connected and ready + this.streamAvailable.resolve(available); + }, + ); void this.streamManager .init(this.preload(), () => this.cacheManager.getEdgeConfig()) .catch((error) => { // eslint-disable-next-line no-console -- intentional error logging console.error('@vercel/edge-config: Stream error', error); }); + } else { + this.streamAvailable.resolve(false); } } @@ -247,6 +260,8 @@ export class Controller { updatedAt: number; }> { await this.preload(); + await this.streamAvailable.promise; + const ts = getMostRecentUpdateTimestamp(this.connection); const cached = this.readCache('GET', key, ts, localOptions); if (cached) return cached; @@ -264,6 +279,8 @@ export class Controller { updatedAt: number; }> { await this.preload(); + await this.streamAvailable.promise; + const ts = getMostRecentUpdateTimestamp(this.connection); const cached = this.readCache('HEAD', key, ts, localOptions); if (cached) return cached; @@ -309,6 +326,7 @@ export class Controller { } await this.preload(); + await this.streamAvailable.promise; const items = filteredKeys.map((key) => this.cacheManager.getItem(key, 'GET'), @@ -405,6 +423,7 @@ export class Controller { updatedAt: number; }> { await this.preload(); + await this.streamAvailable.promise; const ts = getMostRecentUpdateTimestamp(this.connection); const edgeConfig = this.cacheManager.getEdgeConfig(); diff --git a/packages/edge-config/src/utils/stream-manager.ts b/packages/edge-config/src/utils/stream-manager.ts index e985dbbf7..f53dc939a 100644 --- a/packages/edge-config/src/utils/stream-manager.ts +++ b/packages/edge-config/src/utils/stream-manager.ts @@ -1,18 +1,25 @@ -import { createEventSource, type EventSourceClient } from 'eventsource-client'; +import { + createEventSource, + type EventSourceClient, + type FetchLike, +} from 'eventsource-client'; import type { EmbeddedEdgeConfig, Connection } from '../types'; import { pickNewestEdgeConfig } from './pick-newest-edge-config'; export class StreamManager { private stream: EventSourceClient | null = null; private connection: Connection; - private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void; + private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig | null) => void; + private onStreamAvailable: (available: boolean) => void; constructor( connection: Connection, - onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void, + onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig | null) => void, + onStreamAvailable: (available: boolean) => void, ) { this.connection = connection; this.onEdgeConfig = onEdgeConfig; + this.onStreamAvailable = onStreamAvailable; } async init( @@ -27,6 +34,29 @@ export class StreamManager { instanceEdgeConfig, ]); + // TODO we can remove the custom fetch once eventstream-client supports + // seeing the status code. We only need this to be able to stop retrying + // on 401, 403, 404. + const fetchKeepResponse = (): FetchLike & { + status?: number; + statusText?: string; + } => { + const f: FetchLike & { status?: number; statusText?: string } = async ( + url, + fetchInit, + ) => { + f.status = undefined; + f.statusText = undefined; + const response = await fetch(url, fetchInit); + f.status = response.status; + f.statusText = response.statusText; + return response; + }; + return f; + }; + + const customFetch = fetchKeepResponse(); + this.stream = createEventSource({ url: `https://api.vercel.com/v1/edge-config/${this.connection.id}/stream`, headers: { @@ -35,6 +65,17 @@ export class StreamManager { ? { 'x-edge-config-updated-at': String(edgeConfig.updatedAt) } : {}), }, + fetch: customFetch, + onConnect: () => { + this.onStreamAvailable(true); + }, + onDisconnect: () => { + if (!customFetch.status || customFetch.status >= 400) { + this.onStreamAvailable(false); + this.onEdgeConfig(null); + this.stream?.close(); + } + }, }); for await (const { data, event } of this.stream) { From d6c79fe41e935f1f30c237b0f9647f1015f395eb Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 30 Sep 2025 14:48:53 +0300 Subject: [PATCH 77/81] wip --- .gitignore | 3 ++ CLAUDE.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index f8abbdade..18e5b3602 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ npm-debug.log .turbo .DS_Store .vscode +.claude + +packages/edge-config/DEVELOPMENT.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..31475ce2f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Architecture + +This is a pnpm monorepo containing the Vercel Storage packages: + +- `@vercel/blob` - Fast object storage client +- `@vercel/kv` - Redis-compatible key-value store client +- `@vercel/edge-config` - Ultra-low latency edge data client +- `@vercel/postgres` - PostgreSQL database client +- `@vercel/postgres-kysely` - Kysely ORM wrapper for @vercel/postgres + +The packages are designed to work in multiple JavaScript environments: + +- Node.js (serverless and server environments) +- Edge runtime (Vercel Edge Functions) +- Browser environments + +Each package includes environment-specific implementations and comprehensive test coverage across all supported runtimes. + +## Common Commands + +### Development & Testing + +- `pnpm build` - Build all packages using Turbo +- `pnpm test` - Run all tests across packages +- `pnpm lint` - Lint all packages with ESLint (max warnings: 0) +- `pnpm type-check` - TypeScript type checking across packages +- `pnpm prettier-check` - Check code formatting +- `pnpm prettier-fix` - Fix code formatting + +### Package-specific Commands + +Run commands in specific packages using `-F` flag: + +- `pnpm -F @vercel/blob test` - Test blob package only +- `pnpm -F @vercel/blob build` - Build blob package only + +### Testing Strategy + +Each package includes multi-environment testing: + +- `test:node` - Node.js environment tests +- `test:edge` - Edge runtime environment tests +- `test:browser` - Browser environment tests (for applicable packages) +- `test:common` - Tests that run in multiple environments + +### Integration Testing + +- `pnpm run-integration` - Start the Next.js integration test suite +- `pnpm integration-test` - Run Playwright integration tests + +## Key Files & Structure + +### Package Structure + +Each package follows consistent structure: + +- `src/` - Source TypeScript files +- `dist/` - Built output (CJS + ESM) +- `tsconfig.json` - TypeScript configuration extending workspace config +- `tsup.config.js` - Build configuration +- Individual test files with environment suffixes (`.node.test.ts`, `.edge.test.ts`, etc.) + +### Workspace Configuration + +- `pnpm-workspace.yaml` - Defines workspace packages +- `turbo.json` - Task orchestration and caching +- `tooling/` - Shared ESLint and TypeScript configurations +- `test/next/` - Integration test suite using Next.js + Playwright + +### Environment Handling + +Packages use different strategies for multi-environment support: + +- Browser-specific shims (e.g., `crypto-browser.js`, `stream-browser.js`) +- Conditional exports in package.json for different environments +- Environment-specific test suites using different Jest environments + +## Development Notes + +- Uses TypeScript with strict configuration +- ESLint extends @vercel/style-guide with zero warnings policy +- Jest for unit testing with @edge-runtime/jest-environment for edge testing +- Playwright for integration testing +- Changesets for version management and releases +- All packages support both CommonJS and ES modules via dual build From eb1b0b1b822f6fc91e722b363c98b2ea966579d3 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 2 Oct 2025 00:49:32 +0300 Subject: [PATCH 78/81] wip --- .../edge-config/src/utils/stream-manager.ts | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/edge-config/src/utils/stream-manager.ts b/packages/edge-config/src/utils/stream-manager.ts index f53dc939a..fbb636eee 100644 --- a/packages/edge-config/src/utils/stream-manager.ts +++ b/packages/edge-config/src/utils/stream-manager.ts @@ -7,20 +7,17 @@ import type { EmbeddedEdgeConfig, Connection } from '../types'; import { pickNewestEdgeConfig } from './pick-newest-edge-config'; export class StreamManager { - private stream: EventSourceClient | null = null; - private connection: Connection; - private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig | null) => void; - private onStreamAvailable: (available: boolean) => void; + private stream?: EventSourceClient; + private resolveStreamUsable?: (value: boolean) => void; + private primedPromise: Promise = new Promise((resolve) => { + this.resolveStreamUsable = resolve; + }); constructor( - connection: Connection, - onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig | null) => void, - onStreamAvailable: (available: boolean) => void, - ) { - this.connection = connection; - this.onEdgeConfig = onEdgeConfig; - this.onStreamAvailable = onStreamAvailable; - } + // the "private" keyword also auto-assigns to the instance + private connection: Connection, + private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig | null) => void, + ) {} async init( preloadPromise: Promise, @@ -66,24 +63,25 @@ export class StreamManager { : {}), }, fetch: customFetch, - onConnect: () => { - this.onStreamAvailable(true); - }, onDisconnect: () => { if (!customFetch.status || customFetch.status >= 400) { - this.onStreamAvailable(false); - this.onEdgeConfig(null); + this.resolveStreamUsable?.(false); this.stream?.close(); } }, }); for await (const { data, event } of this.stream) { - if (event === 'info' && data === 'token_invalidated') { + if (event === 'status' && data === 'token_invalidated') { this.stream.close(); return; } + if (event === 'status' && data === 'primed') { + this.resolveStreamUsable?.(true); + continue; + } + if (event === 'embed') { try { const parsedEdgeConfig = JSON.parse(data) as EmbeddedEdgeConfig; @@ -101,6 +99,14 @@ export class StreamManager { this.stream.close(); } + primed(): Promise { + return this.primedPromise; + } + + readyState(): 'unstarted' | 'open' | 'connecting' | 'closed' { + return this.stream?.readyState ?? 'unstarted'; + } + close(): void { this.stream?.close(); } From bd37201077d42d03b64f126882598ffd8f6ed13a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 2 Oct 2025 00:58:18 +0300 Subject: [PATCH 79/81] wip --- packages/edge-config/src/controller.test.ts | 33 ++++++++++--------- packages/edge-config/src/controller.ts | 27 ++++----------- packages/edge-config/src/index.node.test.ts | 1 + packages/edge-config/src/utils/readers.ts | 3 +- .../edge-config/src/utils/stream-manager.ts | 2 +- 5 files changed, 27 insertions(+), 39 deletions(-) diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts index a188939aa..c1916473e 100644 --- a/packages/edge-config/src/controller.test.ts +++ b/packages/edge-config/src/controller.test.ts @@ -43,10 +43,11 @@ function setTimestampOfLatestUpdate( describe('lifecycle: reading a single item', () => { beforeAll(() => { fetchMock.resetMocks(); + process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_STREAM = '1'; }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('should MISS the cache initially', async () => { @@ -199,7 +200,7 @@ describe('lifecycle: reading the full config', () => { }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('should MISS the cache initially', async () => { @@ -345,7 +346,7 @@ describe('lifecycle: checking existence of a single item', () => { }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('should MISS the cache initially', async () => { @@ -495,7 +496,7 @@ describe('deduping within a version', () => { fetchMock.resetMocks(); }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); // let promisedValue1: ReturnType; @@ -565,7 +566,7 @@ describe('bypassing dedupe when the timestamp changes', () => { fetchMock.resetMocks(); }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('should fetch twice when the timestamp changes', async () => { @@ -621,7 +622,7 @@ describe('bypassing dedupe when the timestamp changes', () => { describe('development cache: get', () => { const controller = new Controller(connection, { - enableDevelopmentStream: true, + enableStream: true, }); beforeAll(() => { fetchMock.resetMocks(); @@ -761,7 +762,7 @@ describe('development cache: get', () => { describe('development cache: has', () => { const controller = new Controller(connection, { - enableDevelopmentStream: true, + enableStream: true, }); beforeAll(() => { fetchMock.resetMocks(); @@ -828,7 +829,7 @@ describe('lifecycle: mixing get, has and all', () => { }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('get(key1) should MISS the cache initially', async () => { @@ -951,7 +952,7 @@ describe('lifecycle: reading multiple items without full edge config cache', () }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('should MISS the cache initially', async () => { @@ -1105,7 +1106,7 @@ describe('lifecycle: reading multiple items with full edge config cache', () => }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('should MISS the cache initially', async () => { @@ -1175,7 +1176,7 @@ describe('lifecycle: reading multiple items with different updatedAt timestamps' }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('should MISS the cache initially and populate item cache with different timestamps', async () => { @@ -1347,7 +1348,7 @@ describe('lifecycle: reading multiple items when edge config cache is stale but }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('should fetch the full edge config initially', async () => { @@ -1466,7 +1467,7 @@ describe('lifecycle: reading multiple items when the item cache is stale but the }); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); it('should fetch multiple items', async () => { @@ -1609,7 +1610,7 @@ describe('preloading', () => { it('should use the preloaded value is up to date', async () => { const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); // most recent update was only 1s ago, so we can serve the preloaded value @@ -1640,7 +1641,7 @@ describe('preloading', () => { it('should use the preloaded value if stale within the maxStale threshold', async () => { const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); // most recent update was only 1s ago, so we can serve the preloaded value @@ -1685,7 +1686,7 @@ describe('preloading', () => { setTimestampOfLatestUpdate(20000); const controller = new Controller(connection, { - enableDevelopmentStream: false, + enableStream: false, }); (readBuildEmbeddedEdgeConfig as jest.Mock).mockImplementationOnce(() => { diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index 94ea83bdb..eabdcccb0 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -42,7 +42,6 @@ export class Controller { private connection: Connection; private maxStale: number; private preloadPromise: Promise | null = null; - private streamAvailable = Promise.withResolvers(); constructor( connection: Connection, @@ -57,27 +56,15 @@ export class Controller { ); if (options.enableStream && connection.type === 'vercel') { - this.streamManager = new StreamManager( - connection, - (edgeConfig) => { - if (edgeConfig) this.cacheManager.setEdgeConfig(edgeConfig); - }, - (available) => { - // TODO available state can change after init - // TODO server needs to emit either the edge config or a signal that - // it is not necessary to send the edge config, so we know when - // we are fully connected and ready - this.streamAvailable.resolve(available); - }, - ); + this.streamManager = new StreamManager(connection, (edgeConfig) => { + this.cacheManager.setEdgeConfig(edgeConfig); + }); void this.streamManager .init(this.preload(), () => this.cacheManager.getEdgeConfig()) .catch((error) => { // eslint-disable-next-line no-console -- intentional error logging console.error('@vercel/edge-config: Stream error', error); }); - } else { - this.streamAvailable.resolve(false); } } @@ -260,7 +247,7 @@ export class Controller { updatedAt: number; }> { await this.preload(); - await this.streamAvailable.promise; + await this.streamManager?.primed(); const ts = getMostRecentUpdateTimestamp(this.connection); const cached = this.readCache('GET', key, ts, localOptions); @@ -279,7 +266,7 @@ export class Controller { updatedAt: number; }> { await this.preload(); - await this.streamAvailable.promise; + await this.streamManager?.primed(); const ts = getMostRecentUpdateTimestamp(this.connection); const cached = this.readCache('HEAD', key, ts, localOptions); @@ -326,7 +313,7 @@ export class Controller { } await this.preload(); - await this.streamAvailable.promise; + await this.streamManager?.primed(); const items = filteredKeys.map((key) => this.cacheManager.getItem(key, 'GET'), @@ -423,7 +410,7 @@ export class Controller { updatedAt: number; }> { await this.preload(); - await this.streamAvailable.promise; + await this.streamManager?.primed(); const ts = getMostRecentUpdateTimestamp(this.connection); const edgeConfig = this.cacheManager.getEdgeConfig(); diff --git a/packages/edge-config/src/index.node.test.ts b/packages/edge-config/src/index.node.test.ts index 4ee6cddd9..5949038a0 100644 --- a/packages/edge-config/src/index.node.test.ts +++ b/packages/edge-config/src/index.node.test.ts @@ -8,6 +8,7 @@ const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; // eslint-disable-next-line jest/require-top-level-describe -- [@vercel/style-guide@5 migration] beforeEach(() => { fetchMock.resetMocks(); + process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_STREAM = '1'; }); describe('default Edge Config', () => { diff --git a/packages/edge-config/src/utils/readers.ts b/packages/edge-config/src/utils/readers.ts index 5e41abff8..54a95b01a 100644 --- a/packages/edge-config/src/utils/readers.ts +++ b/packages/edge-config/src/utils/readers.ts @@ -19,8 +19,7 @@ export async function getBuildEmbeddedEdgeConfig( }>(edgeConfigId); return mod ? mod.default : null; } catch (e) { - // eslint-disable-next-line no-console -- intentional error logging - console.error('@vercel/edge-config: Error reading local edge config', e); + // console.error('@vercel/edge-config: Error reading local edge config', e); return null; } } diff --git a/packages/edge-config/src/utils/stream-manager.ts b/packages/edge-config/src/utils/stream-manager.ts index fbb636eee..f9fd7444e 100644 --- a/packages/edge-config/src/utils/stream-manager.ts +++ b/packages/edge-config/src/utils/stream-manager.ts @@ -16,7 +16,7 @@ export class StreamManager { constructor( // the "private" keyword also auto-assigns to the instance private connection: Connection, - private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig | null) => void, + private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void, ) {} async init( From b0a233097031c50ce5724460c8da0eaec9517803 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 2 Oct 2025 01:31:15 +0300 Subject: [PATCH 80/81] =?UTF-8?q?mget=20=E2=86=92=20getMany;=20all=20?= =?UTF-8?q?=E2=86=92=20getAll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/edge-config/src/controller.ts | 4 +-- packages/edge-config/src/index.ts | 36 +++++++++++++++----------- packages/edge-config/src/types.ts | 4 +-- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index eabdcccb0..a90f5802d 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -274,7 +274,7 @@ export class Controller { return this.fetchAndCacheItem('HEAD', key, ts, localOptions, true); } - public async mget( + public async getMany( keys: string[], localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ @@ -401,7 +401,7 @@ export class Controller { return { ...result, cache: 'MISS' }; } - public async all( + public async getAll( localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index c6793d724..1deb52834 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -76,7 +76,10 @@ export const createClient = trace( const edgeConfigId = connection.id; - const methods: Pick = { + const methods: Pick< + EdgeConfigClient, + 'get' | 'has' | 'getMany' | 'getAll' + > = { get: trace( async function get( key: string, @@ -120,8 +123,8 @@ export const createClient = trace( }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, ) as EdgeConfigClient['has'], - mget: trace( - async function mget( + getMany: trace( + async function getMany( keys: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; digest: string; cache: CacheStatus } | T> { @@ -132,7 +135,10 @@ export const createClient = trace( ) return {} as T; - const data = await controller.mget(keys as string[], localOptions); + const data = await controller.getMany( + keys as string[], + localOptions, + ); return localOptions?.metadata ? { value: data.value, @@ -142,16 +148,16 @@ export const createClient = trace( : data.value; }, { - name: 'mget', + name: 'getMany', isVerboseTrace: false, attributes: { edgeConfigId }, }, ), - all: trace( - async function all( + getAll: trace( + async function getAll( localOptions?: EdgeConfigFunctionsOptions, ): Promise<{ value: T; digest: string; cache: CacheStatus } | T> { - const data = await controller.all(localOptions); + const data = await controller.getAll(localOptions); return localOptions?.metadata ? { value: data.value, @@ -201,28 +207,28 @@ export const get: EdgeConfigClient['get'] = (...args) => { * Reads all items from the default Edge Config. * * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).all()`. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getAll()`. * * @see {@link EdgeConfigClient.all} */ -export const all: EdgeConfigClient['all'] = (...args) => { +export const getAll: EdgeConfigClient['getAll'] = (...args) => { init(); - return defaultEdgeConfigClient.all(...args); + return defaultEdgeConfigClient.getAll(...args); }; /** * Reads multiple items from the default Edge Config. * * This is a convenience method which reads the default Edge Config. - * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).mget()`. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getMany()`. * - * @see {@link EdgeConfigClient.mget} + * @see {@link EdgeConfigClient.getMany} * @param keys - the keys to read * @returns the values stored under the given keys, or undefined */ -export const mget: EdgeConfigClient['mget'] = (...args) => { +export const getMany: EdgeConfigClient['getMany'] = (...args) => { init(); - return defaultEdgeConfigClient.mget(...args); + return defaultEdgeConfigClient.getMany(...args); }; /** diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index 010606389..2b9efeb8c 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -57,7 +57,7 @@ export interface EdgeConfigClient { * @param keys - the keys to read * @returns Returns entries matching the given keys. */ - mget: { + getMany: { ( keys: (keyof T)[], options: EdgeConfigFunctionsOptions & { metadata: true }, @@ -75,7 +75,7 @@ export interface EdgeConfigClient { * * @returns Returns all entries. */ - all: { + getAll: { ( options: EdgeConfigFunctionsOptions & { metadata: true }, ): Promise<{ value: T; digest: string; cache: CacheStatus }>; From 44166d251eb413c48af05bfe586c3d866131e095 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 3 Oct 2025 15:21:56 +0300 Subject: [PATCH 81/81] wip --- packages/edge-config/src/controller.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts index a90f5802d..40440d583 100644 --- a/packages/edge-config/src/controller.ts +++ b/packages/edge-config/src/controller.ts @@ -250,6 +250,17 @@ export class Controller { await this.streamManager?.primed(); const ts = getMostRecentUpdateTimestamp(this.connection); + + // preload + // if stream + // - wait until stream is primed, or opt out of streaming + // - use streamed value + // if ts, check cache status [check per-item and full cache] + // - on cache HIT, use cached value + // - on cache STALE, use cached value and refresh in background + // - on cache MISS, use network value (blocking fetch) + // if no ts, use 10s cache (or swr?) + const cached = this.readCache('GET', key, ts, localOptions); if (cached) return cached; return this.fetchAndCacheItem('GET', key, ts, localOptions, true);