From 088b8cf83103cf7a1c2dd8d1f9261a480f8ec609 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 29 Sep 2025 13:14:41 +0300 Subject: [PATCH 1/5] add dev streaming --- packages/edge-config/package.json | 3 +- packages/edge-config/src/index.ts | 99 +++++++++++++++++ pnpm-lock.yaml | 178 +++++++++++++++++++++++++++--- 3 files changed, 264 insertions(+), 16 deletions(-) diff --git a/packages/edge-config/package.json b/packages/edge-config/package.json index 2c49d962d..a9b547f79 100644 --- a/packages/edge-config/package.json +++ b/packages/edge-config/package.json @@ -41,7 +41,8 @@ "testEnvironment": "node" }, "dependencies": { - "@vercel/edge-config-fs": "workspace:*" + "@vercel/edge-config-fs": "workspace:*", + "eventsource-client": "1.2.0" }, "devDependencies": { "@changesets/cli": "2.28.1", diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index f617b064a..b1d3247e2 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -1,4 +1,5 @@ import { readFile } from '@vercel/edge-config-fs'; +import { createEventSource, type EventSourceClient } from 'eventsource-client'; import { name as sdkName, version as sdkVersion } from '../package.json'; import { assertIsKey, @@ -246,6 +247,52 @@ async function consumeResponseBody(res: Response): Promise { await res.arrayBuffer(); } +class StreamManager { + private stream: EventSourceClient; + private connection: Connection; + private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void; + + constructor( + connection: Connection, + onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void, + ) { + this.connection = connection; + this.onEdgeConfig = onEdgeConfig; + this.stream = createEventSource({ + url: `https://api.vercel.com/v1/edge-config/${this.connection.id}/stream`, + headers: { Authorization: `Bearer ${this.connection.token}` }, + }); + } + + async init(): Promise { + for await (const { data, event } of this.stream) { + if (event === 'info' && data === 'token_invalidated') { + this.stream.close(); + return; + } + + if (event === 'embed') { + try { + const parsedEdgeConfig = JSON.parse(data) as EmbeddedEdgeConfig; + this.onEdgeConfig(parsedEdgeConfig); + } catch (e) { + // eslint-disable-next-line no-console -- intentional error logging + console.error( + '@vercel/edge-config: Error parsing streamed edge config', + e, + ); + } + } + } + + this.stream.close(); + } + + close(): void { + this.stream.close(); + } +} + interface EdgeConfigClientOptions { /** * The stale-if-error response directive indicates that the cache can reuse a @@ -270,6 +317,11 @@ interface EdgeConfigClientOptions { */ disableDevelopmentCache?: boolean; + /** + * Disables the streaming of the Edge Config. + */ + disableStream?: boolean; + /** * Sets a `cache` option on the `fetch` call made by Edge Config. * @@ -334,6 +386,11 @@ export const createClient = trace( process.env.NODE_ENV === 'development' && process.env.EDGE_CONFIG_DISABLE_DEVELOPMENT_SWR !== '1'; + const shouldUseStream = + !options.disableStream && + process.env.NODE_ENV === 'development' && + process.env.EDGE_CONFIG_DISABLE_STREAM !== '1'; + const getInMemoryEdgeConfig = createGetInMemoryEdgeConfig( shouldUseDevelopmentCache, connection, @@ -341,12 +398,31 @@ export const createClient = trace( fetchCache, ); + let streamManager: StreamManager | null = null; + let resolveStreamReady: (value: boolean) => void; + const streamReady = new Promise((resolve) => { + resolveStreamReady = resolve; + }); + let streamedEdgeConfig: EmbeddedEdgeConfig | null = null; + if (shouldUseStream) { + streamManager = new StreamManager(connection, (edgeConfig) => { + resolveStreamReady(true); + streamedEdgeConfig = edgeConfig; + }); + void streamManager.init().catch(() => null); + } + const api: Omit = { get: trace( async function get( key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise { + if (streamManager) { + await streamReady; + if (streamedEdgeConfig) return streamedEdgeConfig.items[key] as T; + } + const localEdgeConfig = (await getInMemoryEdgeConfig(localOptions)) || (await getLocalEdgeConfig(connection, localOptions)); @@ -397,6 +473,12 @@ export const createClient = trace( key, localOptions?: EdgeConfigFunctionsOptions, ): Promise { + if (streamManager) { + await streamReady; + if (streamedEdgeConfig) + return hasOwnProperty(streamedEdgeConfig.items, key); + } + const localEdgeConfig = (await getInMemoryEdgeConfig(localOptions)) || (await getLocalEdgeConfig(connection, localOptions)); @@ -438,6 +520,18 @@ export const createClient = trace( keys?: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, ): Promise { + if (streamManager) { + await streamReady; + if (streamedEdgeConfig) { + if (keys === undefined) { + return streamedEdgeConfig.items as T; + } + + assertIsKeys(keys); + return pick(streamedEdgeConfig.items, keys) as T; + } + } + const localEdgeConfig = (await getInMemoryEdgeConfig(localOptions)) || (await getLocalEdgeConfig(connection, localOptions)); @@ -497,6 +591,11 @@ export const createClient = trace( async function digest( localOptions?: EdgeConfigFunctionsOptions, ): Promise { + if (streamManager) { + await streamReady; + if (streamedEdgeConfig) return streamedEdgeConfig.digest; + } + const localEdgeConfig = (await getInMemoryEdgeConfig(localOptions)) || (await getLocalEdgeConfig(connection, localOptions)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82d09b737..4b061a6c0 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))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3) tsconfig: specifier: workspace:* version: link:../../tooling/tsconfig @@ -108,6 +108,9 @@ importers: '@vercel/edge-config-fs': specifier: workspace:* version: link:../edge-config-fs + 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.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) tsconfig: specifier: workspace:* version: link:../../tooling/tsconfig @@ -371,7 +374,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 @@ -2996,6 +2999,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'} @@ -4198,6 +4209,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==} @@ -4962,6 +4974,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==} @@ -5918,6 +5931,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5929,6 +5948,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5940,6 +5965,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5951,6 +5982,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5962,6 +5999,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5978,6 +6021,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5989,6 +6038,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -6000,6 +6055,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -6011,6 +6072,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -6022,6 +6089,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -6033,6 +6106,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -6044,6 +6123,12 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.20.2 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.9)': + dependencies: + '@babel/core': 7.23.9 + '@babel/helper-plugin-utils': 7.20.2 + optional: true + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -7200,10 +7285,10 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.0 - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 8.25.0(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/type-utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.7.3) @@ -7415,7 +7500,7 @@ snapshots: '@babel/core': 7.23.9 '@babel/eslint-parser': 7.23.10(@babel/core@7.23.9)(eslint@8.56.0) '@rushstack/eslint-patch': 1.7.2 - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@8.25.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.7.3))(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) eslint-config-prettier: 9.1.0(eslint@8.56.0) eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1) @@ -7729,6 +7814,20 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@29.7.0(@babel/core@7.23.9): + dependencies: + '@babel/core': 7.23.9 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.0 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.23.9) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + optional: true + babel-jest@29.7.0(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -7776,6 +7875,23 @@ snapshots: '@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: '@babel/core': 7.26.0 @@ -7799,6 +7915,13 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.2) + babel-preset-jest@29.6.3(@babel/core@7.23.9): + dependencies: + '@babel/core': 7.23.9 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.9) + optional: true + babel-preset-jest@29.6.3(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -8598,7 +8721,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 +8759,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 +8786,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 +8837,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 +9059,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 @@ -10490,7 +10619,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 +10629,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 @@ -11476,12 +11605,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: @@ -11657,6 +11786,25 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.23.2) esbuild: 0.25.0 + ts-jest@29.2.6(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): + dependencies: + bs-logger: 0.2.6 + ejs: 3.1.10 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.1 + typescript: 5.7.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.23.9 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.23.9) + ts-jest@29.2.6(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 From d985b50e91a25e3e3b23fa7b8091ad98d2e9365f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 29 Sep 2025 13:17:58 +0300 Subject: [PATCH 2/5] add changeset --- .changeset/cruel-suits-move.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cruel-suits-move.md diff --git a/.changeset/cruel-suits-move.md b/.changeset/cruel-suits-move.md new file mode 100644 index 000000000..f38e1d006 --- /dev/null +++ b/.changeset/cruel-suits-move.md @@ -0,0 +1,5 @@ +--- +'@vercel/edge-config': minor +--- + +[experimental] stream updates during development From bffe8d1ab941d94635f5761564b7d6f18bda1729 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 29 Sep 2025 13:24:42 +0300 Subject: [PATCH 3/5] fix test --- packages/edge-config/src/index.common.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/edge-config/src/index.common.test.ts b/packages/edge-config/src/index.common.test.ts index 19559f282..4ba148f11 100644 --- a/packages/edge-config/src/index.common.test.ts +++ b/packages/edge-config/src/index.common.test.ts @@ -436,13 +436,16 @@ describe('connectionStrings', () => { describe('in-memory cache with swr behaviour', () => { const originalEnv = process.env.NODE_ENV; + const originalDisableStream = process.env.EDGE_CONFIG_DISABLE_STREAM; beforeAll(() => { process.env.NODE_ENV = 'development'; + process.env.EDGE_CONFIG_DISABLE_STREAM = '1'; }); afterAll(() => { process.env.NODE_ENV = originalEnv; + process.env.EDGE_CONFIG_DISABLE_STREAM = originalDisableStream; }); it('use in-memory cache', async () => { From 18054d41aa7550b91070128dab60d2343bea4b01 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 29 Sep 2025 15:47:55 +0300 Subject: [PATCH 4/5] handle 401, 403, 404 when initiating stream --- packages/edge-config/src/index.ts | 95 ++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 27 deletions(-) diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index b1d3247e2..b84874aa3 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -1,5 +1,9 @@ import { readFile } from '@vercel/edge-config-fs'; -import { createEventSource, type EventSourceClient } from 'eventsource-client'; +import { + createEventSource, + type EventSourceClient, + type FetchLike, +} from 'eventsource-client'; import { name as sdkName, version as sdkVersion } from '../package.json'; import { assertIsKey, @@ -250,21 +254,56 @@ async function consumeResponseBody(res: Response): Promise { class StreamManager { private stream: EventSourceClient; private connection: Connection; - private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void; + /** + * Callback with either the Edge Config or null when the Edge Config + * does not exist or the token is invalid. + */ + private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig | null) => void; constructor( connection: Connection, - onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig) => void, + onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig | null) => void, ) { this.connection = connection; this.onEdgeConfig = onEdgeConfig; + + // 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}` }, + fetch: customFetch, + onDisconnect: () => { + if (!customFetch.status || customFetch.status >= 400) { + this.onEdgeConfig(null); + this.stream.close(); + } + }, }); } - async init(): Promise { + async listen(): Promise { for await (const { data, event } of this.stream) { if (event === 'info' && data === 'token_invalidated') { this.stream.close(); @@ -400,16 +439,25 @@ export const createClient = trace( let streamManager: StreamManager | null = null; let resolveStreamReady: (value: boolean) => void; - const streamReady = new Promise((resolve) => { + const canUseStream = new Promise((resolve) => { resolveStreamReady = resolve; }); let streamedEdgeConfig: EmbeddedEdgeConfig | null = null; if (shouldUseStream) { streamManager = new StreamManager(connection, (edgeConfig) => { - resolveStreamReady(true); - streamedEdgeConfig = edgeConfig; + if (edgeConfig) { + resolveStreamReady(true); + streamedEdgeConfig = edgeConfig; + } else { + // when token was invalid etc + resolveStreamReady(false); + } + }); + void streamManager.listen().catch(() => { + // reset streamedEdgeConfig so it does not get used when there was an + // unexpected error with the stream + streamedEdgeConfig = null; }); - void streamManager.init().catch(() => null); } const api: Omit = { @@ -418,9 +466,8 @@ export const createClient = trace( key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise { - if (streamManager) { - await streamReady; - if (streamedEdgeConfig) return streamedEdgeConfig.items[key] as T; + if (streamManager && (await canUseStream) && streamedEdgeConfig) { + return streamedEdgeConfig.items[key] as T; } const localEdgeConfig = @@ -473,10 +520,8 @@ export const createClient = trace( key, localOptions?: EdgeConfigFunctionsOptions, ): Promise { - if (streamManager) { - await streamReady; - if (streamedEdgeConfig) - return hasOwnProperty(streamedEdgeConfig.items, key); + if (streamManager && (await canUseStream) && streamedEdgeConfig) { + return hasOwnProperty(streamedEdgeConfig.items, key); } const localEdgeConfig = @@ -520,16 +565,13 @@ export const createClient = trace( keys?: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, ): Promise { - if (streamManager) { - await streamReady; - if (streamedEdgeConfig) { - if (keys === undefined) { - return streamedEdgeConfig.items as T; - } - - assertIsKeys(keys); - return pick(streamedEdgeConfig.items, keys) as T; + if (streamManager && (await canUseStream) && streamedEdgeConfig) { + if (keys === undefined) { + return streamedEdgeConfig.items as T; } + + assertIsKeys(keys); + return pick(streamedEdgeConfig.items, keys) as T; } const localEdgeConfig = @@ -591,9 +633,8 @@ export const createClient = trace( async function digest( localOptions?: EdgeConfigFunctionsOptions, ): Promise { - if (streamManager) { - await streamReady; - if (streamedEdgeConfig) return streamedEdgeConfig.digest; + if (streamManager && (await canUseStream) && streamedEdgeConfig) { + return streamedEdgeConfig.digest; } const localEdgeConfig = From 8326b67351f4c59f5c6353ca112e6de92c0c344a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 1 Oct 2025 16:24:30 +0300 Subject: [PATCH 5/5] adjust event type --- .gitignore | 2 + packages/edge-config/src/index.ts | 64 +++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index f8abbdade..1d05a2b49 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ npm-debug.log .turbo .DS_Store .vscode + +packages/edge-config/DEVELOPMENT.md diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index b84874aa3..2cbde91b8 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -259,6 +259,11 @@ class StreamManager { * does not exist or the token is invalid. */ private onEdgeConfig: (edgeConfig: EmbeddedEdgeConfig | null) => void; + private resolveStreamUsable?: (value: boolean) => void; + + private primedPromise: Promise = new Promise((resolve) => { + this.resolveStreamUsable = resolve; + }); constructor( connection: Connection, @@ -296,7 +301,7 @@ class StreamManager { fetch: customFetch, onDisconnect: () => { if (!customFetch.status || customFetch.status >= 400) { - this.onEdgeConfig(null); + this.resolveStreamUsable?.(false); this.stream.close(); } }, @@ -305,11 +310,16 @@ class StreamManager { async listen(): Promise { for await (const { data, event } of this.stream) { - if (event === 'info' && data === 'token_invalidated') { + if (event === 'status' && data === 'token_invalidated') { this.stream.close(); return; } + if (event === 'status' && data === 'primed') { + this.resolveStreamUsable?.(true); + continue; + } + if (event === 'embed') { try { const parsedEdgeConfig = JSON.parse(data) as EmbeddedEdgeConfig; @@ -327,6 +337,14 @@ class StreamManager { this.stream.close(); } + primed(): Promise { + return this.primedPromise; + } + + readyState(): 'open' | 'connecting' | 'closed' { + return this.stream.readyState; + } + close(): void { this.stream.close(); } @@ -438,21 +456,13 @@ export const createClient = trace( ); let streamManager: StreamManager | null = null; - let resolveStreamReady: (value: boolean) => void; - const canUseStream = new Promise((resolve) => { - resolveStreamReady = resolve; - }); + let streamedEdgeConfig: EmbeddedEdgeConfig | null = null; if (shouldUseStream) { streamManager = new StreamManager(connection, (edgeConfig) => { - if (edgeConfig) { - resolveStreamReady(true); - streamedEdgeConfig = edgeConfig; - } else { - // when token was invalid etc - resolveStreamReady(false); - } + streamedEdgeConfig = edgeConfig; }); + void streamManager.listen().catch(() => { // reset streamedEdgeConfig so it does not get used when there was an // unexpected error with the stream @@ -466,7 +476,12 @@ export const createClient = trace( key: string, localOptions?: EdgeConfigFunctionsOptions, ): Promise { - if (streamManager && (await canUseStream) && streamedEdgeConfig) { + if ( + streamManager && + streamedEdgeConfig && + streamManager.readyState() !== 'closed' && + (await streamManager.primed()) + ) { return streamedEdgeConfig.items[key] as T; } @@ -520,7 +535,12 @@ export const createClient = trace( key, localOptions?: EdgeConfigFunctionsOptions, ): Promise { - if (streamManager && (await canUseStream) && streamedEdgeConfig) { + if ( + streamManager && + streamedEdgeConfig && + streamManager.readyState() !== 'closed' && + (await streamManager.primed()) + ) { return hasOwnProperty(streamedEdgeConfig.items, key); } @@ -565,7 +585,12 @@ export const createClient = trace( keys?: (keyof T)[], localOptions?: EdgeConfigFunctionsOptions, ): Promise { - if (streamManager && (await canUseStream) && streamedEdgeConfig) { + if ( + streamManager && + streamedEdgeConfig && + streamManager.readyState() !== 'closed' && + (await streamManager.primed()) + ) { if (keys === undefined) { return streamedEdgeConfig.items as T; } @@ -633,7 +658,12 @@ export const createClient = trace( async function digest( localOptions?: EdgeConfigFunctionsOptions, ): Promise { - if (streamManager && (await canUseStream) && streamedEdgeConfig) { + if ( + streamManager && + streamedEdgeConfig && + streamManager.readyState() !== 'closed' && + (await streamManager.primed()) + ) { return streamedEdgeConfig.digest; }