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 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/.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 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'; 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/.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/package.json b/packages/edge-config/package.json index 2c49d962d..eaaf4ef48 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.802", "description": "Ultra-low latency data at the edge", "homepage": "https://vercel.com", "repository": { @@ -12,13 +12,23 @@ "sideEffects": false, "type": "module", "exports": { - "import": "./dist/index.js", - "require": "./dist/index.cjs" + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./stores": { + "import": "./stores", + "require": "./stores" + } }, "main": "./dist/index.cjs", "module": "./dist/index.js", + "bin": { + "edge-config": "./dist/cli.js" + }, "files": [ - "dist" + "dist", + "stores" ], "scripts": { "build": "tsup", @@ -41,7 +51,8 @@ "testEnvironment": "node" }, "dependencies": { - "@vercel/edge-config-fs": "workspace:*" + "@vercel/functions": "^2.2.5", + "eventsource-client": "1.2.0" }, "devDependencies": { "@changesets/cli": "2.28.1", diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts new file mode 100755 index 000000000..71fe1e44a --- /dev/null +++ b/packages/edge-config/src/cli.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +/* + * 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) => { + if (typeof value !== 'string') return acc; + const data = parseConnectionString(value); + + if (data) { + acc.push(data); + } + + return acc; + }, + [], + ); + + const storesDir = getStoresDir(); + // eslint-disable-next-line no-console -- This is a CLI tool + console.log(`Creating stores directory: ${storesDir}`); + await mkdir(storesDir, { recursive: true }); + + await Promise.all( + connections.map(async (connection) => { + const { data, updatedAt } = await fetch(connection.baseUrl, { + headers: { + authorization: `Bearer ${connection.token}`, + // consistentRead + 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, + }, + }).then(async (res) => { + const ts = res.headers.get('x-edge-config-updated-at'); + + return { + data: (await res.json()) as EmbeddedEdgeConfig, + updatedAt: ts ? Number(ts) : undefined, + }; + }); + + const outputPath = join(storesDir, `${connection.id}.json`); + await writeFile(outputPath, JSON.stringify({ ...data, updatedAt })); + // eslint-disable-next-line no-console -- This is a CLI tool + console.log(`Emitted Edge Config for ${connection.id} to: ${outputPath}`); + }), + ); +} + +main().catch((error) => { + // eslint-disable-next-line no-console -- This is a CLI tool + console.error('@vercel/edge-config: prepare failed', error); + process.exit(1); +}); diff --git a/packages/edge-config/src/controller.test.ts b/packages/edge-config/src/controller.test.ts new file mode 100644 index 000000000..c1916473e --- /dev/null +++ b/packages/edge-config/src/controller.test.ts @@ -0,0 +1,1723 @@ +import fetchMock from 'jest-fetch-mock'; +import { version } from '../package.json'; +import { Controller } from './controller'; +import type { Connection } from './types'; +import { readBuildEmbeddedEdgeConfig } from './utils/mockable-import'; + +const packageVersion = `@vercel/edge-config@${version}`; + +jest.useFakeTimers(); + +jest.mock('./utils/mockable-import', () => ({ + readBuildEmbeddedEdgeConfig: jest.fn(() => { + throw new Error('not implemented'); + }), +})); + +const connection: Connection = { + baseUrl: 'https://edge-config.vercel.com', + id: 'ecfg_FAKE_EDGE_CONFIG_ID', + token: 'fake-edge-config-token', + version: '1', + 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(() => { + fetchMock.resetMocks(); + process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_STREAM = '1'; + }); + + const controller = new Controller(connection, { + enableStream: false, + }); + + it('should MISS the cache initially', async () => { + jest.setSystemTime(1000); + 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, + }); + }); + + 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', + 'x-edge-config-min-updated-at': '1000', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + 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', + 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 () => { + jest.setSystemTime(7100); + 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': packageVersion, + '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 () => { + jest.setSystemTime(18001); + setTimestampOfLatestUpdate(8000); + fetchMock.mockResponseOnce(JSON.stringify('value3'), { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '8000', + }, + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value3', + digest: 'digest3', + cache: 'MISS', + exists: true, + updatedAt: 8000, + }); + }); + + 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': '8000', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); +}); + +describe('lifecycle: reading the full config', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableStream: false, + }); + + it('should MISS the cache initially', async () => { + jest.setSystemTime(1100); + 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.all()).resolves.toEqual({ + value: { key1: 'value1' }, + digest: 'digest1', + cache: 'MISS', + 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', + { + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'x-edge-config-min-updated-at': '1000', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + 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', + cache: 'HIT', + 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 () => { + // 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': '20000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.all()).resolves.toEqual({ + value: { key1: 'value1' }, + digest: 'digest1', + cache: 'STALE', + 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': '20000', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + it('should serve the new value from cache after the background refresh completes', async () => { + await expect(controller.all()).resolves.toEqual({ + value: { key1: 'value2' }, + digest: 'digest2', + cache: 'HIT', + updatedAt: 20000, + }); + }); + + it('should not fire off any subsequent background refreshes', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should refresh when the stale threshold is exceeded', async () => { + jest.setSystemTime(30002); + setTimestampOfLatestUpdate(20001); + fetchMock.mockResponseOnce(JSON.stringify({ key1: 'value3' }), { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '20001', + }, + }); + + await expect(controller.all()).resolves.toEqual({ + value: { key1: 'value3' }, + digest: 'digest3', + cache: 'MISS', + updatedAt: 20001, + }); + }); + + 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': '20001', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); +}); + +describe('lifecycle: checking existence of a single item', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableStream: false, + }); + + it('should MISS the cache initially', async () => { + jest.setSystemTime(1100); + 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', + 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/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', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + 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', + cache: 'HIT', + 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 () => { + 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': '20000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }); + + await expect(controller.has('key1')).resolves.toEqual({ + exists: true, + digest: 'digest1', + cache: 'STALE', + 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: 'HEAD', + cache: 'no-store', + headers: new Headers({ + Authorization: 'Bearer fake-edge-config-token', + 'x-edge-config-min-updated-at': '20000', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + 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', + updatedAt: 20000, + value: undefined, + }); + }); + + it('should not fire off any subsequent background refreshes', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should refresh when the stale threshold is exceeded', async () => { + jest.setSystemTime(40000); + setTimestampOfLatestUpdate(21000); + fetchMock.mockResponseOnce('', { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '21000', + }, + }); + + await expect(controller.has('key1')).resolves.toEqual({ + exists: true, + digest: 'digest3', + cache: 'MISS', + updatedAt: 21000, + }); + }); + + 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': '21000', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); +}); + +describe('deduping within a version', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + const controller = new Controller(connection, { + enableStream: false, + }); + + // let promisedValue1: ReturnType; + let promisedValue2: ReturnType; + + it('should only fetch once given the same request', async () => { + setTimestampOfLatestUpdate(1000); + const resolvers = Promise.withResolvers(); + + fetchMock.mockResolvedValueOnce(resolvers.promise); + + // blocking fetches first, which should get deduped + const promisedValue1 = controller.get('key1'); + // fetch again before resolving promise of the first fetch + promisedValue2 = controller.get('key1'); + + resolvers.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', + exists: true, + updatedAt: 1000, + }); + }); + + it('should reuse the existing promise', async () => { + await expect(promisedValue2).resolves.toEqual({ + value: 'value1', + digest: 'digest1', + cache: 'MISS', + updatedAt: 1000, + exists: true, + }); + }); + + 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', + cache: 'HIT', + updatedAt: 1000, + exists: true, + }); + }); + + 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, { + enableStream: false, + }); + + it('should fetch twice when the timestamp changes', 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', + updatedAt: 1000, + exists: true, + }); + + read2.resolve( + new Response(JSON.stringify('value2'), { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '1001', + }, + }), + ); + + await expect(promisedValue2).resolves.toEqual({ + value: 'value2', + digest: 'digest2', + cache: 'MISS', + updatedAt: 1001, + exists: true, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('development cache: get', () => { + const controller = new Controller(connection, { + enableStream: true, + }); + beforeAll(() => { + fetchMock.resetMocks(); + }); + + it('should fetch initially', async () => { + setTimestampOfLatestUpdate(undefined); + 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', + cache: 'MISS', + exists: true, + updatedAt: 1000, + }); + + 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', + }, + }); + + // 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', + cache: 'MISS', + exists: true, + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + + await expect(promise2).resolves.toEqual({ + value: 'value2', + digest: 'digest2', + cache: 'MISS', + exists: true, + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should use the etag http cache', async () => { + fetchMock.mockResponseOnce('', { + status: 304, + 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({ + value: 'value2', + 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( + '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': '"digest2"', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + + 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': '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: 'value3', + digest: 'digest3', + cache: 'MISS', + exists: true, + updatedAt: 1001, + }); + + 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', + // we query with the older etag we had in memory + 'If-None-Match': '"digest2"', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); + +describe('development cache: has', () => { + const controller = new Controller(connection, { + enableStream: 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', + updatedAt: 1000, + }); + + 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({ + digest: 'digest2', + cache: 'MISS', + exists: true, + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + + await expect(promise2).resolves.toEqual({ + exists: true, + digest: 'digest2', + cache: 'MISS', + updatedAt: 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('lifecycle: mixing get, has and all', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableStream: 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', + updatedAt: 1000, + exists: true, + }); + + 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', + updatedAt: 1000, + value: 'value1', // we have the value from the previous GET call + }); + // 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', + updatedAt: 1000, + value: undefined, + }); + + 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', + 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); + }); +}); + +describe('lifecycle: reading multiple items without full edge config cache', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableStream: false, + }); + + it('should MISS the cache initially', async () => { + jest.setSystemTime(1100); + 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.mget(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2' }, + digest: 'digest1', + cache: 'MISS', + 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': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + 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', + cache: 'HIT', + 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 () => { + jest.setSystemTime(20000); + setTimestampOfLatestUpdate(15000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'valueA', key2: 'valueB' }), + { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '15000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2' }, + digest: 'digest1', + cache: 'STALE', + 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': '15000', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); + + it('should serve the new value from cache after the background refresh completes', async () => { + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'valueA', key2: 'valueB' }, + digest: 'digest2', + cache: 'HIT', + updatedAt: 15000, + }); + }); + + it('should not fire off any subsequent background refreshes', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should refresh when the stale threshold is exceeded', async () => { + jest.setSystemTime(40000); + setTimestampOfLatestUpdate(29000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'valueC', key2: 'valueD' }), + { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '29000', + }, + }, + ); + + await expect(controller.mget(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'valueC', key2: 'valueD' }, + digest: 'digest3', + cache: 'MISS', + updatedAt: 29000, + }); + }); + + 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': '29000', + 'x-edge-config-sdk': packageVersion, + 'x-edge-config-vercel-env': 'test', + }), + }, + ); + }); +}); + +describe('lifecycle: reading multiple items with full edge config cache', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableStream: 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.mget(['key1'])).resolves.toEqual({ + value: { key1: 'value1', key2: 'value2' }, + digest: 'digest1', + cache: 'MISS', + 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.all()).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.mget(['key2'])).resolves.toEqual({ + value: { key2: 'value2' }, + digest: 'digest1', + cache: 'HIT', + updatedAt: 1000, + }); + }); + + it('should not fire off any background refreshes after the cache HIT', () => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('lifecycle: reading multiple items with different updatedAt timestamps', () => { + beforeAll(() => { + fetchMock.resetMocks(); + }); + + const controller = new Controller(connection, { + enableStream: 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.mget(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1a', key2: 'value2a' }, + digest: 'digest3', + cache: 'MISS', + 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': packageVersion, + '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.mget(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'value1a', key2: 'value2a' }, + digest: 'digest3', + cache: 'HIT', + 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.mget(['key1', 'key2'])).resolves.toEqual({ + value: { key1: 'valueA', key2: 'valueB' }, + digest: 'digest4', + cache: 'MISS', + 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.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ + value: { key1: 'valueX', key2: 'valueY', key3: 'valueZ' }, + digest: 'digest6', + cache: 'MISS', + updatedAt: 16000, + }); + + 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, { + enableStream: false, + }); + + it('should fetch the full edge config initially', async () => { + jest.setSystemTime(1100); + 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.all()).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 () => { + jest.setSystemTime(20000); + setTimestampOfLatestUpdate(5000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1a', key2: 'value2a', key3: 'value3a' }), + { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '5000', + etag: '"digest1"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ + value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, + digest: 'digest2', + cache: 'MISS', + updatedAt: 5000, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + 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: 'digest2', + cache: 'HIT', + 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': 'digest3', + 'x-edge-config-updated-at': '30000', + etag: '"digest3"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ + value: { key1: 'value1a', key2: 'value2a', key3: 'value3a' }, + cache: 'STALE', + 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', + }); + }); +}); + +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, { + enableStream: false, + }); + + it('should fetch multiple items', async () => { + jest.setSystemTime(1100); + 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.mget(['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.all()).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.mget(['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.all()).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 () => { + jest.setSystemTime(25000); + setTimestampOfLatestUpdate(20000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1b', key2: 'value2b', key3: 'value3b' }), + { + headers: { + 'x-edge-config-digest': 'digest2', + 'x-edge-config-updated-at': '20000', + etag: '"digest2"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.all()).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.all()).resolves.toEqual({ + value: { key1: 'value1b', key2: 'value2b', key3: 'value3b' }, + digest: 'digest2', + cache: 'HIT', + updatedAt: 20000, + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should serve STALE values from the edge config cache', async () => { + jest.setSystemTime(24000); + setTimestampOfLatestUpdate(23000); + fetchMock.mockResponseOnce( + JSON.stringify({ key1: 'value1c', key2: 'value2c', key3: 'value3c' }), + { + headers: { + 'x-edge-config-digest': 'digest3', + 'x-edge-config-updated-at': '23000', + etag: '"digest3"', + 'content-type': 'application/json', + }, + }, + ); + + await expect(controller.mget(['key1', 'key2', 'key3'])).resolves.toEqual({ + value: { key1: 'value1b', key2: 'value2b', key3: 'value3b' }, + digest: 'digest2', + cache: 'STALE', + updatedAt: 20000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); + +describe('preloading', () => { + beforeEach(() => { + (readBuildEmbeddedEdgeConfig as jest.Mock).mockReset(); + fetchMock.resetMocks(); + }); + + it('should use the preloaded value is up to date', async () => { + const controller = new Controller(connection, { + enableStream: 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); + + (readBuildEmbeddedEdgeConfig 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(readBuildEmbeddedEdgeConfig).toHaveBeenCalledTimes(1); + }); + + it('should use the preloaded value if stale within the maxStale threshold', async () => { + const controller = new Controller(connection, { + enableStream: 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', + }, + }); + + (readBuildEmbeddedEdgeConfig as jest.Mock).mockImplementationOnce(() => { + return Promise.resolve({ + default: { + items: { key1: 'value-preloaded' }, + updatedAt: 1000, + digest: 'digest-preloaded', + }, + }); + }); + + await expect(controller.get('key1')).resolves.toEqual({ + value: 'value-preloaded', + digest: 'digest-preloaded', + cache: 'STALE', + exists: true, + updatedAt: 1000, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(readBuildEmbeddedEdgeConfig).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, { + enableStream: false, + }); + + (readBuildEmbeddedEdgeConfig 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(readBuildEmbeddedEdgeConfig).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/edge-config/src/controller.ts b/packages/edge-config/src/controller.ts new file mode 100644 index 000000000..40440d583 --- /dev/null +++ b/packages/edge-config/src/controller.ts @@ -0,0 +1,457 @@ +import type { + EdgeConfigValue, + EmbeddedEdgeConfig, + EdgeConfigFunctionsOptions, + Connection, + EdgeConfigClientOptions, + CacheStatus, + EdgeConfigItems, +} from './types'; +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'; +import { CacheManager } from './utils/cache-manager'; + +const DEFAULT_STALE_THRESHOLD = 10; + +function getCacheStatus( + latestUpdate: number | null, + updatedAt: number, + maxStale: number, +): CacheStatus { + if (latestUpdate === null) return 'MISS'; + if (latestUpdate <= updatedAt) return 'HIT'; + + const now = Date.now(); + const maxStaleMs = maxStale * 1000; + + if (now - latestUpdate <= maxStaleMs) return 'STALE'; + return 'MISS'; +} + +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; + + constructor( + connection: Connection, + options: EdgeConfigClientOptions & { enableStream: 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.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); + }); + } + } + + /** + * 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 () => { + // 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; + } + + private readCache( + method: 'GET' | 'HEAD', + 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; + digest: string; + cache: CacheStatus; + exists: boolean; + updatedAt: number; + } | 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( + mostRecentUpdateTs, + cached.updatedAt, + this.maxStale, + ); + + if (cacheStatus === 'HIT') return { ...cached, cache: 'HIT' }; + + if (cacheStatus === 'STALE') { + after(() => + this.fetchAndCacheItem( + method, + key, + mostRecentUpdateTs, + localOptions, + false, + ).catch(), + ); + return { ...cached, cache: 'STALE' }; + } + + return null; + } + + private async fetchAndCacheFullConfig< + T extends Record, + >( + minUpdatedAt: number | null, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T; + digest: string; + cache: CacheStatus; + updatedAt: number; + }> { + const result = await this.networkClient.fetchFullConfig( + minUpdatedAt, + localOptions, + ); + + this.cacheManager.setEdgeConfig(result); + + return { + value: result.items as T, + digest: result.digest, + cache: 'MISS', + updatedAt: result.updatedAt, + }; + } + + private async fetchAndCacheItem( + method: 'GET' | 'HEAD', + key: string, + minUpdatedAt: number | null, + localOptions?: EdgeConfigFunctionsOptions, + staleIfError?: boolean, + ): Promise<{ + value: T | undefined; + digest: string; + cache: CacheStatus; + exists: boolean; + updatedAt: number; + }> { + try { + const result = await this.networkClient.fetchItem( + method, + key, + minUpdatedAt, + localOptions, + ); + + this.cacheManager.setItem( + key, + result.value, + result.updatedAt, + result.digest, + result.exists, + ); + + 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 get( + key: string, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T | undefined; + digest: string; + cache: CacheStatus; + exists: boolean; + updatedAt: number; + }> { + await this.preload(); + 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); + } + + public async has( + key: string, + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T | undefined; + exists: boolean; + digest: string; + cache: CacheStatus; + updatedAt: number; + }> { + await this.preload(); + await this.streamManager?.primed(); + + 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 getMany( + keys: string[], + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T; + digest: string; + cache: CacheStatus; + updatedAt: number; + }> { + 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), + ); + + const edgeConfig = this.cacheManager.getEdgeConfig(); + if (this.streamManager && edgeConfig) { + return { + value: pick(edgeConfig.items, filteredKeys) as T, + digest: edgeConfig.digest, + cache: 'HIT', + updatedAt: edgeConfig.updatedAt, + }; + } + + const ts = getMostRecentUpdateTimestamp(this.connection); + + if (!localOptions?.metadata && filteredKeys.length === 0) { + return { + value: {} as T, + digest: '', + cache: 'HIT', + updatedAt: -1, + }; + } + + await this.preload(); + await this.streamManager?.primed(); + + const items = filteredKeys.map((key) => + this.cacheManager.getItem(key, 'GET'), + ); + const firstItem = items[0]; + + const canUseItemCache = + firstItem && + items.every( + (item) => + item?.exists && + item.value !== undefined && + item.updatedAt === firstItem.updatedAt, + ); + + const currentEdgeConfig = this.cacheManager.getEdgeConfig(); + + // First try individual item cache if all items are consistent and newer + if ( + canUseItemCache && + (!currentEdgeConfig || currentEdgeConfig.updatedAt < firstItem.updatedAt) + ) { + const cacheStatus = getCacheStatus( + ts, + firstItem.updatedAt, + this.maxStale, + ); + + if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { + if (cacheStatus === 'STALE') { + after(() => this.fetchAndCacheFullConfig(ts, 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, + updatedAt: firstItem.updatedAt, + }; + } + } + + // Fallback to edge config cache + if (currentEdgeConfig) { + const cacheStatus = getCacheStatus( + ts, + currentEdgeConfig.updatedAt, + this.maxStale, + ); + + if (cacheStatus === 'HIT' || cacheStatus === 'STALE') { + if (cacheStatus === 'STALE') { + after(() => this.fetchAndCacheFullConfig(ts, localOptions).catch()); + } + + return { + value: pick(currentEdgeConfig.items, filteredKeys) as T, + digest: currentEdgeConfig.digest, + cache: cacheStatus, + updatedAt: currentEdgeConfig.updatedAt, + }; + } + } + + const result = await this.networkClient.fetchMultipleItems( + filteredKeys, + ts, + localOptions, + ); + + 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, + ); + } + + return { ...result, cache: 'MISS' }; + } + + public async getAll( + localOptions?: EdgeConfigFunctionsOptions, + ): Promise<{ + value: T; + digest: string; + cache: CacheStatus; + updatedAt: number; + }> { + await this.preload(); + await this.streamManager?.primed(); + const ts = getMostRecentUpdateTimestamp(this.connection); + + const edgeConfig = this.cacheManager.getEdgeConfig(); + if (ts && edgeConfig) { + const cacheStatus = getCacheStatus( + ts, + edgeConfig.updatedAt, + this.maxStale, + ); + + if (cacheStatus === 'HIT') { + return { + value: edgeConfig.items as T, + digest: edgeConfig.digest, + cache: 'HIT', + updatedAt: edgeConfig.updatedAt, + }; + } + + if (cacheStatus === 'STALE') { + after(() => this.fetchAndCacheFullConfig(ts, localOptions).catch()); + return { + value: edgeConfig.items as T, + digest: edgeConfig.digest, + cache: 'STALE', + updatedAt: edgeConfig.updatedAt, + }; + } + } + + return this.fetchAndCacheFullConfig(ts, localOptions); + } +} diff --git a/packages/edge-config/src/index.common.test.ts b/packages/edge-config/src/index.common.test.ts index 19559f282..df37a3809 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/fetch-with-cached-response'; 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'); @@ -311,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', }, @@ -323,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', }, @@ -339,39 +271,44 @@ 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'); + 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({ 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`, + `${modifiedBaseUrl}/item/foo4934?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', }, @@ -388,7 +325,6 @@ describe('connectionStrings', () => { beforeEach(() => { fetchMock.resetMocks(); - cache.clear(); edgeConfig = pkg.createClient(modifiedConnectionString); }); @@ -411,19 +347,25 @@ 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'); 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', { + 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', }, @@ -433,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.edge.test.ts b/packages/edge-config/src/index.edge.test.ts deleted file mode 100644 index 4d0ebbaa2..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/fetch-with-cached-response'; -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 58bccb279..5949038a0 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/fetch-with-cached-response'; -import { get, has, digest, createClient, getAll } from './index'; +import { get, has, all, mget } from './index'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; @@ -11,21 +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(); - 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)); - }), - }; + process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_STREAM = '1'; }); describe('default Edge Config', () => { @@ -38,17 +21,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 +47,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 +61,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 +85,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 +98,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 +130,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', }, @@ -149,17 +146,17 @@ 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({ 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', }, @@ -171,19 +168,19 @@ 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({ 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,31 +189,42 @@ describe('default Edge Config', () => { }); }); - describe('getAll(keys)', () => { - describe('when called without keys', () => { - it('should return all items', async () => { - fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' })); + describe('all()', () => { + 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' }); + await expect(all()).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', - }); + 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('mget(keys)', () => { describe('when called with keys', () => { it('should return the selected items', async () => { - fetchMock.mockResponse(JSON.stringify({ foo: 'foo1', bar: 'bar1' })); + 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(getAll(['foo', 'bar'])).resolves.toEqual({ + await expect(mget(['foo', 'bar'])).resolves.toEqual({ foo: 'foo1', bar: 'bar1', }); @@ -229,7 +237,6 @@ describe('default Edge Config', () => { 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', }, @@ -239,15 +246,23 @@ describe('default Edge Config', () => { describe('when called with an empty string key', () => { it('should return the selected items', async () => { - await expect(getAll([''])).resolves.toEqual({}); + await expect(mget([''])).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' }); + fetchMock.mockResponse(JSON.stringify({ foo: 'foo1' }), { + headers: { + 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', + 'content-type': 'application/json', + }, + }); + await expect(mget(['foo', ''])).resolves.toEqual({ + foo: 'foo1', + }); expect(fetchMock).toHaveBeenCalledTimes(1); }); }); @@ -264,7 +279,7 @@ describe('default Edge Config', () => { { status: 404, headers: { 'content-type': 'application/json' } }, ); - await expect(getAll(['foo', 'bar'])).rejects.toThrow( + await expect(mget(['foo', 'bar'])).rejects.toThrow( '@vercel/edge-config: Edge Config not found', ); @@ -276,7 +291,6 @@ describe('default Edge Config', () => { 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', }, @@ -288,7 +302,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`, { @@ -296,7 +310,6 @@ describe('default Edge Config', () => { 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', }); @@ -307,7 +320,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', ); @@ -317,7 +330,6 @@ describe('default Edge Config', () => { 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', }); @@ -328,7 +340,13 @@ describe('default Edge Config', () => { 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(has('foo')).resolves.toEqual(true); @@ -341,7 +359,6 @@ describe('default Edge Config', () => { 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', }, @@ -363,6 +380,7 @@ describe('default Edge Config', () => { headers: { 'content-type': 'application/json', 'x-edge-config-digest': 'fake', + 'x-edge-config-updated-at': '1000', }, }, ); @@ -378,7 +396,6 @@ describe('default Edge Config', () => { 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', }, @@ -411,7 +428,6 @@ describe('default Edge Config', () => { 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', }, @@ -419,227 +435,4 @@ describe('default Edge Config', () => { }); }); }); - - 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', - }), - }, - ); - }); - }); }); diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index f617b064a..1deb52834 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -1,27 +1,18 @@ -import { readFile } from '@vercel/edge-config-fs'; -import { name as sdkName, version as sdkVersion } from '../package.json'; -import { - assertIsKey, - assertIsKeys, - isEmptyKey, - ERRORS, - UnexpectedNetworkError, - hasOwnProperty, - parseConnectionString, - pick, -} from './utils'; +import { assertIsKey, isEmptyKey, parseConnectionString } from './utils'; import type { - Connection, EdgeConfigClient, EdgeConfigItems, EdgeConfigValue, EmbeddedEdgeConfig, EdgeConfigFunctionsOptions, + EdgeConfigClientOptions, + CacheStatus, } from './types'; -import { fetchWithCachedResponse } from './utils/fetch-with-cached-response'; import { trace } from './utils/tracing'; +import { Controller } from './controller'; export { setTracerProvider } from './utils/tracing'; +export { ERRORS, UnexpectedNetworkError } from './utils/errors'; export { parseConnectionString, @@ -31,253 +22,6 @@ 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', - }, -); - -/** - * 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}`); -} - -/** - * Reads the Edge Config from a local provider, if available, - * to avoid Network requests. - */ -async function getLocalEdgeConfig( - connection: Connection, - options?: EdgeConfigFunctionsOptions, -): Promise { - if (options?.consistentRead) return null; - - const edgeConfig = - (await getPrivateEdgeConfig(connection)) || - (await getFileSystemEdgeConfig(connection)); - - return edgeConfig; -} - -/** - * 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. * @@ -292,7 +36,18 @@ export const createClient = trace( function createClient( connectionString: string | undefined, options: EdgeConfigClientOptions = { - staleIfError: 604800 /* one week */, + /** + * Allows a stored response that is stale for N seconds to be served + * in case of an error. + */ + 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. + * + * If the threshold is exceeded a blocking read will be performed. + */ + maxStale: 60, cache: 'no-store', }, ): EdgeConfigClient { @@ -306,229 +61,116 @@ 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 = { - 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. + * 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, + enableStream: shouldUseDevelopmentStream, + }); - const getInMemoryEdgeConfig = createGetInMemoryEdgeConfig( - shouldUseDevelopmentCache, - connection, - headers, - fetchCache, - ); + const edgeConfigId = connection.id; - const api: Omit = { + const methods: Pick< + EdgeConfigClient, + 'get' | 'has' | 'getMany' | 'getAll' + > = { get: trace( - async function get( + async function get( key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = - (await getInMemoryEdgeConfig(localOptions)) || - (await getLocalEdgeConfig(connection, localOptions)); - + ): Promise< + | T + | undefined + | { value: T | undefined; digest: string; cache: CacheStatus } + > { 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 + ? { value: data.value, digest: data.digest, cache: data.cache } + : data.value; }, { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, ), has: trace( async function has( - key, + key: string, localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = - (await getInMemoryEdgeConfig(localOptions)) || - (await getLocalEdgeConfig(connection, localOptions)); - + ): Promise< + boolean | { exists: boolean; digest: string; cache: CacheStatus } + > { assertIsKey(key); - if (isEmptyKey(key)) return false; - - if (localEdgeConfig) { - return Promise.resolve(hasOwnProperty(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 + ? { + exists: data.exists, + digest: data.digest, + cache: data.cache, + } + : data.exists; }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, - ), - getAll: trace( - async function getAll( - keys?: (keyof T)[], + ) as EdgeConfigClient['has'], + getMany: trace( + async function getMany( + keys: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = - (await getInMemoryEdgeConfig(localOptions)) || - (await getLocalEdgeConfig(connection, localOptions)); - - 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; cache: CacheStatus } | 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.getMany( + keys as string[], + localOptions, + ); + return localOptions?.metadata + ? { + value: data.value, + digest: data.digest, + cache: data.cache, + } + : data.value; + }, + { + name: 'getMany', + isVerboseTrace: false, + attributes: { edgeConfigId }, }, - { name: 'getAll', isVerboseTrace: false, attributes: { edgeConfigId } }, ), - digest: trace( - async function digest( + getAll: trace( + async function getAll( localOptions?: EdgeConfigFunctionsOptions, - ): Promise { - const localEdgeConfig = - (await getInMemoryEdgeConfig(localOptions)) || - (await getLocalEdgeConfig(connection, localOptions)); - - 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); - }); + ): Promise<{ value: T; digest: string; cache: CacheStatus } | T> { + const data = await controller.getAll(localOptions); + return localOptions?.metadata + ? { + value: data.value, + digest: data.digest, + cache: data.cache, + } + : data.value; }, - { name: 'digest', isVerboseTrace: false, attributes: { edgeConfigId } }, + { name: 'all', isVerboseTrace: false, attributes: { edgeConfigId } }, ), }; - return { ...api, connection }; + return { ...methods, connection }; }, { name: 'createClient', @@ -562,14 +204,12 @@ export const get: EdgeConfigClient['get'] = (...args) => { }; /** - * Reads multiple or all values. + * 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()`. * - * @see {@link EdgeConfigClient.getAll} - * @param keys - the keys to read - * @returns the value stored under the given key, or undefined + * @see {@link EdgeConfigClient.all} */ export const getAll: EdgeConfigClient['getAll'] = (...args) => { init(); @@ -577,33 +217,34 @@ export const getAll: EdgeConfigClient['getAll'] = (...args) => { }; /** - * Check if a given key exists in 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).has()`. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).getMany()`. * - * @see {@link EdgeConfigClient.has} - * @param key - the key to check - * @returns true if the given key exists in the Edge Config. + * @see {@link EdgeConfigClient.getMany} + * @param keys - the keys to read + * @returns the values stored under the given keys, or undefined */ -export const has: EdgeConfigClient['has'] = (...args) => { +export const getMany: EdgeConfigClient['getMany'] = (...args) => { init(); - return defaultEdgeConfigClient.has(...args); + return defaultEdgeConfigClient.getMany(...args); }; /** - * Get the digest of the Edge Config. + * 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).digest()`. + * It is conceptually similar to `createClient(process.env.EDGE_CONFIG).has()`. * - * @see {@link EdgeConfigClient.digest} - * @returns The digest of the Edge Config. + * @see {@link EdgeConfigClient.has} + * @param key - the key to check + * @returns true if the given key exists in the Edge Config. */ -export const digest: EdgeConfigClient['digest'] = (...args) => { +export const has = ((...args: Parameters) => { init(); - return defaultEdgeConfigClient.digest(...args); -}; + 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 d6ce01c1f..2b9efeb8c 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; } /** @@ -38,37 +39,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; cache: CacheStatus }>; + ( + 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; + getMany: { + ( + keys: (keyof T)[], + options: EdgeConfigFunctionsOptions & { metadata: true }, + ): Promise<{ value: T; digest: string; cache: CacheStatus }>; + ( + keys: (keyof T)[], + options?: EdgeConfigFunctionsOptions, + ): Promise; + }; + /** - * Check if a given key exists in the Edge Config. + * Reads all values. * - * @param key - the key to check - * @returns true if the given key exists in the Edge Config. + * Allows you to read all keys of an Edge Config at once. + * + * @returns Returns all entries. */ - has: (key: string, options?: EdgeConfigFunctionsOptions) => Promise; + getAll: { + ( + options: EdgeConfigFunctionsOptions & { metadata: true }, + ): Promise<{ value: T; digest: string; cache: CacheStatus }>; + (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. + * Check if a given key exists in the Edge Config. * - * @returns The digest of the Edge Config. + * @param key - the key to check + * @returns true if the given key exists in the Edge Config. */ - digest: (options?: EdgeConfigFunctionsOptions) => Promise; + has: { + ( + key: string, + options: EdgeConfigFunctionsOptions & { metadata: true }, + ): Promise<{ exists: boolean; digest: string; cache: CacheStatus }>; + (key: string, options?: EdgeConfigFunctionsOptions): Promise; + }; } export type EdgeConfigItems = Record; @@ -91,4 +117,72 @@ 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; } + +export interface EdgeConfigClientOptions { + /** + * 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?: boolean; + + /** + * 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 + * 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 + */ + maxStale?: number; + + /** + * In development, a stream is employed as the default streaming strategy. + */ + disableDevelopmentStream?: 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 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 maxStale was exceeded + | 'BYPASS'; // value was fetched over the network as a consistent read was requested 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/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; + } +} 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/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.test.ts b/packages/edge-config/src/utils/fetch-with-cached-response.test.ts index 3366380e9..d19270db1 100644 --- a/packages/edge-config/src/utils/fetch-with-cached-response.test.ts +++ b/packages/edge-config/src/utils/fetch-with-cached-response.test.ts @@ -1,225 +1,127 @@ import fetchMock from 'jest-fetch-mock'; -import { fetchWithCachedResponse, cache } from './fetch-with-cached-response'; +import { createEnhancedFetch } from './fetch-with-cached-response'; -jest.useFakeTimers(); +describe('enhancedFetch', () => { + let enhancedFetch: ReturnType; -describe('cache', () => { - it('should be an object', () => { - expect(typeof cache).toEqual('object'); - }); -}); - -describe('fetchWithCachedResponse', () => { 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 fetchWithCachedResponse('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 fetchWithCachedResponse('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', - }, + describe('fetch deduplication', () => { + it('should return a function', () => { + expect(typeof enhancedFetch).toEqual('function'); }); - // First request - const data1 = await fetchWithCachedResponse( - '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' }), + 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' }), { + headers: { 'content-type': 'application/json' }, + }), + ); + const [res1, res2, res3] = await Promise.all([ + invocation1Promise, + invocation2Promise, + invocation3Promise, + ]); + 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); }); - 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' }, + 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' }); }); - const data2 = await fetchWithCachedResponse( - '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' }), + 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' }); }); - 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 fetchWithCachedResponse( - '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', - }), + 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' }); }); - 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('etag and if-none-match', () => { + it('should return from the http cache if the response is not modified', async () => { + const content = JSON.stringify({ name: 'A' }); + fetchMock.mockResponseOnce(content, { headers: { ETag: '"123"' } }); + fetchMock.mockResponseOnce('', { + status: 304, + headers: { ETag: '"123"' }, + }); + 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 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); + expect(cachedRes2).toHaveProperty('status', 200); + const text1 = await res1.text(); + const cachedText = await cachedRes2?.text(); + expect(text1).toStrictEqual(cachedText); + expect(text1).toEqual(content); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - - // First request - const data1 = await fetchWithCachedResponse('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 fetchWithCachedResponse( - '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 fetchWithCachedResponse('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 fetchWithCachedResponse( - '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' }); }); }); 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 136562e65..a2d6872dd 100644 --- a/packages/edge-config/src/utils/fetch-with-cached-response.ts +++ b/packages/edge-config/src/utils/fetch-with-cached-response.ts @@ -1,166 +1,101 @@ -import { trace } from './tracing'; - -interface CachedResponseEntry { - etag: string; - response: string; - headers: Record; - status: number; - time: number; -} - -type FetchOptions = Omit & { headers?: Headers }; - -interface ResponseWithCachedResponse extends Response { - cachedResponseBody?: unknown; -} - -/** - * 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; - }; -} +// import { trace } from './tracing'; /** - * A cache of request urls & auth headers and the resulting responses. + * Generate a key for the dedupe cache * - * This cache does not use Response instances as the cache value as reusing - * responses across requests leads to issues in Cloudflare Workers. + * We currently take only the url and authorization header into account. */ -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 getDedupeCacheKey(url: string, init?: RequestInit): string { + const h = + init?.headers instanceof Headers + ? init.headers + : new Headers(init?.headers); + + // should be faster than JSON.stringify + return [ + url, + init?.method?.toUpperCase() ?? 'GET', + h.get('Authorization') ?? '', + h.get('x-edge-config-min-updated-at') ?? '', + ].join('\n'); } /** - * This is similar to fetch, but it also implements ETag semantics, and - * it implmenets stale-if-error semantics. + * Like `fetch`, but with an http etag cache and deduplication. */ -export const fetchWithCachedResponse = trace( - async function fetchWithCachedResponse( - 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 } = cachedResponseEntry; - const headers = new Headers(customHeaders); - headers.set('If-None-Match', etag); +export function createEnhancedFetch(): ( + url: string, + options?: RequestInit, +) => Promise<[Response, Response | null]> { + const pendingRequests = new Map>(); + /* not a full http cache, but caches by etags */ + const httpCache = new Map(); + + function writeHttpCache( + /* the original 200 response */ + httpCacheKey: string | null, + response: Response, + ): void { + if (!httpCacheKey) return; + if (response.status !== 200) return; + const etag = response.headers.get('ETag'); + if (!etag) return; + httpCache.set(httpCacheKey, { response: response.clone(), etag }); + } + + /** + * 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 readHttpCache( + httpCacheKey: string | 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]; + } + + 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', etag); + return h; + } + + return function enhancedFetch(url, options = {}) { + const dedupeCacheKey = getDedupeCacheKey(url, options); + const pendingRequest = pendingRequests.get(dedupeCacheKey); + + // 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); + } - const staleIfError = extractStaleIfError(headers.get('Cache-Control')); + /** Attaches the cached response */ + const attach = (r: Response): [Response, Response | null] => + r.status === 304 && cachedResponse + ? [r, cachedResponse.clone()] + : [r, null]; - const res: ResponseWithCachedResponse = await fetch(url, { - ...customOptions, - headers, - }).then( - createHandleStaleIfError(cachedResponseEntry, staleIfError), - createHandleStaleIfErrorException(cachedResponseEntry, staleIfError), - ); + // 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())); - if (res.status === 304) { - res.cachedResponseBody = JSON.parse(cachedResponse); + const promise = fetch(url, options) + .then((res) => { + writeHttpCache(dedupeCacheKey, res); return res; - } - - const newETag = res.headers.get('ETag'); - if (res.ok && newETag) - cache.set(cacheKey, { - etag: newETag, - response: await res.clone().text(), - headers: Object.fromEntries(res.headers.entries()), - 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: Object.fromEntries(res.headers.entries()), - 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); + }; +} diff --git a/packages/edge-config/src/utils/index.ts b/packages/edge-config/src/utils/index.ts index 20e384c17..8f868bc92 100644 --- a/packages/edge-config/src/utils/index.ts +++ b/packages/edge-config/src/utils/index.ts @@ -1,23 +1,10 @@ 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 */ -export function hasOwnProperty( +export function hasOwn( obj: X, prop: Y, ): obj is X & Record { @@ -26,9 +13,9 @@ export function hasOwnProperty( 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; } 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..fb40d4f4c --- /dev/null +++ b/packages/edge-config/src/utils/mockable-import.ts @@ -0,0 +1,26 @@ +/** + * Reads the local edge config that gets embedded at build time. + * + * We currently use webpackIgnore to avoid bundling the local edge config. + */ +export async function readBuildEmbeddedEdgeConfig( + id: string, +): Promise { + try { + 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/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..54a95b01a --- /dev/null +++ b/packages/edge-config/src/utils/readers.ts @@ -0,0 +1,45 @@ +// 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) { + // 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..f9fd7444e --- /dev/null +++ b/packages/edge-config/src/utils/stream-manager.ts @@ -0,0 +1,113 @@ +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; + private resolveStreamUsable?: (value: boolean) => void; + private primedPromise: Promise = new Promise((resolve) => { + this.resolveStreamUsable = resolve; + }); + + constructor( + // the "private" keyword also auto-assigns to the instance + private connection: Connection, + private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void, + ) {} + + async init( + preloadPromise: Promise, + getEdgeConfig: () => EmbeddedEdgeConfig | null, + ): Promise { + const preloadedEdgeConfig = await preloadPromise; + const instanceEdgeConfig = getEdgeConfig(); + + const edgeConfig = pickNewestEdgeConfig([ + preloadedEdgeConfig, + 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: { + Authorization: `Bearer ${this.connection.token}`, + ...(edgeConfig?.updatedAt + ? { 'x-edge-config-updated-at': String(edgeConfig.updatedAt) } + : {}), + }, + fetch: customFetch, + onDisconnect: () => { + if (!customFetch.status || customFetch.status >= 400) { + this.resolveStreamUsable?.(false); + this.stream?.close(); + } + }, + }); + + for await (const { data, event } of this.stream) { + 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; + 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(); + } + + primed(): Promise { + return this.primedPromise; + } + + readyState(): 'unstarted' | 'open' | 'connecting' | 'closed' { + return this.stream?.readyState ?? 'unstarted'; + } + + 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; +} 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/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"] } 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 + } +} diff --git a/packages/edge-config/tsup.config.js b/packages/edge-config/tsup.config.js index 0b5a5da08..ac4c29bdf 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', '@vercel/edge-config/stores'], + }, + { + entry: ['src/cli.ts'], + format: ['esm'], + splitting: true, + sourcemap: true, + minify: false, + clean: true, + skipNodeModulesBundle: true, + dts: true, + external: ['node_modules'], + }, +]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82d09b737..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))(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 @@ -105,9 +105,12 @@ importers: '@opentelemetry/api': specifier: ^1.7.0 version: 1.7.0 - '@vercel/edge-config-fs': - specifier: workspace:* - version: link:../edge-config-fs + '@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 @@ -144,7 +147,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 @@ -155,8 +158,6 @@ importers: specifier: 5.7.3 version: 5.7.3 - packages/edge-config-fs: {} - packages/kv: dependencies: '@upstash/redis': @@ -371,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 @@ -452,10 +453,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 +473,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 +488,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 +548,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 +580,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 +691,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 +1601,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==} @@ -1898,6 +1869,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'} @@ -2996,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'} @@ -3351,8 +3343,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 +4079,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 +4186,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 +4453,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'} @@ -4962,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==} @@ -5662,12 +5648,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 +5664,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 +5708,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 +5717,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 +5731,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 +5747,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 +5769,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 +5787,6 @@ snapshots: '@babel/traverse': 7.26.9 transitivePeerDependencies: - supports-color - optional: true '@babel/helper-plugin-utils@7.20.2': {} @@ -5861,22 +5798,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 +5822,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 +5836,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 +5998,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 +6025,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 +6036,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 +6569,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 +6604,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 +6650,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 +6672,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 +6720,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 +6732,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 +6764,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 +6967,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 +7006,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 +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) @@ -7410,12 +7316,18 @@ 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 '@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 +7628,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 +7654,6 @@ snapshots: slash: 3.0.0 transitivePeerDependencies: - supports-color - optional: true babel-plugin-istanbul@6.1.1: dependencies: @@ -7755,26 +7667,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 +7704,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 +7791,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 +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 @@ -8636,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: @@ -8663,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 @@ -8714,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 @@ -8936,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 @@ -9357,7 +9275,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 +9610,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 +9620,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 +9756,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 +9787,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 +9818,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 +9929,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 +10031,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 +10179,7 @@ snapshots: jsesc@3.0.2: {} - jsesc@3.1.0: - optional: true + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -10424,11 +10341,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 +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 @@ -10500,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 @@ -10782,8 +10694,6 @@ snapshots: pify@4.0.1: {} - pirates@4.0.5: {} - pirates@4.0.6: {} pkg-dir@4.2.0: @@ -11476,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: @@ -11637,7 +11547,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 +11561,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 +11584,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 +12050,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 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)}
;