diff --git a/.changeset/famous-games-sleep.md b/.changeset/famous-games-sleep.md new file mode 100644 index 000000000..677872e06 --- /dev/null +++ b/.changeset/famous-games-sleep.md @@ -0,0 +1,58 @@ +--- +"@vercel/edge-config": minor +--- + +**NEW** Edge Config Snapshots + +You can now bundle a snapshot of your Edge Config along with your deployment. +- snapshot is used as a fallback in case the Edge Config service is unavailable +- snapshot is consistently used during builds, ensuring your app uses a consistent version and reducing build time +- snapshot will be used in the future to immediately bootstrap the Edge Config SDK (soon) + +**How it works:** +- Your app continues using the latest Edge Config version under normal conditions +- If the Edge Config service is degraded, the SDK automatically falls back to the in-memory version +- If that's unavailable, it uses the snapshot embedded at build time as a last resort +- This ensures your app maintains functionality even if Edge Config is temporarily unavailable + +Note that this means your application may serve outdated values in case the Edge Config service is unavailable at runtime. In most cases this is preferred to not serving any values at all. + +**Setup:** + +Add the `edge-config snapshot` command to your `prebuild` script: + +```json +{ + "scripts": { + "prebuild": "edge-config snapshot" + } +} +``` + +The snapshot command reads your environment variables and bundles all connected Edge Configs. Use `--verbose` for detailed logs. Note that the bundled Edge Config stores count towards your build [function bundle size limit](https://vercel.com/docs/functions/limitations#bundle-size-limits). + +You can further configure your client to throw errors in case it can not find the Edge Config snapshot by editing the connection string stored in the `EDGE_CONFIG` environment variable and appending `&snapshot=required`. You can also specify `snapshot: "required"` when creating clients using `createClient`. + +**Build improvements:** + +Using `edge-config snapshot` also improves build performance and consistency: + +- **Faster builds:** The SDK fetches each Edge Config store once per build instead of once per key +- **Eliminates inconsistencies:** Prevents Edge Config changes between individual key reads during the build +- **Automatic optimization:** No code changes required—just add the prebuild script + +**Timeout configuration:** + +You can now configure request timeouts to prevent slow Edge Config reads from blocking your application: + +```ts +// Set timeout when creating the client +const client = createClient(process.env.EDGE_CONFIG, { + timeoutMs: 1000 // timeout after 1 second +}); + +// Or per-request +await client.get('key', { timeoutMs: 500 }); +``` + +When a timeout occurs, the SDK will fall back to the bundled Edge Config if available, or throw an error otherwise. This is particularly useful when combined with bundled Edge Configs to ensure fast, resilient reads. diff --git a/.node-version b/.node-version index 9de225682..b03f40867 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -lts/iron +lts/krypton diff --git a/packages/edge-config/README.md b/packages/edge-config/README.md index dbe82a0d6..ae7d6a927 100644 --- a/packages/edge-config/README.md +++ b/packages/edge-config/README.md @@ -1,166 +1,334 @@ # @vercel/edge-config -A client that lets you read Edge Config. +The official JavaScript client for reading from [Vercel Edge Config](https://vercel.com/docs/storage/edge-config) — an ultra-low latency data store for global configuration data. -## Installation +## Quick Start + +### Installation ```sh npm install @vercel/edge-config ``` -## Examples +### Setup + +1. Create an Edge Config on [vercel.com](https://vercel.com/d?to=%2F%5Bteam%5D%2F%5Bproject%5D%2Fstores&title=Create+Edge+Config+Store). +2. Connect it to your project to get a connection string +3. The connection string is automatically available as `process.env.EDGE_CONFIG` + +### Basic Usage + +```js +import { get } from '@vercel/edge-config'; + +// Read a single value +const value = await get('myKey'); + +// Check if a key exists +import { has } from '@vercel/edge-config'; +const exists = await has('myKey'); // true or false + +// Read multiple values at once +import { getAll } from '@vercel/edge-config'; +const values = await getAll(['keyA', 'keyB', 'keyC']); + +// Read all values +const allValues = await getAll(); +``` + +### Production Best Practices -You can use the methods below to read your Edge Config given you have its Connection String stored in an Environment Variable called `process.env.EDGE_CONFIG`. +Add Edge Config bundling for resilience and faster builds: -### Reading a value +```json +{ + "scripts": { + "prebuild": "edge-config snapshot" + } +} +``` + +This bundles a snapshot of your Edge Config into your build as a fallback, ensuring your application continues working in the rare event the Edge Config service is temporarily unavailable. + +--- + +## API Reference + +### Default Client Functions + +These functions read from the Edge Config specified in `process.env.EDGE_CONFIG`. + +#### `get(key)` + +Reads a single value from Edge Config. ```js import { get } from '@vercel/edge-config'; -await get('someKey'); +const value = await get('myKey'); ``` -Returns the value if the key exists. -Returns `undefined` if the key does not exist. -Throws on invalid tokens, deleted edge configs or network errors. +**Returns:** +- The value if the key exists +- `undefined` if the key does not exist + +**Throws:** +- Error on invalid connection string +- Error on deleted Edge Config +- Error on network failures + +#### `has(key)` -### Checking if a key exists +Checks if a key exists in Edge Config. ```js import { has } from '@vercel/edge-config'; -await has('someKey'); +const exists = await has('myKey'); ``` -Returns `true` if the key exists. -Returns `false` if the key does not exist. -Throws on invalid tokens, deleted edge configs or network errors. +**Returns:** +- `true` if the key exists +- `false` if the key does not exist -### Reading all items +**Throws:** +- Error on invalid connection string +- Error on deleted Edge Config +- Error on network failures + +#### `getAll(keys?)` + +Reads multiple or all values from Edge Config. ```js import { getAll } from '@vercel/edge-config'; -await getAll(); + +// Get specific keys +const some = await getAll(['keyA', 'keyB']); + +// Get all keys +const all = await getAll(); ``` -Returns all Edge Config items. -Throws on invalid tokens, deleted edge configs or network errors. +**Parameters:** +- `keys` (optional): Array of keys to retrieve. If omitted, returns all items. + +**Returns:** +- Object containing the requested key-value pairs + +**Throws:** +- Error on invalid connection string +- Error on deleted Edge Config +- Error on network failures + +#### `digest()` -### Reading items in batch +Gets the current digest (version hash) of the Edge Config. ```js -import { getAll } from '@vercel/edge-config'; -await getAll(['keyA', 'keyB']); +import { digest } from '@vercel/edge-config'; +const currentDigest = await digest(); ``` -Returns selected Edge Config items. -Throws on invalid tokens, deleted edge configs or network errors. +**Returns:** +- String containing the current digest + +**Throws:** +- Error on invalid connection string +- Error on deleted Edge Config +- Error on network failures -### Default behaviour +--- -By default `@vercel/edge-config` will read from the Edge Config stored in `process.env.EDGE_CONFIG`. +### Custom Client -The exported `get`, `getAll`, `has` and `digest` functions are bound to this default Edge Config Client. +Use `createClient()` to connect to a specific Edge Config or customize behavior. -### Reading a value from a specific Edge Config +#### `createClient(connectionString, options?)` -You can use `createClient(connectionString)` to read values from Edge Configs other than the default one. +Creates a client instance for a specific Edge Config. ```js import { createClient } from '@vercel/edge-config'; -const edgeConfig = createClient(process.env.ANOTHER_EDGE_CONFIG); -await edgeConfig.get('someKey'); + +const client = createClient(process.env.ANOTHER_EDGE_CONFIG); +await client.get('myKey'); ``` -The `createClient` function connects to a any Edge Config based on the provided Connection String. +**Parameters:** + +- `connectionString` (string): The Edge Config connection string +- `options` (object, optional): Configuration options + +**Options:** + +```ts +{ + // Fallback to stale data for N seconds if the API returns an error + staleIfError?: number | false; + + // Disable the default development cache (stale-while-revalidate) + disableDevelopmentCache?: boolean; + + // Control Next.js fetch cache behavior + cache?: 'no-store' | 'force-cache'; + + // Timeout for network requests in milliseconds + // Falls back to bundled config if available, or throws if not + timeoutMs?: number; +} +``` -It returns the same `get`, `getAll`, `has` and `digest` functions as the default Edge Config Client exports. +**Returns:** +- Client object with `get()`, `getAll()`, `has()`, and `digest()` methods -### Making a value mutable +**Example with options:** -By default, the value returned by `get` and `getAll` is immutable. Modifying the object might cause an error or other undefined behaviour. +```js +const client = createClient(process.env.EDGE_CONFIG, { + timeoutMs: 750, + cache: 'force-cache', + staleIfError: 300, // Use stale data for 5 minutes on error +}); +``` -In order to make the returned value mutable, you can use the exported function `clone` to safely clone the object and make it mutable. +#### `clone(value)` -## Writing Edge Config Items +Creates a mutable copy of a value returned from Edge Config. -Edge Config Items can be managed in two ways: +```js +import { get, clone } from '@vercel/edge-config'; -- [Using the Dashboard on vercel.com](https://vercel.com/docs/concepts/edge-network/edge-config/edge-config-dashboard#manage-items-in-the-store) -- [Using the Vercel API](https://vercel.com/docs/concepts/edge-network/edge-config/vercel-api#update-your-edge-config) +const value = await get('myKey'); +const mutableValue = clone(value); +mutableValue.someProperty = 'new value'; // Safe to modify +``` -Keep in mind that Edge Config is built for very high read volume, but for infrequent writes. +**Why this is needed:** For performance, Edge Config returns immutable references. Mutating values directly may cause unexpected behavior. Use `clone()` when you need to modify returned values. -## Error Handling +--- -- An error is thrown in case of a network error -- An error is thrown in case of an unexpected response +## Advanced Features -## Edge Runtime Support +### Edge Config Bundling -`@vercel/edge-config` is compatible with the [Edge Runtime](https://edge-runtime.vercel.app/). It can be used inside environments like [Vercel Edge Functions](https://vercel.com/edge) as follows: +Bundling creates a build-time snapshot of your Edge Config that serves as a fallback and eliminates network requests during builds. -```js -// Next.js (pages/api/edge.js) (npm i next@canary) -// Other frameworks (api/edge.js) (npm i -g vercel@canary) +**Setup:** -import { get } from '@vercel/edge-config'; +```json +{ + "scripts": { + "prebuild": "edge-config snapshot" + } +} +``` -export default (req) => { - const value = await get("someKey") - return new Response(`someKey contains value "${value})"`); -}; +**Benefits:** +- Resilience: Your app continues working if Edge Config is temporarily unavailable +- Faster builds: Only a single network request needed per Edge Config during build +- Consistency: Guarantees the same Edge Config state throughout your build -export const config = { runtime: 'edge' }; -``` +**How it works:** +1. The `edge-config snapshot` command scans environment variables for connection strings +2. It fetches the latest version of each Edge Config +3. It saves them to local files that are automatically bundled by your build tool +4. The SDK automatically uses these as fallbacks when needed -## OpenTelemetry Tracing +### Timeouts -The `@vercel/edge-config` package makes use of the OpenTelemetry standard to trace certain functions for observability. In order to enable it, use the function `setTracerProvider` to set the `TracerProvider` that should be used by the SDK. +Set a maximum wait time for Edge Config requests: ```js -import { setTracerProvider } from '@vercel/edge-config'; -import { trace } from '@opentelemetry/api'; +import { createClient } from '@vercel/edge-config'; -setTracerProvider(trace); +const client = createClient(process.env.EDGE_CONFIG, { + timeoutMs: 750, +}); ``` -More verbose traces can be enabled by setting the `EDGE_CONFIG_TRACE_VERBOSE` environment variable to `true`. +**Behavior:** +- If a request exceeds the timeout, the SDK falls back to the bundled version (if available) +- If no bundled version exists, an error is thrown + +**Recommendation:** Only use timeouts when you have bundling enabled or proper error handling. + +### Writing to Edge Config -## Frameworks +Edge Config is optimized for high-volume reads and infrequent writes. Update values using: + +- [Vercel Dashboard](https://vercel.com/docs/concepts/edge-network/edge-config/edge-config-dashboard#manage-items-in-the-store) — Visual interface +- [Vercel API](https://vercel.com/docs/concepts/edge-network/edge-config/vercel-api#update-your-edge-config) — Programmatic updates + +--- + +## Framework Integration ### Next.js -#### Cache Components +#### App Router (Dynamic Rendering) -The Edge Config SDK supports [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) out of the box. Since Edge Config is a dynamic operation it always triggers dynamic mode unless you explicitly opt out as shown in the next section. +By default, Edge Config triggers dynamic rendering: -##### Fetch cache +```js +import { get } from '@vercel/edge-config'; + +export default async function Page() { + const value = await get('myKey'); + return
{value}
; +} +``` -By default the Edge Config SDK will fetch with `no-store`, which triggers dynamic mode in Next.js ([docs](https://nextjs.org/docs/app/api-reference/functions/fetch#optionscache)). +#### App Router (Static Rendering) -To use Edge Config with static pages, pass the `force-cache` option: +To use Edge Config with static pages, enable caching: ```js import { createClient } from '@vercel/edge-config'; -const edgeConfigClient = createClient(process.env.EDGE_CONFIG, { +const client = createClient(process.env.EDGE_CONFIG, { cache: 'force-cache', }); -// then use the client as usual -edgeConfigClient.get('someKey'); +export default async function Page() { + const value = await client.get('myKey'); + return
{value}
; +} +``` + +**Note:** Static rendering may display stale values until the page is rebuilt. + +#### Pages Router + +```js +// pages/api/config.js +import { get } from '@vercel/edge-config'; + +export default async function handler(req, res) { + const value = await get('myKey'); + res.json({ value }); +} ``` -**Note** This opts out of dynamic behavior, so the page might display stale values. +#### Edge Runtime -### Nuxt, SvelteKit and other vite based frameworks +```js +// pages/api/edge.js +import { get } from '@vercel/edge-config'; -`@vercel/edge-config` reads database credentials from the environment variables on `process.env`. In general, `process.env` is automatically populated from your `.env` file during development, which is created when you run `vc env pull`. However, Vite does not expose the `.env` variables on `process.env.` +export default async function handler(req) { + const value = await get('myKey'); + return new Response(JSON.stringify({ value })); +} -You can fix this in **one** of following two ways: +export const config = { runtime: 'edge' }; +``` -1. You can populate `process.env` yourself using something like `dotenv-expand`: +### Vite-Based Frameworks (Nuxt, SvelteKit, etc.) -```shell +Vite doesn't automatically expose `.env` variables on `process.env`. Choose one solution: + +**Option 1: Populate `process.env` with dotenv-expand** + +```sh pnpm install --save-dev dotenv dotenv-expand ``` @@ -170,43 +338,111 @@ import dotenvExpand from 'dotenv-expand'; import { loadEnv, defineConfig } from 'vite'; export default defineConfig(({ mode }) => { - // This check is important! if (mode === 'development') { const env = loadEnv(mode, process.cwd(), ''); dotenvExpand.expand({ parsed: env }); } return { - ... + // Your config }; }); ``` -2. You can provide the credentials explicitly, instead of relying on a zero-config setup. For example, this is how you could create a client in SvelteKit, which makes private environment variables available via `$env/static/private`: +**Option 2: Pass connection string explicitly** -```diff +```js +// SvelteKit example import { createClient } from '@vercel/edge-config'; -+ import { EDGE_CONFIG } from '$env/static/private'; +import { EDGE_CONFIG } from '$env/static/private'; + +const client = createClient(EDGE_CONFIG); +await client.get('myKey'); +``` + +--- + +## Observability + +### OpenTelemetry Tracing + +Enable tracing for observability: -- const edgeConfig = createClient(process.env.ANOTHER_EDGE_CONFIG); -+ const edgeConfig = createClient(EDGE_CONFIG); -await edgeConfig.get('someKey'); +```js +import { setTracerProvider } from '@vercel/edge-config'; +import { trace } from '@opentelemetry/api'; + +setTracerProvider(trace); +``` + +For verbose traces, set the environment variable: + +```sh +EDGE_CONFIG_TRACE_VERBOSE=true +``` + +--- + +## Error Handling + +Edge Config throws errors in these cases: + +- **Invalid connection string**: The provided connection string is malformed or invalid +- **Deleted Edge Config**: The Edge Config has been deleted +- **Network errors**: Request failed due to network issues +- **Timeout**: Request exceeded `timeoutMs` and no bundled fallback is available + +**Example:** + +```js +import { get } from '@vercel/edge-config'; + +try { + const value = await get('myKey'); +} catch (error) { + console.error('Failed to read Edge Config:', error); + // Handle error appropriately +} +``` + +--- + +## Important Notes + +### Immutability + +Values returned by `get()` and `getAll()` are immutable by default. Do not modify them directly: + +```js +// BAD - Do not do this +const value = await get('myKey'); +value.property = 'new value'; // Causes undefined behavior + +// GOOD - Clone first +import { clone } from '@vercel/edge-config'; +const value = await get('myKey'); +const mutableValue = clone(value); +mutableValue.property = 'new value'; // Safe ``` -## Notes +**Why?** For performance, the SDK returns references to cached objects. Mutations can affect other parts of your application. -### Do not mutate return values +--- -Cloning objects in JavaScript can be slow. That's why the Edge Config SDK uses an optimization which can lead to multiple calls reading the same key all receiving a reference to the same value. +## Contributing -For this reason the value read from Edge Config should never be mutated, otherwise they could affect other parts of the code base reading the same key, or a later request reading the same key. +Found a bug or want to contribute? -If you need to modify, see the `clone` function described [here](#do-not-mutate-return-values). +1. [Fork this repository](https://help.github.com/articles/fork-a-repo/) +2. [Clone it locally](https://help.github.com/articles/cloning-a-repository/) +3. Link the package: `npm link` +4. In your test project: `npm link @vercel/edge-config` +5. Make your changes and run tests: `npm test` -## Caught a Bug? +--- -1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device -2. Link the package to the global module directory: `npm link` -3. Within the module you want to test your local development instance of `@vercel/edge-config`, just link it to the dependencies: `npm link @vercel/edge-config`. Instead of the default one from npm, Node.js will now use your clone of `@vercel/edge-config`! +## Resources -As always, you can run the tests using: `npm test` +- [Edge Config Documentation](https://vercel.com/docs/edge-config) +- [Vercel Dashboard](https://vercel.com/) +- [Report Issues](https://github.com/vercel/storage/issues) diff --git a/packages/edge-config/jest/setup.js b/packages/edge-config/jest/setup.js index 971565061..065980f20 100644 --- a/packages/edge-config/jest/setup.js +++ b/packages/edge-config/jest/setup.js @@ -1,6 +1,6 @@ require('jest-fetch-mock').enableMocks(); -process.env.EDGE_CONFIG = 'https://edge-config.vercel.com/ecfg-1?token=token-1'; +process.env.EDGE_CONFIG = 'https://edge-config.vercel.com/ecfg_1?token=token-1'; process.env.VERCEL_ENV = 'test'; // Adds a DOMException polyfill diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 0bd274fdf..334e8a772 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -19,7 +19,8 @@ }, "import": "./dist/index.js", "require": "./dist/index.cjs" - } + }, + "./dist/stores.json": "./dist/stores.json" }, "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -32,12 +33,14 @@ "check": "biome check", "prepublishOnly": "pnpm run build", "publint": "npx publint", - "test": "pnpm run test:node && pnpm run test:edge && pnpm run test:common", - "test:common": "jest --env @edge-runtime/jest-environment .common.test.ts && jest --env node .common.test.ts", - "test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts", - "test:node": "jest --env node .node.test.ts", + "test": "jest", + "test:edge": "jest --env @edge-runtime/jest-environment", + "test:node": "jest --env node", "type-check": "tsc --noEmit" }, + "bin": { + "edge-config": "./dist/cli.js" + }, "jest": { "preset": "ts-jest", "setupFiles": [ @@ -46,7 +49,8 @@ "testEnvironment": "node" }, "dependencies": { - "@vercel/edge-config-fs": "workspace:*" + "@vercel/edge-config-fs": "workspace:*", + "commander": "14.0.2" }, "devDependencies": { "@changesets/cli": "2.29.7", @@ -76,6 +80,6 @@ } }, "engines": { - "node": ">=14.6" + "node": ">=20" } } diff --git a/packages/edge-config/public/stores.json b/packages/edge-config/public/stores.json new file mode 100644 index 000000000..19765bd50 --- /dev/null +++ b/packages/edge-config/public/stores.json @@ -0,0 +1 @@ +null diff --git a/packages/edge-config/src/cli.ts b/packages/edge-config/src/cli.ts new file mode 100755 index 000000000..32446b80b --- /dev/null +++ b/packages/edge-config/src/cli.ts @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/* + * Edge Config CLI + * + * command: prepare + * Reads all connected Edge Configs and emits a single stores.json file. + * that can be accessed at runtime by the mockable-import function. + * + * Attaches the updatedAt timestamp from the header to the emitted file, since + * the endpoint does not currently include it in the response body. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Command } from 'commander'; +import { version } from '../package.json'; +import type { + BundledEdgeConfig, + Connection, + EmbeddedEdgeConfig, +} from '../src/types'; +import { + parseConnectionString, + parseTimeoutMs, +} from '../src/utils/parse-connection-string'; + +// Get the directory where this CLI script is located +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +type StoresJson = Record; + +type PrepareOptions = { + verbose?: boolean; +}; + +/** + * Parses a connection string with the following format: + * `flags:edgeConfigId=ecfg_abcd&edgeConfigToken=xxx` + */ +function parseConnectionFromFlags(text: string): Connection | null { + try { + if (!text.startsWith('flags:')) return null; + const params = new URLSearchParams(text.slice(6)); + + const id = params.get('edgeConfigId'); + const token = params.get('edgeConfigToken'); + + if (!id || !token) return null; + + const snapshot = + params.get('snapshot') === 'required' ? 'required' : 'optional'; + + const timeoutMs = parseTimeoutMs(params.get('timeoutMs')); + + return { + type: 'vercel', + baseUrl: `https://edge-config.vercel.com/${id}`, + id, + version: '1', + token, + snapshot, + timeoutMs, + }; + } catch { + // no-op + } + + return null; +} + +async function prepare(output: string, options: PrepareOptions): Promise { + const connections = Object.values(process.env).reduce( + (acc, value) => { + if (typeof value !== 'string') return acc; + const data = parseConnectionString(value); + if (data) acc.push(data); + + const vfData = parseConnectionFromFlags(value); + if (vfData) acc.push(vfData); + + return acc; + }, + [], + ); + + const values: BundledEdgeConfig[] = await Promise.all( + connections.map>(async (connection) => { + const res = await fetch(connection.baseUrl, { + headers: { + authorization: `Bearer ${connection.token}`, + // consistentRead + 'x-edge-config-min-updated-at': `${Number.MAX_SAFE_INTEGER}`, + 'user-agent': `@vercel/edge-config@${version} (prepare)`, + }, + }); + + if (!res.ok) { + throw new Error( + `@vercel/edge-config: Failed to prepare edge config ${connection.id}: ${res.status} ${res.statusText}`, + ); + } + + const ts = res.headers.get('x-edge-config-updated-at'); + const data: EmbeddedEdgeConfig = await res.json(); + return { data, updatedAt: ts ? Number(ts) : undefined }; + }), + ); + + const stores = connections.reduce((acc, connection, index) => { + const value = values[index]; + acc[connection.id] = value; + return acc; + }, {}); + + // Ensure the dist directory exists before writing + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, JSON.stringify(stores)); + if (options.verbose) { + console.log(`@vercel/edge-config snapshot`); + console.log(` → created ${output}`); + if (Object.keys(stores).length === 0) { + console.log(` → no edge configs included`); + } else { + console.log(` → included ${Object.keys(stores).join(', ')}`); + } + } +} + +const program = new Command(); +program + .name('@vercel/edge-config') + .description('Vercel Edge Config CLI') + .version(version); + +program + .command('snapshot') + .description( + 'Capture point-in-time snapshots of Edge Configs. ' + + 'Ensures consistent values during build, enables instant bootstrapping, ' + + 'and provides fallback when the service is unavailable.', + ) + .option('--verbose', 'Enable verbose logging') + .action(async (options: PrepareOptions) => { + if (process.env.EDGE_CONFIG_SKIP_PREPARE_SCRIPT === '1') return; + + const output = join(__dirname, '..', 'dist', 'stores.json'); + await prepare(output, options); + }); + +program.parse(); diff --git a/packages/edge-config/src/create-create-client.ts b/packages/edge-config/src/create-create-client.ts index 9df61745a..3180fd213 100644 --- a/packages/edge-config/src/create-create-client.ts +++ b/packages/edge-config/src/create-create-client.ts @@ -1,6 +1,7 @@ import { name as sdkName, version as sdkVersion } from '../package.json'; import type * as deps from './edge-config'; import type { + BundledEdgeConfig, EdgeConfigClient, EdgeConfigFunctionsOptions, EdgeConfigItems, @@ -15,6 +16,9 @@ import { parseConnectionString, pick, } from './utils'; +import { delay } from './utils/delay'; +import { readBundledEdgeConfig } from './utils/read-bundled-edge-config'; +import { TimeoutError } from './utils/timeout-error'; import { trace } from './utils/tracing'; type CreateClient = ( @@ -53,6 +57,7 @@ export function createCreateClient({ options = { staleIfError: 604800 /* one week */, cache: 'no-store', + snapshot: 'optional', }, ): EdgeConfigClient { if (!connectionString) @@ -81,7 +86,15 @@ export function createCreateClient({ if (typeof options.staleIfError === 'number' && options.staleIfError > 0) headers['cache-control'] = `stale-if-error=${options.staleIfError}`; + const snapshot = options.snapshot ?? connection.snapshot; + const fetchCache = options.cache || 'no-store'; + const timeoutMs = + typeof options.timeoutMs === 'number' + ? options.timeoutMs + : typeof connection.timeoutMs === 'number' + ? connection.timeoutMs + : undefined; /** * While in development we use SWR-like behavior for the api client to @@ -92,6 +105,56 @@ export function createCreateClient({ process.env.NODE_ENV === 'development' && process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; + /** + * The edge config bundled at build time + */ + const bundledEdgeConfig: BundledEdgeConfig | null = + connection && connection.type === 'vercel' + ? readBundledEdgeConfig(connection.id) + : null; + + const isBuildStep = + process.env.CI === '1' || + process.env.NEXT_PHASE === 'phase-production-build'; + + if ( + isBuildStep && + snapshot === 'required' && + bundledEdgeConfig === null + ) { + throw new Error( + `@vercel/edge-config: Missing snapshot for ${connection.id}. Did you forget to set up the "edge-config snapshot" script or do you have multiple Edge Config versions present in your project?`, + ); + } + + /** + * Ensures that the provided function runs within a specified timeout. + * If the timeout is reached before the function completes, it returns the fallback. + */ + async function timeout( + method: string, + key: string | string[] | undefined, + localOptions: EdgeConfigFunctionsOptions | undefined, + run: () => Promise, + ): Promise { + const ms = localOptions?.timeoutMs ?? timeoutMs; + + if (typeof ms !== 'number') return run(); + + let timer: NodeJS.Timeout | undefined; + // ensure we don't throw within race to avoid throwing after run() completes + const result = await Promise.race([ + delay(ms, new TimeoutError(edgeConfigId, method, key), (t) => { + timer = t; + }), + run(), + ]).finally(() => { + clearTimeout(timer); + }); + if (result instanceof TimeoutError) throw result; + return result; + } + const api: Omit = { get: trace( async function get( @@ -100,40 +163,53 @@ export function createCreateClient({ ): Promise { assertIsKey(key); - let localEdgeConfig: EmbeddedEdgeConfig | null = null; - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); + function select(edgeConfig: EmbeddedEdgeConfig) { + if (isEmptyKey(key)) return undefined; + return edgeConfig.items[key] as T; } - if (localEdgeConfig) { - if (isEmptyKey(key)) return undefined; - // We need to return a clone of the value so users can't modify - // our original value, and so the reference changes. - // - // This makes it consistent with the real API. - return Promise.resolve(localEdgeConfig.items[key] as T); + if (bundledEdgeConfig && isBuildStep) { + return select(bundledEdgeConfig.data); } - return fetchEdgeConfigItem( - baseUrl, - key, - version, - localOptions?.consistentRead, - headers, - fetchCache, - ); + try { + return await timeout('get', key, localOptions, async () => { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } + + if (localEdgeConfig) return select(localEdgeConfig); + + return await fetchEdgeConfigItem( + baseUrl, + key, + version, + localOptions?.consistentRead, + headers, + fetchCache, + ); + }); + } catch (error) { + if (!bundledEdgeConfig) throw error; + console.warn( + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId} due to the following error`, + error, + ); + return select(bundledEdgeConfig.data); + } }, { name: 'get', isVerboseTrace: false, attributes: { edgeConfigId } }, ), @@ -145,36 +221,55 @@ export function createCreateClient({ assertIsKey(key); if (isEmptyKey(key)) return false; - let localEdgeConfig: EmbeddedEdgeConfig | null = null; - - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); + function select(edgeConfig: EmbeddedEdgeConfig) { + return hasOwn(edgeConfig.items, key); } - if (localEdgeConfig) { - return Promise.resolve(hasOwn(localEdgeConfig.items, key)); + if (bundledEdgeConfig && isBuildStep) { + return select(bundledEdgeConfig.data); } - return fetchEdgeConfigHas( - baseUrl, - key, - version, - localOptions?.consistentRead, - headers, - fetchCache, - ); + try { + return await timeout('has', key, localOptions, async () => { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } + + if (localEdgeConfig) { + return Promise.resolve(hasOwn(localEdgeConfig.items, key)); + } + + return await fetchEdgeConfigHas( + baseUrl, + key, + version, + localOptions?.consistentRead, + headers, + fetchCache, + ); + }); + } catch (error) { + if (!bundledEdgeConfig) throw error; + console.warn( + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId} due to the following error`, + error, + ); + return select(bundledEdgeConfig.data); + } }, { name: 'has', isVerboseTrace: false, attributes: { edgeConfigId } }, ), @@ -187,40 +282,55 @@ export function createCreateClient({ assertIsKeys(keys); } - let localEdgeConfig: EmbeddedEdgeConfig | null = null; + function select(edgeConfig: EmbeddedEdgeConfig) { + return keys === undefined + ? (edgeConfig.items as T) + : (pick(edgeConfig.items as T, keys) as T); + } - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); + if (bundledEdgeConfig && isBuildStep) { + return select(bundledEdgeConfig.data); } - if (localEdgeConfig) { - if (keys === undefined) { - return Promise.resolve(localEdgeConfig.items as T); - } + try { + return await timeout('getAll', keys, localOptions, async () => { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; - return Promise.resolve(pick(localEdgeConfig.items, keys) as T); - } + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } - return fetchAllEdgeConfigItem( - baseUrl, - keys, - version, - localOptions?.consistentRead, - headers, - fetchCache, - ); + if (localEdgeConfig) return select(localEdgeConfig); + + return await fetchAllEdgeConfigItem( + baseUrl, + keys, + version, + localOptions?.consistentRead, + headers, + fetchCache, + ); + }); + } catch (error) { + if (!bundledEdgeConfig) throw error; + console.warn( + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId} due to the following error`, + error, + ); + return select(bundledEdgeConfig.data); + } }, { name: 'getAll', @@ -232,35 +342,57 @@ export function createCreateClient({ async function digest( localOptions?: EdgeConfigFunctionsOptions, ): Promise { - let localEdgeConfig: EmbeddedEdgeConfig | null = null; - - if (localOptions?.consistentRead) { - // fall through to fetching - } else if (shouldUseDevelopmentCache) { - localEdgeConfig = await getInMemoryEdgeConfig( - connectionString, - fetchCache, - options.staleIfError, - ); - } else { - localEdgeConfig = await getLocalEdgeConfig( - connection.type, - connection.id, - fetchCache, - ); + function select(embeddedEdgeConfig: EmbeddedEdgeConfig) { + return embeddedEdgeConfig.digest; } - if (localEdgeConfig) { - return Promise.resolve(localEdgeConfig.digest); + if (bundledEdgeConfig && isBuildStep) { + return select(bundledEdgeConfig.data); } - return fetchEdgeConfigTrace( - baseUrl, - version, - localOptions?.consistentRead, - headers, - fetchCache, - ); + try { + return await timeout( + 'digest', + undefined, + localOptions, + async () => { + let localEdgeConfig: EmbeddedEdgeConfig | null = null; + + if (localOptions?.consistentRead) { + // fall through to fetching + } else if (shouldUseDevelopmentCache) { + localEdgeConfig = await getInMemoryEdgeConfig( + connectionString, + fetchCache, + options.staleIfError, + ); + } else { + localEdgeConfig = await getLocalEdgeConfig( + connection.type, + connection.id, + fetchCache, + ); + } + + if (localEdgeConfig) return select(localEdgeConfig); + + return await fetchEdgeConfigTrace( + baseUrl, + version, + localOptions?.consistentRead, + headers, + fetchCache, + ); + }, + ); + } catch (error) { + if (!bundledEdgeConfig) throw error; + console.warn( + `@vercel/edge-config: Falling back to bundled version of ${edgeConfigId} due to the following error`, + error, + ); + return select(bundledEdgeConfig.data); + } }, { name: 'digest', diff --git a/packages/edge-config/src/edge-config.ts b/packages/edge-config/src/edge-config.ts index 32833bfa3..9a55e74da 100644 --- a/packages/edge-config/src/edge-config.ts +++ b/packages/edge-config/src/edge-config.ts @@ -301,6 +301,7 @@ export async function fetchEdgeConfigHas( if (consistentRead) { addConsistentReadHeader(headers); } + // this is a HEAD request anyhow, no need for fetchWithCachedResponse return fetch(`${baseUrl}/item/${key}?version=${version}`, { method: 'HEAD', @@ -456,4 +457,22 @@ export interface EdgeConfigClientOptions { * Unlike Next.js, this defaults to `no-store`, as you most likely want to use Edge Config dynamically. */ cache?: 'no-store' | 'force-cache'; + + /** + * How long to wait for a fresh value before falling back to a stale value or throwing. + * + * It is recommended to only use this in combination with a bundled Edge Config (see "edge-config snapshot" script). + */ + timeoutMs?: number; + + /** + * When set to "required" the createClient will throw an error when no snapshot is found + * during the build process. + * + * Note that the client will only check for the snapshot and throw when the CI environment + * variable is set to "1", or when the NEXT_PHASE environment variable is set to "phase-production-build". + * + * This is done as the snapshot is usually not present in development. + */ + snapshot?: 'optional' | 'required'; } diff --git a/packages/edge-config/src/index.bundled.test.ts b/packages/edge-config/src/index.bundled.test.ts new file mode 100644 index 000000000..916762218 --- /dev/null +++ b/packages/edge-config/src/index.bundled.test.ts @@ -0,0 +1,510 @@ +// Tests the bundled Edge Config (stores.json) behavior + +import fetchMock from 'jest-fetch-mock'; +import { version as pkgVersion } from '../package.json'; +import { get, getAll, has } from './index'; +import type { EmbeddedEdgeConfig } from './types'; +import { delay } from './utils/delay'; +import { cache } from './utils/fetch-with-cached-response'; +import { TimeoutError } from './utils/timeout-error'; + +jest.mock('@vercel/edge-config/dist/stores.json', () => { + return { + ecfg_1: { + updatedAt: 100, + data: { + items: { foo: 'foo-build-embedded', bar: 'bar-build-embedded' }, + digest: 'a', + }, + }, + }; +}); + +const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; +const baseUrl = 'https://edge-config.vercel.com/ecfg_1'; + +beforeEach(() => { + fetchMock.resetMocks(); + cache.clear(); + + jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); +}); + +// mock fs for test +jest.mock('@vercel/edge-config-fs', () => { + const embeddedEdgeConfig: EmbeddedEdgeConfig = { + digest: 'awe1', + items: { foo: 'bar', someArray: [] }, + }; + + return { + readFile: jest.fn((): Promise => { + return Promise.resolve(JSON.stringify(embeddedEdgeConfig)); + }), + }; +}); + +describe('default Edge Config', () => { + describe('test conditions', () => { + it('should have an env var called EDGE_CONFIG', () => { + expect(process.env.EDGE_CONFIG).toEqual( + 'https://edge-config.vercel.com/ecfg_1?token=token-1', + ); + }); + }); + + describe('get(key)', () => { + describe('when fetch aborts', () => { + it('should fall back to the build embedded config', async () => { + fetchMock.mockAbort(); + await expect(get('foo')).resolves.toEqual('foo-build-embedded'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + + describe('when fetch rejects', () => { + it('should fall back to the build embedded config', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + await expect(get('foo')).resolves.toEqual('foo-build-embedded'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + + describe('when fetch times out', () => { + it('should fall back to the build embedded config', async () => { + const timeoutMs = 50; + fetchMock.mockResponseOnce(() => + delay(timeoutMs * 4, JSON.stringify('fetched-value')), + ); + await expect(get('foo', { timeoutMs })).resolves.toEqual( + 'foo-build-embedded', + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(TimeoutError), + ); + }); + }); + }); + + describe('getAll(keys)', () => { + describe('when fetch aborts', () => { + describe('when called without keys', () => { + it('should return all items', async () => { + fetchMock.mockAbort(); + + await expect(getAll()).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + describe('when called with keys', () => { + it('should return the selected items', async () => { + fetchMock.mockAbort(); + + await expect(getAll(['foo', 'bar'])).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/items?version=1&key=foo&key=bar`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + }); + + describe('when fetch rejects', () => { + describe('when called without keys', () => { + it('should return all items', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + + await expect(getAll()).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + describe('when called with keys', () => { + it('should return the selected items', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + + await expect(getAll(['foo', 'bar'])).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/items?version=1&key=foo&key=bar`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + }); + + describe('when fetch times out', () => { + const timeoutMs = 50; + describe('when called without keys', () => { + it('should return all items', async () => { + fetchMock.mockResponseOnce(() => + delay( + timeoutMs * 4, + JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), + ), + ); + + await expect(getAll(undefined, { timeoutMs })).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/items?version=1`, { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + describe('when called with keys', () => { + it('should return the selected items', async () => { + fetchMock.mockResponseOnce(() => + delay( + timeoutMs * 4, + JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), + ), + ); + + await expect(getAll(['foo', 'bar'], { timeoutMs })).resolves.toEqual({ + foo: 'foo-build-embedded', + bar: 'bar-build-embedded', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/items?version=1&key=foo&key=bar`, + { + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + }); + }); + + describe('has(key)', () => { + describe('when fetch aborts', () => { + describe('when item exists', () => { + it('should return true', async () => { + fetchMock.mockAbort(); + + await expect(has('foo')).resolves.toEqual(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + + describe('when the item does not exist', () => { + it('should return false', async () => { + fetchMock.mockAbort(); + await expect(has('foo-does-not-exist')).resolves.toEqual(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo-does-not-exist?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + }); + + describe('when fetch rejects', () => { + describe('when item exists', () => { + it('should return true', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + + await expect(has('foo')).resolves.toEqual(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + + describe('when the item does not exist', () => { + it('should return false', async () => { + fetchMock.mockReject(new Error('mock fetch error')); + await expect(has('foo-does-not-exist')).resolves.toEqual(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo-does-not-exist?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + }); + + describe('when fetch times out', () => { + const timeoutMs = 50; + describe('when item exists', () => { + it('should return true', async () => { + fetchMock.mockResponseOnce(() => + delay(timeoutMs * 4, { + status: 404, + headers: { 'x-edge-config-digest': '1' }, + }), + ); + + await expect(has('foo', { timeoutMs })).resolves.toEqual(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + + describe('when the item does not exist', () => { + it('should return false', async () => { + fetchMock.mockResponseOnce(() => + delay( + timeoutMs * 4, + JSON.stringify({ foo: 'fetched-foo', bar: 'fetched-bar' }), + ), + ); + await expect( + has('foo-does-not-exist', { timeoutMs }), + ).resolves.toEqual(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/item/foo-does-not-exist?version=1`, + { + method: 'HEAD', + headers: new Headers({ + Authorization: 'Bearer token-1', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + + expect(console.warn).toHaveBeenCalledWith( + '@vercel/edge-config: Falling back to bundled version of ecfg_1 due to the following error', + expect.any(DOMException), + ); + }); + }); + }); + }); +}); diff --git a/packages/edge-config/src/index.edge.test.ts b/packages/edge-config/src/index.edge.test.ts index d23b37d4a..50367d448 100644 --- a/packages/edge-config/src/index.edge.test.ts +++ b/packages/edge-config/src/index.edge.test.ts @@ -1,10 +1,14 @@ +/** + * @jest-environment @edge-runtime/jest-environment + */ + import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import { createClient, digest, get, getAll, has } from './index'; import { cache } from './utils/fetch-with-cached-response'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; -const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; +const baseUrl = 'https://edge-config.vercel.com/ecfg_1'; describe('default Edge Config', () => { beforeEach(() => { @@ -15,9 +19,13 @@ describe('default Edge Config', () => { describe('test conditions', () => { it('should have an env var called EDGE_CONFIG', () => { expect(process.env.EDGE_CONFIG).toEqual( - 'https://edge-config.vercel.com/ecfg-1?token=token-1', + 'https://edge-config.vercel.com/ecfg_1?token=token-1', ); }); + + it('should use Edge Runtime', () => { + expect(EdgeRuntime).toBe('edge-runtime'); + }); }); it('should fetch an item from the Edge Config specified by process.env.EDGE_CONFIG', async () => { @@ -102,7 +110,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -241,7 +249,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -375,7 +383,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, diff --git a/packages/edge-config/src/index.node.test.ts b/packages/edge-config/src/index.node.test.ts index 4a3ee1ce5..c2a87d06a 100644 --- a/packages/edge-config/src/index.node.test.ts +++ b/packages/edge-config/src/index.node.test.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment node + */ + import { readFile } from '@vercel/edge-config-fs'; import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; @@ -6,7 +10,7 @@ import type { EmbeddedEdgeConfig } from './types'; import { cache } from './utils/fetch-with-cached-response'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; -const baseUrl = 'https://edge-config.vercel.com/ecfg-1'; +const baseUrl = 'https://edge-config.vercel.com/ecfg_1'; beforeEach(() => { fetchMock.resetMocks(); @@ -31,9 +35,13 @@ describe('default Edge Config', () => { describe('test conditions', () => { it('should have an env var called EDGE_CONFIG', () => { expect(process.env.EDGE_CONFIG).toEqual( - 'https://edge-config.vercel.com/ecfg-1?token=token-1', + 'https://edge-config.vercel.com/ecfg_1?token=token-1', ); }); + + it('should use Edge Runtime', () => { + expect(typeof EdgeRuntime).toBe('undefined'); + }); }); it('should fetch an item from the Edge Config specified by process.env.EDGE_CONFIG', async () => { @@ -118,7 +126,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -257,7 +265,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -391,7 +399,7 @@ describe('default Edge Config', () => { JSON.stringify({ error: { code: 'edge_config_not_found', - message: 'Could not find the edge config: ecfg-1', + message: 'Could not find the edge config: ecfg_1', }, }), { status: 404, headers: { 'content-type': 'application/json' } }, @@ -524,7 +532,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -537,7 +545,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -577,7 +585,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -590,7 +598,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -604,7 +612,7 @@ describe('createClient', () => { expect(fetchMock).toHaveBeenCalledTimes(0); expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); }); @@ -622,14 +630,14 @@ describe('createClient', () => { // returns undefined as file does not exist expect(readFile).toHaveBeenCalledTimes(1); expect(readFile).toHaveBeenCalledWith( - '/opt/edge-config/ecfg-1.json', + '/opt/edge-config/ecfg_1.json', 'utf-8', ); // ensure fetch was called with the right options expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - 'https://edge-config.vercel.com/ecfg-1/item/foo?version=1', + 'https://edge-config.vercel.com/ecfg_1/item/foo?version=1', { cache: 'force-cache', headers: new Headers({ diff --git a/packages/edge-config/src/index.common.test.ts b/packages/edge-config/src/index.test.ts similarity index 80% rename from packages/edge-config/src/index.common.test.ts rename to packages/edge-config/src/index.test.ts index 71ac8e93b..9036e1a0c 100644 --- a/packages/edge-config/src/index.common.test.ts +++ b/packages/edge-config/src/index.test.ts @@ -1,12 +1,8 @@ -// This file is meant to ensure the common logic works in both enviornments. -// -// It runs tests in both envs: -// - @edge-runtime/jest-environment -// - node import fetchMock from 'jest-fetch-mock'; import { version as pkgVersion } from '../package.json'; import * as pkg from './index'; import type { EdgeConfigClient } from './types'; +import { delay } from './utils/delay'; import { cache } from './utils/fetch-with-cached-response'; const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; @@ -14,100 +10,76 @@ const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : ''; describe('test conditions', () => { it('should have an env var called EDGE_CONFIG', () => { expect(process.env.EDGE_CONFIG).toEqual( - 'https://edge-config.vercel.com/ecfg-1?token=token-1', + 'https://edge-config.vercel.com/ecfg_1?token=token-1', ); }); }); -// test both package.json exports (for node & edge) separately - describe('parseConnectionString', () => { - it('should return null when an invalid Connection String is given', () => { - expect(pkg.parseConnectionString('foo')).toBeNull(); - }); - - it('should return null when the given Connection String has no token', () => { - expect( - pkg.parseConnectionString( - 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', - ), - ).toBeNull(); + it.each([ + ['url with no id', 'https://edge-config.vercel.com/?token=abcd'], + [ + 'url with no token', + 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', + ], + ['edge-config protocol without id', 'edge-config:token=abcd&id='], + [ + 'edge-config protocol without params', + 'edge-config:ecfg_cljia81u2q1gappdgptj881dwwtc', + ], + [ + 'edge-config protocol without token param', + 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc', + ], + ])('should return null when an invalid Connection String is given (%s)', (_, connectionString) => { + expect(pkg.parseConnectionString(connectionString)).toBeNull(); }); - it('should return the id and token when a valid internal Connection String is given', () => { - expect( - pkg.parseConnectionString( - 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc?token=00000000-0000-0000-0000-000000000000', - ), - ).toEqual({ + it.each([ + [ + 'url', + 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc?token=00000000-0000-0000-0000-000000000000', + ], + [ + 'edge-config', + 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=00000000-0000-0000-0000-000000000000', + ], + ])('should return the id and token when a valid connection string is given (%s)', (_, connectionString) => { + expect(pkg.parseConnectionString(connectionString)).toEqual({ baseUrl: 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', token: '00000000-0000-0000-0000-000000000000', type: 'vercel', version: '1', + snapshot: 'optional', + timeoutMs: undefined, }); }); - it('should return the id and token when a valid external Connection String is given using pathname', () => { - expect( - pkg.parseConnectionString( - 'https://example.com/ecfg_cljia81u2q1gappdgptj881dwwtc?token=00000000-0000-0000-0000-000000000000', - ), - ).toEqual({ - id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', - token: '00000000-0000-0000-0000-000000000000', - version: '1', - type: 'external', - baseUrl: 'https://example.com/ecfg_cljia81u2q1gappdgptj881dwwtc', - }); - }); - - it('should return the id and token when a valid external Connection String is given using search params', () => { - expect( - pkg.parseConnectionString( - 'https://example.com/?id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=00000000-0000-0000-0000-000000000000', - ), - ).toEqual({ - id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', - token: '00000000-0000-0000-0000-000000000000', - baseUrl: 'https://example.com/', - type: 'external', - version: '1', - }); - }); - - it('should return a valid connection for an `edgd-config:` connection string', () => { - expect( - pkg.parseConnectionString( - 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=00000000-0000-0000-0000-000000000000', - ), - ).toEqual({ - baseUrl: - 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', - id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', - token: '00000000-0000-0000-0000-000000000000', - type: 'vercel', - version: '1', + describe('option#snapshot', () => { + it.each([ + [ + 'should parse snapshot=required', + 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=token-2&snapshot=required', + ], + [ + 'should parse snapshot=optional', + 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc&token=token-2&snapshot=required', + ], + ])('%s', (_, connectionString) => { + expect(pkg.parseConnectionString(connectionString)).toEqual({ + baseUrl: + 'https://edge-config.vercel.com/ecfg_cljia81u2q1gappdgptj881dwwtc', + id: 'ecfg_cljia81u2q1gappdgptj881dwwtc', + token: 'token-2', + type: 'vercel', + version: '1', + snapshot: 'required', + timeoutMs: undefined, + }); }); }); - - it('should return null for an invalid `edge-config:` connection string', () => { - expect(pkg.parseConnectionString('edge-config:token=abd&id=')).toEqual( - null, - ); - expect( - pkg.parseConnectionString( - 'edge-config:ecfg_cljia81u2q1gappdgptj881dwwtc', - ), - ).toEqual(null); - expect( - pkg.parseConnectionString( - 'edge-config:id=ecfg_cljia81u2q1gappdgptj881dwwtc', - ), - ).toEqual(null); - expect(pkg.parseConnectionString('edge-config:invalid')).toEqual(null); - }); }); describe('when running without lambda layer or via edge function', () => { @@ -162,6 +134,30 @@ describe('when running without lambda layer or via edge function', () => { expect(fetchMock).toHaveBeenCalledTimes(0); }); }); + + describe('timeoutMs', () => { + it('should not race when timeoutMs is not set', async () => { + fetchMock.mockResponseOnce(() => + delay(10, JSON.stringify('fetched-value')), + ); + + await expect(edgeConfig.get('foo')).resolves.toEqual('fetched-value'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${modifiedBaseUrl}/item/foo?version=1`, + { + headers: new Headers({ + Authorization: 'Bearer token-2', + 'x-edge-config-vercel-env': 'test', + 'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`, + 'cache-control': 'stale-if-error=604800', + }), + cache: 'no-store', + }, + ); + }); + }); }); describe('has(key)', () => { @@ -404,6 +400,8 @@ describe('connectionStrings', () => { token: 'token-2', type: 'external', version: '1', + snapshot: 'optional', + timeoutMs: undefined, }); }); }); diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 4726c45c2..ffcdd170c 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -13,8 +13,9 @@ import type { EdgeConfigValue, EmbeddedEdgeConfig, } from './types'; -import { parseConnectionString } from './utils'; +import { ERRORS, parseConnectionString } from './utils'; +export { TimeoutError } from './utils/timeout-error'; export { setTracerProvider } from './utils/tracing'; export { @@ -44,15 +45,16 @@ export const createClient = createCreateClient({ fetchEdgeConfigTrace, }); -let defaultEdgeConfigClient: EdgeConfigClient; - -// lazy init fn so the default edge config does not throw in case -// process.env.EDGE_CONFIG is not defined and its methods are never used. -function init(): void { - if (!defaultEdgeConfigClient) { - defaultEdgeConfigClient = createClient(process.env.EDGE_CONFIG); - } -} +/** + * The default Edge Config client that is automatically created from the `process.env.EDGE_CONFIG` environment variable. + * When using the `get`, `getAl`, `has`, and `digest` exports they use this underlying default client. + */ +export const defaultClient: EdgeConfigClient | null = + typeof process.env.EDGE_CONFIG === 'string' && + (process.env.EDGE_CONFIG.startsWith('edge-config:') || + process.env.EDGE_CONFIG.startsWith('https://edge-config.vercel.com/')) + ? createClient(process.env.EDGE_CONFIG) + : null; /** * Reads a single item from the default Edge Config. @@ -65,8 +67,10 @@ function init(): void { * @returns the value stored under the given key, or undefined */ export const get: EdgeConfigClient['get'] = (...args) => { - init(); - return defaultEdgeConfigClient.get(...args); + if (!defaultClient) { + throw new Error(ERRORS.MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING); + } + return defaultClient.get(...args); }; /** @@ -80,8 +84,10 @@ export const get: EdgeConfigClient['get'] = (...args) => { * @returns the value stored under the given key, or undefined */ export const getAll: EdgeConfigClient['getAll'] = (...args) => { - init(); - return defaultEdgeConfigClient.getAll(...args); + if (!defaultClient) { + throw new Error(ERRORS.MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING); + } + return defaultClient.getAll(...args); }; /** @@ -95,8 +101,10 @@ export const getAll: EdgeConfigClient['getAll'] = (...args) => { * @returns true if the given key exists in the Edge Config. */ export const has: EdgeConfigClient['has'] = (...args) => { - init(); - return defaultEdgeConfigClient.has(...args); + if (!defaultClient) { + throw new Error(ERRORS.MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING); + } + return defaultClient.has(...args); }; /** @@ -109,8 +117,10 @@ export const has: EdgeConfigClient['has'] = (...args) => { * @returns The digest of the Edge Config. */ export const digest: EdgeConfigClient['digest'] = (...args) => { - init(); - return defaultEdgeConfigClient.digest(...args); + if (!defaultClient) { + throw new Error(ERRORS.MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING); + } + return defaultClient.digest(...args); }; /** diff --git a/packages/edge-config/src/stores.json b/packages/edge-config/src/stores.json new file mode 100644 index 000000000..19765bd50 --- /dev/null +++ b/packages/edge-config/src/stores.json @@ -0,0 +1 @@ +null diff --git a/packages/edge-config/src/types.ts b/packages/edge-config/src/types.ts index d6ce01c1f..d468b77e3 100644 --- a/packages/edge-config/src/types.ts +++ b/packages/edge-config/src/types.ts @@ -3,6 +3,18 @@ export interface EmbeddedEdgeConfig { items: Record; } +/** + * An Edge Config bundled into stores.json + * + * The contents of stores.json itself are either + * - null + * - Record + */ +export type BundledEdgeConfig = { + data: EmbeddedEdgeConfig; + updatedAt: number | undefined; +}; + /** * The parsed info contained in a connection string. */ @@ -13,6 +25,8 @@ export type Connection = token: string; version: string; type: 'vercel'; + snapshot: 'required' | 'optional'; + timeoutMs: number | undefined; } | { baseUrl: string; @@ -20,6 +34,8 @@ export type Connection = token: string; version: string; type: 'external'; + snapshot: 'required' | 'optional'; + timeoutMs: number | undefined; }; /** @@ -91,4 +107,10 @@ export interface EdgeConfigFunctionsOptions { * need to ensure you generate with the latest content. */ consistentRead?: boolean; + + /** + * How long to wait for the Edge Config to be fetched before timing out + * and falling back to the bundled Edge Config value if present, or throwing. + */ + timeoutMs?: number; } diff --git a/packages/edge-config/src/utils/delay.ts b/packages/edge-config/src/utils/delay.ts new file mode 100644 index 000000000..35488bfa1 --- /dev/null +++ b/packages/edge-config/src/utils/delay.ts @@ -0,0 +1,10 @@ +export function delay( + timeoutMs: number, + data: T, + assign?: (timeoutId?: NodeJS.Timeout) => void, +): Promise { + return new Promise((resolve) => { + const timeoutId = setTimeout(() => resolve(data), timeoutMs); + assign?.(timeoutId); + }); +} diff --git a/packages/edge-config/src/utils/index.ts b/packages/edge-config/src/utils/index.ts index 8cc02b738..ae219a75a 100644 --- a/packages/edge-config/src/utils/index.ts +++ b/packages/edge-config/src/utils/index.ts @@ -1,11 +1,14 @@ -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', + MISSING_DEFAULT_EDGE_CONFIG_CONNECTION_STRING: + '@vercel/edge-config: Missing default Edge Config connection string', }; +export { parseConnectionString } from './parse-connection-string'; + export class UnexpectedNetworkError extends Error { constructor(res: Response) { super( @@ -65,142 +68,3 @@ export const clone = trace( }, { name: 'clone' }, ); - -/** - * Parses internal edge config connection strings - * - * Internal edge config connection strings are those which are native to Vercel. - * - * Internal Edge Config Connection Strings look like this: - * https://edge-config.vercel.com/?token= - */ -function parseVercelConnectionStringFromUrl(text: string): Connection | null { - try { - const url = new URL(text); - if (url.host !== 'edge-config.vercel.com') return null; - if (url.protocol !== 'https:') return null; - if (!url.pathname.startsWith('/ecfg')) return null; - - const id = url.pathname.split('/')[1]; - if (!id) return null; - - const token = url.searchParams.get('token'); - if (!token || token === '') return null; - - return { - type: 'vercel', - baseUrl: `https://edge-config.vercel.com/${id}`, - id, - version: '1', - token, - }; - } catch { - return null; - } -} - -/** - * Parses a connection string with the following format: - * `edge-config:id=ecfg_abcd&token=xxx` - */ -function parseConnectionFromQueryParams(text: string): Connection | null { - try { - if (!text.startsWith('edge-config:')) return null; - const params = new URLSearchParams(text.slice(12)); - - const id = params.get('id'); - const token = params.get('token'); - - if (!id || !token) return null; - - return { - type: 'vercel', - baseUrl: `https://edge-config.vercel.com/${id}`, - id, - version: '1', - token, - }; - } catch { - // no-op - } - - return null; -} - -/** - * Parses info contained in connection strings. - * - * This works with the vercel-provided connection strings, but it also - * works with custom connection strings. - * - * The reason we support custom connection strings is that it makes testing - * edge config really straightforward. Users can provide connection strings - * pointing to their own servers and then either have a custom server - * return the desired values or even intercept requests with something like - * msw. - * - * To allow interception we need a custom connection string as the - * edge-config.vercel.com connection string might not always go over - * the network, so msw would not have a chance to intercept. - */ -/** - * Parses external edge config connection strings - * - * External edge config connection strings are those which are foreign to Vercel. - * - * External Edge Config Connection Strings look like this: - * - https://example.com/?id=&token= - * - https://example.com/?token= - */ -function parseExternalConnectionStringFromUrl( - connectionString: string, -): Connection | null { - try { - const url = new URL(connectionString); - - let id: string | null = url.searchParams.get('id'); - const token = url.searchParams.get('token'); - const version = url.searchParams.get('version') || '1'; - - // try to determine id based on pathname if it wasn't provided explicitly - if (!id || url.pathname.startsWith('/ecfg_')) { - id = url.pathname.split('/')[1] || null; - } - - if (!id || !token) return null; - - // remove all search params for use as baseURL - url.search = ''; - - // try to parse as external connection string - return { - type: 'external', - baseUrl: url.toString(), - id, - token, - version, - }; - } catch { - return null; - } -} - -/** - * Parse the edgeConfigId and token from an Edge Config Connection String. - * - * Edge Config Connection Strings usually look like one of the following: - * - https://edge-config.vercel.com/?token= - * - edge-config:id=&token= - * - * @param text - A potential Edge Config Connection String - * @returns The connection parsed from the given Connection String or null. - */ -export function parseConnectionString( - connectionString: string, -): Connection | null { - return ( - parseConnectionFromQueryParams(connectionString) || - parseVercelConnectionStringFromUrl(connectionString) || - parseExternalConnectionStringFromUrl(connectionString) - ); -} diff --git a/packages/edge-config/src/utils/parse-connection-string.ts b/packages/edge-config/src/utils/parse-connection-string.ts new file mode 100644 index 000000000..6b5206d79 --- /dev/null +++ b/packages/edge-config/src/utils/parse-connection-string.ts @@ -0,0 +1,168 @@ +import type { Connection } from '../types'; + +/** + * Parses internal edge config connection strings + * + * Internal edge config connection strings are those which are native to Vercel. + * + * Internal Edge Config Connection Strings look like this: + * https://edge-config.vercel.com/?token= + */ +function parseVercelConnectionStringFromUrl(text: string): Connection | null { + try { + const url = new URL(text); + if (url.host !== 'edge-config.vercel.com') return null; + if (url.protocol !== 'https:') return null; + if (!url.pathname.startsWith('/ecfg')) return null; + + const id = url.pathname.split('/')[1]; + if (!id) return null; + + const token = url.searchParams.get('token'); + if (!token || token === '') return null; + + const snapshot = + url.searchParams.get('snapshot') === 'required' ? 'required' : 'optional'; + + const timeoutMs = parseTimeoutMs(url.searchParams.get('timeoutMs')); + + return { + type: 'vercel', + baseUrl: `https://edge-config.vercel.com/${id}`, + id, + version: '1', + token, + snapshot, + timeoutMs, + }; + } catch { + return null; + } +} + +export function parseTimeoutMs(timeoutMs: string | null): number | undefined { + if (!timeoutMs) return undefined; + const parsedTimeoutMs = Number.parseInt(timeoutMs, 10); + if (Number.isNaN(parsedTimeoutMs)) return undefined; + return parsedTimeoutMs; +} + +/** + * Parses a connection string with the following format: + * `edge-config:id=ecfg_abcd&token=xxx` + */ +function parseConnectionFromQueryParams(text: string): Connection | null { + try { + if (!text.startsWith('edge-config:')) return null; + const params = new URLSearchParams(text.slice(12)); + + const id = params.get('id'); + const token = params.get('token'); + + if (!id || !token) return null; + + const snapshot = + params.get('snapshot') === 'required' ? 'required' : 'optional'; + + const timeoutMs = parseTimeoutMs(params.get('timeoutMs')); + + return { + type: 'vercel', + baseUrl: `https://edge-config.vercel.com/${id}`, + id, + version: '1', + token, + snapshot, + timeoutMs, + }; + } catch { + // no-op + } + + return null; +} + +/** + * Parses info contained in connection strings. + * + * This works with the vercel-provided connection strings, but it also + * works with custom connection strings. + * + * The reason we support custom connection strings is that it makes testing + * edge config really straightforward. Users can provide connection strings + * pointing to their own servers and then either have a custom server + * return the desired values or even intercept requests with something like + * msw. + * + * To allow interception we need a custom connection string as the + * edge-config.vercel.com connection string might not always go over + * the network, so msw would not have a chance to intercept. + */ +/** + * Parses external edge config connection strings + * + * External edge config connection strings are those which are foreign to Vercel. + * + * External Edge Config Connection Strings look like this: + * - https://example.com/?id=&token= + * - https://example.com/?token= + */ +function parseExternalConnectionStringFromUrl( + connectionString: string, +): Connection | null { + try { + const url = new URL(connectionString); + + let id: string | null = url.searchParams.get('id'); + const token = url.searchParams.get('token'); + const version = url.searchParams.get('version') || '1'; + + // try to determine id based on pathname if it wasn't provided explicitly + if (!id || url.pathname.startsWith('/ecfg_')) { + id = url.pathname.split('/')[1] || null; + } + + if (!id || !token) return null; + + const snapshot = + url.searchParams.get('snapshot') === 'required' ? 'required' : 'optional'; + + const timeoutMs = parseTimeoutMs(url.searchParams.get('timeoutMs')); + + // remove all search params for use as baseURL + url.search = ''; + + // try to parse as external connection string + return { + type: 'external', + baseUrl: url.toString(), + id, + token, + version, + snapshot, + timeoutMs, + }; + } catch { + return null; + } +} + +/** + * Parse the edgeConfigId and token from an Edge Config Connection String. + * + * Edge Config Connection Strings usually look like one of the following: + * - https://edge-config.vercel.com/?token= + * - edge-config:id=&token= + * + * @param text - A potential Edge Config Connection String + * @returns The connection parsed from the given Connection String or null. + */ +export function parseConnectionString( + connectionString: string, +): Connection | null { + return ( + parseConnectionFromQueryParams(connectionString) || + parseVercelConnectionStringFromUrl(connectionString) || + parseExternalConnectionStringFromUrl(connectionString) + ); +} diff --git a/packages/edge-config/src/utils/read-bundled-edge-config.ts b/packages/edge-config/src/utils/read-bundled-edge-config.ts new file mode 100644 index 000000000..13ccb354e --- /dev/null +++ b/packages/edge-config/src/utils/read-bundled-edge-config.ts @@ -0,0 +1,38 @@ +// The stores.json file is overwritten at build time by the app, +// which then becomes part of the actual app's bundle. This is a fallback +// mechanism used so the app can always fall back to a bundled version of +// the config, even if the Edge Config service is degraded or unavailable. +// +// At build time of the actual app the stores.json file is overwritten +// using the "edge-config snapshot" script. +// +// At build time of this package we also copy over a placeholder file, +// such that any app not using the "edge-config snapshot" script has +// imports an empty object instead. +// +// By default we provide a "stores.json" file that contains "null", which +// allows us to determine whether the "edge-config snapshot" script ran. +// If the value is "null" the script did not run. If the value is an empty +// object or an object with keys the script definitely ran. +// +// @ts-expect-error this file exists in the final bundle +import stores from '@vercel/edge-config/dist/stores.json' with { type: 'json' }; +import type { BundledEdgeConfig } from '../types'; + +/** + * Reads the local edge config that gets bundled at build time (stores.json). + */ +export function readBundledEdgeConfig(id: string): BundledEdgeConfig | null { + try { + // "edge-config snapshot" script did not run + if (stores === null) return null; + + return (stores[id] as BundledEdgeConfig | undefined) ?? null; + } catch (error) { + console.error( + '@vercel/edge-config: Failed to read bundled edge config:', + error, + ); + return null; + } +} diff --git a/packages/edge-config/src/utils/timeout-error.ts b/packages/edge-config/src/utils/timeout-error.ts new file mode 100644 index 000000000..766052dbe --- /dev/null +++ b/packages/edge-config/src/utils/timeout-error.ts @@ -0,0 +1,24 @@ +export class TimeoutError extends Error { + public method: string; + public edgeConfigId: string; + public key: string | string[] | undefined; + + constructor( + edgeConfigId: string, + method: string, + key: string | string[] | undefined, + ) { + super( + `@vercel/edge-config: read timed out for ${edgeConfigId} (${[ + method, + key ? (Array.isArray(key) ? key.join(', ') : key) : '', + ] + .filter((x) => x !== '') + .join(' ')})`, + ); + this.name = 'TimeoutError'; + this.edgeConfigId = edgeConfigId; + this.key = key; + this.method = method; + } +} diff --git a/packages/edge-config/tsup.config.js b/packages/edge-config/tsup.config.js index 9f00fb676..fa7fedbbf 100644 --- a/packages/edge-config/tsup.config.js +++ b/packages/edge-config/tsup.config.js @@ -11,6 +11,8 @@ export default [ skipNodeModulesBundle: true, dts: true, external: ['node_modules'], + // copies over the stores.json file to dist/ + publicDir: 'public', }), // Separate configs so we don't get split types defineConfig({ @@ -24,4 +26,16 @@ export default [ dts: true, external: ['node_modules'], }), + // cli + defineConfig({ + 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 904ddd46c..9a6deed36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@vercel/edge-config-fs': specifier: workspace:* version: link:../edge-config-fs + commander: + specifier: 14.0.2 + version: 14.0.2 devDependencies: '@changesets/cli': specifier: 2.29.7 @@ -2000,6 +2003,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -5222,8 +5229,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.5': - optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.18': dependencies: @@ -5249,7 +5255,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@manypkg/find-root@1.1.0': dependencies: @@ -5905,6 +5911,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.2: {} + commander@4.1.1: {} concat-map@0.0.1: {} diff --git a/test/next/tsconfig.json b/test/next/tsconfig.json index e1063c1e6..33c26b426 100644 --- a/test/next/tsconfig.json +++ b/test/next/tsconfig.json @@ -13,7 +13,7 @@ "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -29,7 +29,8 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - "src/app/vercel/blob/script.mts" + "src/app/vercel/blob/script.mts", + ".next/dev/types/**/*.ts" ], "exclude": ["node_modules"] }