From 46311c43f2627f95578cdb5635e909f98c6ee35f Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Thu, 27 Nov 2025 16:36:18 +0100 Subject: [PATCH 01/10] feat(gateway): add support for new Hive CDN mirror and circuit breaker --- package.json | 1 + packages/runtime/src/createGatewayRuntime.ts | 272 +++++++++++-------- packages/runtime/src/types.ts | 4 +- yarn.lock | 77 +++++- 4 files changed, 235 insertions(+), 119 deletions(-) diff --git a/package.json b/package.json index 7cb32fd94..83a2ac9ad 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "vitest": "3.2.4" }, "resolutions": { + "@graphql-hive/core": "0.16.0-alpha-20251126142740-d265dc6d6d73bf62ec597dbe41409f466b9d9874", "@graphql-mesh/types": "0.104.13", "@graphql-mesh/utils": "0.104.13", "@graphql-tools/delegate": "workspace:^", diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 0bbde6b2e..2e446a2d9 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -5,10 +5,7 @@ import { } from '@envelop/core'; import { useDisableIntrospection } from '@envelop/disable-introspection'; import { useGenericAuth } from '@envelop/generic-auth'; -import { - createSchemaFetcher, - createSupergraphSDLFetcher, -} from '@graphql-hive/core'; +import { createCDNArtifactFetcher, joinUrl } from '@graphql-hive/core'; import { LegacyLogger } from '@graphql-hive/logger'; import type { OnDelegationPlanHook, @@ -60,7 +57,12 @@ import { useCSRFPrevention } from '@graphql-yoga/plugin-csrf-prevention'; import { useDeferStream } from '@graphql-yoga/plugin-defer-stream'; import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'; import { AsyncDisposableStack } from '@whatwg-node/disposablestack'; -import { handleMaybePromise, MaybePromise } from '@whatwg-node/promise-helpers'; +import { + fakePromise, + handleMaybePromise, + MaybePromise, + unfakePromise, +} from '@whatwg-node/promise-helpers'; import { ServerAdapterPlugin } from '@whatwg-node/server'; import { useCookies } from '@whatwg-node/server-plugin-cookies'; import { @@ -270,7 +272,7 @@ export function createGatewayRuntime< clearTimeout(currentTimeout); } if (pollingInterval) { - currentTimeout = setTimeout(schemaFetcher, pollingInterval); + currentTimeout = setTimeout(schemaFetcher.fetch, pollingInterval); } } function pausePolling() { @@ -280,7 +282,10 @@ export function createGatewayRuntime< } let lastFetchedSdl: string | undefined; let initialFetch$: MaybePromise; - let schemaFetcher: () => MaybePromise; + let schemaFetcher: { + fetch: () => MaybePromise; + dispose?: () => void | PromiseLike; + }; if ( config.schema && @@ -288,25 +293,43 @@ export function createGatewayRuntime< 'type' in config.schema ) { // hive cdn - const { endpoint, key } = config.schema; - const fetcher = createSchemaFetcher({ - endpoint, - key, + const { endpoint, key, circuitBreaker } = config.schema; + const endpoints = (Array.isArray(endpoint) ? endpoint : [endpoint]).map( + (url) => + url.endsWith('/sdl') + ? url + : joinUrl( + url.endsWith('/services') + ? url.substring(0, url.length - 8) + : url, + 'sdl', + ), + ); + const fetcher = createCDNArtifactFetcher({ + endpoint: endpoints as [string, string], + circuitBreaker, + accessKey: key, logger: configContext.log.child('[hiveSchemaFetcher] '), }); - schemaFetcher = function fetchSchemaFromCDN() { - pausePolling(); - initialFetch$ = handleMaybePromise(fetcher, ({ sdl }) => { - if (lastFetchedSdl == null || lastFetchedSdl !== sdl) { - unifiedGraph = buildSchema(sdl, { - assumeValid: true, - assumeValidSDL: true, - }); - } - continuePolling(); - return true; - }); - return initialFetch$; + schemaFetcher = { + fetch: function fetchSchemaFromCDN() { + pausePolling(); + initialFetch$ = handleMaybePromise( + fetcher.fetch, + ({ contents }): true => { + if (lastFetchedSdl == null || lastFetchedSdl !== contents) { + unifiedGraph = buildSchema(contents, { + assumeValid: true, + assumeValidSDL: true, + }); + } + continuePolling(); + return true; + }, + ); + return initialFetch$; + }, + dispose: () => fetcher.dispose(), }; } else if (config.schema) { // local or remote @@ -316,60 +339,67 @@ export function createGatewayRuntime< delete config.pollingInterval; } - schemaFetcher = function fetchSchema() { - pausePolling(); - initialFetch$ = handleMaybePromise( - () => - handleUnifiedGraphConfig( - // @ts-expect-error TODO: what's up with type narrowing - config.schema, - configContext, - ), - (schema) => { - if (isSchema(schema)) { - unifiedGraph = schema; - } else if (isDocumentNode(schema)) { - unifiedGraph = buildASTSchema(schema, { - assumeValid: true, - assumeValidSDL: true, - }); - } else { - unifiedGraph = buildSchema(schema, { - noLocation: true, - assumeValid: true, - assumeValidSDL: true, - }); - } - continuePolling(); - return true; - }, - ); - return initialFetch$; + schemaFetcher = { + fetch: function fetchSchema() { + pausePolling(); + initialFetch$ = handleMaybePromise( + () => + handleUnifiedGraphConfig( + // @ts-expect-error TODO: what's up with type narrowing + config.schema, + configContext, + ), + (schema) => { + if (isSchema(schema)) { + unifiedGraph = schema; + } else if (isDocumentNode(schema)) { + unifiedGraph = buildASTSchema(schema, { + assumeValid: true, + assumeValidSDL: true, + }); + } else { + unifiedGraph = buildSchema(schema, { + noLocation: true, + assumeValid: true, + assumeValidSDL: true, + }); + } + continuePolling(); + return true; + }, + ); + return initialFetch$; + }, }; } else { // introspect endpoint - schemaFetcher = function fetchSchemaWithExecutor() { - pausePolling(); - return handleMaybePromise( - () => - schemaFromExecutor(proxyExecutor, configContext, { - assumeValid: true, - }), - (schema) => { - unifiedGraph = schema; - continuePolling(); - return true; - }, - ); + schemaFetcher = { + fetch: function fetchSchemaWithExecutor() { + pausePolling(); + return handleMaybePromise( + () => + schemaFromExecutor(proxyExecutor, configContext, { + assumeValid: true, + }), + (schema) => { + unifiedGraph = schema; + continuePolling(); + return true; + }, + ); + }, }; } - const instrumentedFetcher = schemaFetcher; - schemaFetcher = (...args) => - getInstrumented(null).asyncFn( - instrumentation?.schema, - instrumentedFetcher, - )(...args); + const instrumentedFetcher = schemaFetcher.fetch; + schemaFetcher = { + ...schemaFetcher, + fetch: (...args) => + getInstrumented(null).asyncFn( + instrumentation?.schema, + instrumentedFetcher, + )(...args), + }; getSchema = () => { if (unifiedGraph != null) { @@ -381,22 +411,22 @@ export function createGatewayRuntime< () => unifiedGraph, ); } - return handleMaybePromise(schemaFetcher, () => unifiedGraph); + return handleMaybePromise(schemaFetcher.fetch, () => unifiedGraph); }; const shouldSkipValidation = 'skipValidation' in config ? config.skipValidation : false; - const executorPlugin: GatewayPlugin = { + unifiedGraphPlugin = { onValidate({ params, setResult }) { if (shouldSkipValidation || !params.schema) { setResult([]); } }, - onDispose() { + async onDispose() { pausePolling(); - return transportExecutorStack.disposeAsync(); + await transportExecutorStack.disposeAsync(); + return schemaFetcher.dispose?.(); }, }; - unifiedGraphPlugin = executorPlugin; readinessChecker = () => handleMaybePromise( () => @@ -410,7 +440,7 @@ export function createGatewayRuntime< schemaInvalidator = () => { // @ts-expect-error TODO: this is illegal but somehow we want it unifiedGraph = undefined; - initialFetch$ = schemaFetcher(); + initialFetch$ = schemaFetcher.fetch(); }; } else if ('subgraph' in config) { const subgraphInConfig = config.subgraph; @@ -641,40 +671,51 @@ export function createGatewayRuntime< }, }; } /** 'supergraph' in config */ else { - let unifiedGraphFetcher: ( - transportCtx: TransportContext, - ) => MaybePromise; + let unifiedGraphFetcher: { + fetch: ( + transportCtx: TransportContext, + ) => MaybePromise; + dispose?: () => void | PromiseLike; + }; + if (typeof config.supergraph === 'object' && 'type' in config.supergraph) { if (config.supergraph.type === 'hive') { // hive cdn - const { endpoint, key } = config.supergraph; - const fetcher = createSupergraphSDLFetcher({ - endpoint, - key, - logger: LegacyLogger.from( - configContext.log.child('[hiveSupergraphFetcher] '), - ), + const { endpoint, key, circuitBreaker } = config.supergraph; + const endpoints = (Array.isArray(endpoint) ? endpoint : [endpoint]).map( + (url) => (url.endsWith('/supergraph') ? url : `${url}/supergraph`), + ); + const fetcher = createCDNArtifactFetcher({ + endpoint: endpoints as [string, string], + accessKey: key, + logger: configContext.log.child('[hiveSupergraphFetcher] '), // @ts-expect-error - MeshFetch is not compatible with `typeof fetch` - fetchImplementation: configContext.fetch, - + fetch: configContext.fetch, + circuitBreaker, name: 'hive-gateway', version: globalThis.__VERSION__, }); - unifiedGraphFetcher = () => - fetcher().then(({ supergraphSdl }) => supergraphSdl); + unifiedGraphFetcher = { + fetch: () => fetcher.fetch().then(({ contents }) => contents), + dispose: () => fetcher.dispose(), + }; } else if (config.supergraph.type === 'graphos') { const graphosFetcherContainer = createGraphOSFetcher({ graphosOpts: config.supergraph, configContext, pollingInterval: config.pollingInterval, }); - unifiedGraphFetcher = graphosFetcherContainer.unifiedGraphFetcher; + unifiedGraphFetcher = { + fetch: graphosFetcherContainer.unifiedGraphFetcher, + }; } else { - unifiedGraphFetcher = () => { - throw new Error( - `Unknown supergraph configuration: ${config.supergraph}`, - ); + unifiedGraphFetcher = { + fetch: () => { + throw new Error( + `Unknown supergraph configuration: ${config.supergraph}`, + ); + }, }; } } else { @@ -689,24 +730,29 @@ export function createGatewayRuntime< ); } - unifiedGraphFetcher = () => - handleUnifiedGraphConfig( - // @ts-expect-error TODO: what's up with type narrowing - config.supergraph, - configContext, - ); + unifiedGraphFetcher = { + fetch: () => + handleUnifiedGraphConfig( + // @ts-expect-error TODO: what's up with type narrowing + config.supergraph, + configContext, + ), + }; } - const instrumentedGraphFetcher = unifiedGraphFetcher; - unifiedGraphFetcher = (...args) => - getInstrumented(null).asyncFn( - instrumentation?.schema, - instrumentedGraphFetcher, - )(...args); + const instrumentedGraphFetcher = unifiedGraphFetcher.fetch; + unifiedGraphFetcher = { + ...unifiedGraphFetcher, + fetch: (...args) => + getInstrumented(null).asyncFn( + instrumentation?.schema, + instrumentedGraphFetcher, + )(...args), + }; const unifiedGraphManager = new UnifiedGraphManager({ handleUnifiedGraph: config.unifiedGraphHandler, - getUnifiedGraph: unifiedGraphFetcher, + getUnifiedGraph: unifiedGraphFetcher.fetch, onUnifiedGraphChange(newUnifiedGraph: GraphQLSchema) { unifiedGraph = newUnifiedGraph; replaceSchema(newUnifiedGraph); @@ -756,7 +802,11 @@ export function createGatewayRuntime< getExecutor = () => unifiedGraphManager.getExecutor(); unifiedGraphPlugin = { onDispose() { - return dispose(unifiedGraphManager); + return unfakePromise( + fakePromise(undefined) + .then(() => dispose(unifiedGraphManager)) + .then(() => unifiedGraphFetcher.dispose?.()), + ); }, }; } diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 901bd082a..13bf5c20d 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -50,6 +50,7 @@ import { RequestIdOptions } from './plugins/useRequestId'; import { SubgraphErrorPluginOptions } from './plugins/useSubgraphErrorPlugin'; import { UpstreamRetryPluginOptions } from './plugins/useUpstreamRetry'; import { UpstreamTimeoutPluginOptions } from './plugins/useUpstreamTimeout'; +import { CircuitBreakerConfiguration } from '@graphql-hive/core'; export type { UnifiedGraphHandler, UnifiedGraphPlugin }; export type { TransportEntryAdditions, UnifiedGraphConfig }; @@ -335,11 +336,12 @@ export interface GatewayHiveCDNOptions { /** * GraphQL Hive CDN endpoint URL. */ - endpoint: string; + endpoint: string | [string, string]; /** * GraphQL Hive CDN access key. */ key: string; + circuitBreaker?: CircuitBreakerConfiguration; } export interface GatewayHiveReportingOptions extends Omit< diff --git a/yarn.lock b/yarn.lock index 664980498..e6fb34875 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1008,7 +1008,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/types@npm:3.936.0, @aws-sdk/types@npm:^3.222.0": +"@aws-sdk/types@npm:3.936.0": version: 3.936.0 resolution: "@aws-sdk/types@npm:3.936.0" dependencies: @@ -1018,6 +1018,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:^3.222.0": + version: 3.922.0 + resolution: "@aws-sdk/types@npm:3.922.0" + dependencies: + "@smithy/types": "npm:^4.8.1" + tslib: "npm:^2.6.2" + checksum: 10c0/8a02f3af191d553ed54d30c404ac35c439db71c64ed45a7bcbf53e6200662030df8f28e0559679b14aa0d0afbb91479c11cc4656545a80d0a64567e6959cfca0 + languageName: node + linkType: hard + "@aws-sdk/util-endpoints@npm:3.936.0": version: 3.936.0 resolution: "@aws-sdk/util-endpoints@npm:3.936.0" @@ -4128,9 +4138,9 @@ __metadata: languageName: node linkType: hard -"@graphql-hive/core@npm:0.18.0, @graphql-hive/core@npm:^0.18.0": - version: 0.18.0 - resolution: "@graphql-hive/core@npm:0.18.0" +"@graphql-hive/core@npm:0.16.0-alpha-20251126142740-d265dc6d6d73bf62ec597dbe41409f466b9d9874": + version: 0.16.0-alpha-20251126142740-d265dc6d6d73bf62ec597dbe41409f466b9d9874 + resolution: "@graphql-hive/core@npm:0.16.0-alpha-20251126142740-d265dc6d6d73bf62ec597dbe41409f466b9d9874" dependencies: "@graphql-hive/logger": "npm:^1.0.9" "@graphql-hive/signal": "npm:^2.0.0" @@ -4143,7 +4153,7 @@ __metadata: tiny-lru: "npm:^8.0.2" peerDependencies: graphql: ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - checksum: 10c0/e693a59e2dbdf10a1e90bd3ab4468b09648ac9ea5de17d0549b42267a96b159c166694c0ac83951fc6967574212a4903bf4e802ebe1f7d51b43d3045893db583 + checksum: 10c0/39b158298fca88d95cb4eee8284c9d5386793f39e64e533927caf888c1446c3bc81db2283d233787040f4b047ca139f9bc402f4a8d23605190fb860a1ea16dbe languageName: node linkType: hard @@ -8934,6 +8944,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^4.8.1": + version: 4.8.1 + resolution: "@smithy/types@npm:4.8.1" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/517f90a32f19f867456b253d99ab9d96b680bde8dd19129e7e7cabf355e8082d8d25ece561fef68a73a1d961155dedc30d44194e25232ed05005399aa5962195 + languageName: node + linkType: hard + "@smithy/types@npm:^4.9.0": version: 4.9.0 resolution: "@smithy/types@npm:4.9.0" @@ -9700,7 +9719,16 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:24.10.1, @types/node@npm:>=13.7.0, @types/node@npm:^24.10.1": +"@types/node@npm:*, @types/node@npm:>=13.7.0": + version: 24.10.0 + resolution: "@types/node@npm:24.10.0" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/f82ed7194e16f5590ef7afdc20c6d09068c76d50278b485ede8f0c5749683536e3064ffa8def8db76915196afb3724b854aa5723c64d6571b890b14492943b46 + languageName: node + linkType: hard + +"@types/node@npm:24.10.1, @types/node@npm:^24.10.1": version: 24.10.1 resolution: "@types/node@npm:24.10.1" dependencies: @@ -10383,6 +10411,28 @@ __metadata: languageName: node linkType: hard +"@whatwg-node/fetch@npm:^0.10.12": + version: 0.10.12 + resolution: "@whatwg-node/fetch@npm:0.10.12" + dependencies: + "@whatwg-node/node-fetch": "npm:^0.8.2" + urlpattern-polyfill: "npm:^10.0.0" + checksum: 10c0/f7628c719c0448bd6b2ac935a91930310251ec61e3eb1b8a97cac7994c62b35d4e0e2f014dafe11c2327fb3fe6a56635c12ca96afa5cc6b74e8c838821c09588 + languageName: node + linkType: hard + +"@whatwg-node/node-fetch@npm:^0.8.2": + version: 0.8.2 + resolution: "@whatwg-node/node-fetch@npm:0.8.2" + dependencies: + "@fastify/busboy": "npm:^3.1.1" + "@whatwg-node/disposablestack": "npm:^0.0.6" + "@whatwg-node/promise-helpers": "npm:^1.3.2" + tslib: "npm:^2.6.3" + checksum: 10c0/5768beaffe3df53260bea498d1a2fbadcd8b4bc92903e5920f23fec67f97d82f32c4b5f4effb75352d5447a8b08c440f0e16a8c9196443d9468f3f8ccf6b62b2 + languageName: node + linkType: hard + "@whatwg-node/node-fetch@npm:^0.8.3": version: 0.8.4 resolution: "@whatwg-node/node-fetch@npm:0.8.4" @@ -10415,7 +10465,20 @@ __metadata: languageName: node linkType: hard -"@whatwg-node/server@npm:^0.10.0, @whatwg-node/server@npm:^0.10.14, @whatwg-node/server@npm:^0.10.17, @whatwg-node/server@npm:^0.10.5": +"@whatwg-node/server@npm:^0.10.0, @whatwg-node/server@npm:^0.10.14, @whatwg-node/server@npm:^0.10.5": + version: 0.10.15 + resolution: "@whatwg-node/server@npm:0.10.15" + dependencies: + "@envelop/instrumentation": "npm:^1.0.0" + "@whatwg-node/disposablestack": "npm:^0.0.6" + "@whatwg-node/fetch": "npm:^0.10.12" + "@whatwg-node/promise-helpers": "npm:^1.3.2" + tslib: "npm:^2.6.3" + checksum: 10c0/6391cec0a829f436ba3d79dd761c9d473cc5b2892a6065d2e87f94aab5ffd1773b899f1d98b524f5e7ada80117ef0d7624cac9c2ee517f9261abad920528b270 + languageName: node + linkType: hard + +"@whatwg-node/server@npm:^0.10.17": version: 0.10.17 resolution: "@whatwg-node/server@npm:0.10.17" dependencies: From 2a2d974d19a5f7c49b30c64fedcf6a52c25f918e Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 28 Nov 2025 10:38:21 +0100 Subject: [PATCH 02/10] add mirror and circuit breaker to persisted documents --- packages/runtime/src/createGatewayRuntime.ts | 1 + packages/runtime/src/types.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 2e446a2d9..c81d646aa 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -232,6 +232,7 @@ export function createGatewayRuntime< endpoint: config.persistedDocuments.endpoint, accessToken: config.persistedDocuments.token, }, + circuitBreaker: config.persistedDocuments.circuitBreaker, // @ts-expect-error - Hive Console plugin options are not compatible yet allowArbitraryDocuments: allowArbitraryDocumentsForPersistedDocuments, }, diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 13bf5c20d..c285f7532 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -1,5 +1,6 @@ import type { Plugin as EnvelopPlugin } from '@envelop/core'; import type { GenericAuthPluginOptions } from '@envelop/generic-auth'; +import { CircuitBreakerConfiguration } from '@graphql-hive/core'; import type { Logger, LogLevel } from '@graphql-hive/logger'; import type { PubSub } from '@graphql-hive/pubsub'; import type { @@ -50,7 +51,6 @@ import { RequestIdOptions } from './plugins/useRequestId'; import { SubgraphErrorPluginOptions } from './plugins/useSubgraphErrorPlugin'; import { UpstreamRetryPluginOptions } from './plugins/useUpstreamRetry'; import { UpstreamTimeoutPluginOptions } from './plugins/useUpstreamTimeout'; -import { CircuitBreakerConfiguration } from '@graphql-hive/core'; export type { UnifiedGraphHandler, UnifiedGraphPlugin }; export type { TransportEntryAdditions, UnifiedGraphConfig }; @@ -439,7 +439,11 @@ export interface GatewayHivePersistedDocumentsOptions { /** * GraphQL Hive persisted documents CDN endpoint URL. */ - endpoint: string; + endpoint: string | [string, string]; + /** + * Circuit Breaker configuration to customize CDN failures handling and switch to mirror endpoint. + */ + circuitBreaker?: CircuitBreakerConfiguration; /** * GraphQL Hive persisted documents CDN access token. */ From 2087e28e3b6e5385cf7090cc644c5218b9e5db72 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 28 Nov 2025 10:41:02 +0100 Subject: [PATCH 03/10] fix missing circuit breaker when usage report entabled --- packages/runtime/src/getReportingPlugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/runtime/src/getReportingPlugin.ts b/packages/runtime/src/getReportingPlugin.ts index 810b12486..3b5eff4fa 100644 --- a/packages/runtime/src/getReportingPlugin.ts +++ b/packages/runtime/src/getReportingPlugin.ts @@ -43,6 +43,7 @@ export function getReportingPlugin>( endpoint: config.persistedDocuments.endpoint, accessToken: config.persistedDocuments.token, }, + circuitBreaker: config.persistedDocuments.circuitBreaker, // Trick to satisfy the Hive Console plugin types allowArbitraryDocuments: allowArbitraryDocuments as boolean, }, From 5a2207f13466d56376ae246d4226b6d214b6b3a3 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 28 Nov 2025 11:50:35 +0100 Subject: [PATCH 04/10] add changeset --- .changeset/cold-bags-confess.md | 67 +++++++++++++++++++++++++++++++++ packages/runtime/src/types.ts | 23 ++++++----- 2 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 .changeset/cold-bags-confess.md diff --git a/.changeset/cold-bags-confess.md b/.changeset/cold-bags-confess.md new file mode 100644 index 000000000..17ef041b7 --- /dev/null +++ b/.changeset/cold-bags-confess.md @@ -0,0 +1,67 @@ +--- +'@graphql-hive/gateway-runtime': minor +--- + +## New Hive CDN mirror and circuit breaker + +Hive CDN introduced a new CDN mirror and circuit breaker to mitigate the risk related to Cloudflare +services failures. + +You can now provide multiple endpoint in Hive Console related features, and configure the circuit +breaker handling CDN failure and how it switches to the CDN mirror. + +### Usage + +To enable this feature, please provide the mirror endpoint in `supergraph` and `persistedDocument` +options: + +```diff +import { defineConfig } from '@graphql-hive/gateway' + +export const gatewayConfig = defineConfig({ + supergraph: { + type: 'hive', +- endpoint: 'https://cdn.graphql-hive.com/artifacts/v1/...../supergraph', ++ endpoint: [ ++ 'https://cdn.graphql-hive.com/artifacts/v1/...../supergraph', ++ 'https://cdn-mirror.graphql-hive.com/artifacts/v1/...../supergraph' ++ ] + }, + + persistedDocuments: { +- endpoint: 'https://cdn.graphql-hive.com/artifacts/v1/...', ++ endpoint: [ ++ 'https://cdn.graphql-hive.com/artifacts/v1/...', ++ 'https://cdn-mirror.graphql-hive.com/artifacts/v1/...' ++ ] + } +}) +``` + +### Configuration + +The circuit breaker has production ready default configuration, but you customize its behavior: + +```ts +import { defineConfig, CircuitBreakerConfiguration } from '@graphql-hive/gateway'; + +const circuitBreaker: CircuitBreakerConfiguration = { + resetTimeout: 30_000; // 30s + errorThresholdPercentage: 50; + volumeThreshold: 5; +} + +export const gatewayConfig = defineConfig({ + supergraph: { + type: 'hive', + endpoint: [...], + circuitBreaker, + }, + + persistedDocuments: { + type: 'hive', + endpoint: [...], + circuitBreaker, + }, +}); +``` diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index c285f7532..e75df1f60 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -54,6 +54,7 @@ import { UpstreamTimeoutPluginOptions } from './plugins/useUpstreamTimeout'; export type { UnifiedGraphHandler, UnifiedGraphPlugin }; export type { TransportEntryAdditions, UnifiedGraphConfig }; +export type { CircuitBreakerConfiguration }; export type GatewayConfig< TContext extends Record = Record, @@ -87,7 +88,8 @@ export interface GatewayConfigContext { } export interface GatewayContext - extends GatewayConfigContext, YogaInitialContext { + extends GatewayConfigContext, + YogaInitialContext { /** * Environment agnostic HTTP headers provided with the request. */ @@ -282,9 +284,8 @@ export interface GatewayConfigSubgraph< subgraph: UnifiedGraphConfig; } -export interface GatewayConfigSchemaBase< - TContext extends Record, -> extends GatewayConfigBase { +export interface GatewayConfigSchemaBase> + extends GatewayConfigBase { /** * Additional GraphQL schema type definitions. */ @@ -344,11 +345,12 @@ export interface GatewayHiveCDNOptions { circuitBreaker?: CircuitBreakerConfiguration; } -export interface GatewayHiveReportingOptions extends Omit< - HiveConsolePluginOptions, - // we omit this property because we define persisted documents in GatewayHivePersistedDocumentsOptions - 'experimental__persistedDocuments' -> { +export interface GatewayHiveReportingOptions + extends Omit< + HiveConsolePluginOptions, + // we omit this property because we define persisted documents in GatewayHivePersistedDocumentsOptions + 'experimental__persistedDocuments' + > { type: 'hive'; /** GraphQL Hive registry access token. */ token: string; @@ -374,7 +376,8 @@ export interface GatewayGraphOSOptions { apiKey: string; } -export interface GatewayGraphOSManagedFederationOptions extends GatewayGraphOSOptions { +export interface GatewayGraphOSManagedFederationOptions + extends GatewayGraphOSOptions { /** * Maximum number of retries to attempt when fetching the schema from the managed federation up link. */ From 6189eb9cff19cda020ec150d9075a28da272b523 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 28 Nov 2025 15:23:09 +0100 Subject: [PATCH 05/10] format --- packages/runtime/src/types.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index e75df1f60..6c4d07330 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -88,8 +88,7 @@ export interface GatewayConfigContext { } export interface GatewayContext - extends GatewayConfigContext, - YogaInitialContext { + extends GatewayConfigContext, YogaInitialContext { /** * Environment agnostic HTTP headers provided with the request. */ @@ -284,8 +283,9 @@ export interface GatewayConfigSubgraph< subgraph: UnifiedGraphConfig; } -export interface GatewayConfigSchemaBase> - extends GatewayConfigBase { +export interface GatewayConfigSchemaBase< + TContext extends Record, +> extends GatewayConfigBase { /** * Additional GraphQL schema type definitions. */ @@ -345,12 +345,11 @@ export interface GatewayHiveCDNOptions { circuitBreaker?: CircuitBreakerConfiguration; } -export interface GatewayHiveReportingOptions - extends Omit< - HiveConsolePluginOptions, - // we omit this property because we define persisted documents in GatewayHivePersistedDocumentsOptions - 'experimental__persistedDocuments' - > { +export interface GatewayHiveReportingOptions extends Omit< + HiveConsolePluginOptions, + // we omit this property because we define persisted documents in GatewayHivePersistedDocumentsOptions + 'experimental__persistedDocuments' +> { type: 'hive'; /** GraphQL Hive registry access token. */ token: string; @@ -376,8 +375,7 @@ export interface GatewayGraphOSOptions { apiKey: string; } -export interface GatewayGraphOSManagedFederationOptions - extends GatewayGraphOSOptions { +export interface GatewayGraphOSManagedFederationOptions extends GatewayGraphOSOptions { /** * Maximum number of retries to attempt when fetching the schema from the managed federation up link. */ From 4d37f5c2f493e5ff76cde3a20afff4e4eb82feeb Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 28 Nov 2025 15:28:52 +0100 Subject: [PATCH 06/10] fix tests --- packages/runtime/tests/hive.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/runtime/tests/hive.spec.ts b/packages/runtime/tests/hive.spec.ts index cbda3f7ca..55a0a1991 100644 --- a/packages/runtime/tests/hive.spec.ts +++ b/packages/runtime/tests/hive.spec.ts @@ -98,10 +98,8 @@ describe('Hive CDN', () => { it('uses Hive CDN instead of introspection for Proxy mode', async () => { const upstreamSchema = createUpstreamSchema(); await using cdnServer = await createDisposableServer( - createServerAdapter(() => - Response.json({ - sdl: printSchemaWithDirectives(upstreamSchema), - }), + createServerAdapter( + () => new Response(printSchemaWithDirectives(upstreamSchema)), ), ); await using upstreamServer = createYoga({ From 2a634a155f8d3720199a1ca183180275fe7c7768 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 28 Nov 2025 15:59:01 +0100 Subject: [PATCH 07/10] fix e2e tests for hive usage --- e2e/self-hosting-hive/self-hosting-hive.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/self-hosting-hive/self-hosting-hive.e2e.ts b/e2e/self-hosting-hive/self-hosting-hive.e2e.ts index 43796e19f..8f7f47886 100644 --- a/e2e/self-hosting-hive/self-hosting-hive.e2e.ts +++ b/e2e/self-hosting-hive/self-hosting-hive.e2e.ts @@ -60,7 +60,7 @@ describe('Self Hosting Hive', () => { /\[hiveSupergraphFetcher\] GET .* succeeded with status 200/, ); expect(gwLogs).toMatch( - /\[useHiveConsole\] POST .* succeeded with status 200/, + /\[useHiveConsole\] POST .*\/usage .* succeeded with status 200/, ); }); }); From ad845fb3a06fe1669ff56ab23f6fb07980739c33 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 28 Nov 2025 16:40:24 +0100 Subject: [PATCH 08/10] try to fix wrangler config --- e2e/cloudflare-workers/wrangler.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/cloudflare-workers/wrangler.toml b/e2e/cloudflare-workers/wrangler.toml index ac2d3da4a..45e2220df 100644 --- a/e2e/cloudflare-workers/wrangler.toml +++ b/e2e/cloudflare-workers/wrangler.toml @@ -7,4 +7,4 @@ compatibility_date = "2024-01-01" "@graphql-tools/batch-delegate" = "../../packages/batch-delegate/src/index.ts" "@graphql-tools/delegate" = "../../packages/delegate/src/index.ts" "@graphql-hive/signal" = "../../packages/signal/src/index.ts" -"@graphql-hive/logger" = "../../packages/logger/src/index.ts" \ No newline at end of file +"@graphql-hive/logger" = "../../packages/logger/src/index.ts" From 945d98e02a14eb3d2d2aae9fb2fd55c442dcc5ff Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Tue, 2 Dec 2025 14:52:36 +0100 Subject: [PATCH 09/10] fix from review --- .changeset/cold-bags-confess.md | 2 +- package.json | 1 - yarn.lock | 26 ++++++++++++++++++++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.changeset/cold-bags-confess.md b/.changeset/cold-bags-confess.md index 17ef041b7..3c642039d 100644 --- a/.changeset/cold-bags-confess.md +++ b/.changeset/cold-bags-confess.md @@ -2,7 +2,7 @@ '@graphql-hive/gateway-runtime': minor --- -## New Hive CDN mirror and circuit breaker +New Hive CDN mirror and circuit breaker Hive CDN introduced a new CDN mirror and circuit breaker to mitigate the risk related to Cloudflare services failures. diff --git a/package.json b/package.json index 83a2ac9ad..7cb32fd94 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "vitest": "3.2.4" }, "resolutions": { - "@graphql-hive/core": "0.16.0-alpha-20251126142740-d265dc6d6d73bf62ec597dbe41409f466b9d9874", "@graphql-mesh/types": "0.104.13", "@graphql-mesh/utils": "0.104.13", "@graphql-tools/delegate": "workspace:^", diff --git a/yarn.lock b/yarn.lock index e6fb34875..a645365c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4138,9 +4138,27 @@ __metadata: languageName: node linkType: hard -"@graphql-hive/core@npm:0.16.0-alpha-20251126142740-d265dc6d6d73bf62ec597dbe41409f466b9d9874": - version: 0.16.0-alpha-20251126142740-d265dc6d6d73bf62ec597dbe41409f466b9d9874 - resolution: "@graphql-hive/core@npm:0.16.0-alpha-20251126142740-d265dc6d6d73bf62ec597dbe41409f466b9d9874" +"@graphql-hive/core@npm:0.15.1": + version: 0.15.1 + resolution: "@graphql-hive/core@npm:0.15.1" + dependencies: + "@graphql-hive/signal": "npm:^2.0.0" + "@graphql-tools/utils": "npm:^10.0.0" + "@whatwg-node/fetch": "npm:^0.10.13" + async-retry: "npm:^1.3.3" + events: "npm:^3.3.0" + js-md5: "npm:0.8.3" + lodash.sortby: "npm:^4.7.0" + tiny-lru: "npm:^8.0.2" + peerDependencies: + graphql: ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + checksum: 10c0/53db4014cc7517c55050f71b7aafa32c157f8c11036ba96f9a03f736dd0a7ea859af7ca2ae2f53471e188856d227d92c362766eaa9347e485134036f63a88912 + languageName: node + linkType: hard + +"@graphql-hive/core@npm:^0.18.0": + version: 0.18.0 + resolution: "@graphql-hive/core@npm:0.18.0" dependencies: "@graphql-hive/logger": "npm:^1.0.9" "@graphql-hive/signal": "npm:^2.0.0" @@ -4153,7 +4171,7 @@ __metadata: tiny-lru: "npm:^8.0.2" peerDependencies: graphql: ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - checksum: 10c0/39b158298fca88d95cb4eee8284c9d5386793f39e64e533927caf888c1446c3bc81db2283d233787040f4b047ca139f9bc402f4a8d23605190fb860a1ea16dbe + checksum: 10c0/e693a59e2dbdf10a1e90bd3ab4468b09648ac9ea5de17d0549b42267a96b159c166694c0ac83951fc6967574212a4903bf4e802ebe1f7d51b43d3045893db583 languageName: node linkType: hard From 65ad5f97b6cf2a8dd5ad0a7bb8ea9cf3b540b497 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Tue, 2 Dec 2025 15:06:32 +0100 Subject: [PATCH 10/10] yarn lockfile --- yarn.lock | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/yarn.lock b/yarn.lock index a645365c4..c65e57244 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4138,25 +4138,7 @@ __metadata: languageName: node linkType: hard -"@graphql-hive/core@npm:0.15.1": - version: 0.15.1 - resolution: "@graphql-hive/core@npm:0.15.1" - dependencies: - "@graphql-hive/signal": "npm:^2.0.0" - "@graphql-tools/utils": "npm:^10.0.0" - "@whatwg-node/fetch": "npm:^0.10.13" - async-retry: "npm:^1.3.3" - events: "npm:^3.3.0" - js-md5: "npm:0.8.3" - lodash.sortby: "npm:^4.7.0" - tiny-lru: "npm:^8.0.2" - peerDependencies: - graphql: ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - checksum: 10c0/53db4014cc7517c55050f71b7aafa32c157f8c11036ba96f9a03f736dd0a7ea859af7ca2ae2f53471e188856d227d92c362766eaa9347e485134036f63a88912 - languageName: node - linkType: hard - -"@graphql-hive/core@npm:^0.18.0": +"@graphql-hive/core@npm:0.18.0, @graphql-hive/core@npm:^0.18.0": version: 0.18.0 resolution: "@graphql-hive/core@npm:0.18.0" dependencies: