From a462163f4e3c6ea6a7d313777311ea1b69325a47 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:28:47 -0700 Subject: [PATCH 1/6] Add a local subgraph schema fetcher function based on hive:dev --- .changeset/tiny-badgers-brake.md | 5 + .gitignore | 1 + packages/libraries/cli/__tests__/dev.spec.ts | 125 ++++++ packages/libraries/cli/package.json | 3 +- packages/libraries/cli/src/base-command.ts | 12 +- packages/libraries/cli/src/commands/dev.ts | 257 +++++------- packages/libraries/core/package.json | 1 + .../libraries/core/src/client/dev-fetcher.ts | 390 ++++++++++++++++++ packages/libraries/core/src/index.ts | 15 + .../libraries/core/tests/dev-fetcher.spec.ts | 359 ++++++++++++++++ pnpm-lock.yaml | 6 + 11 files changed, 1016 insertions(+), 158 deletions(-) create mode 100644 .changeset/tiny-badgers-brake.md create mode 100644 packages/libraries/cli/__tests__/dev.spec.ts create mode 100644 packages/libraries/core/src/client/dev-fetcher.ts create mode 100644 packages/libraries/core/tests/dev-fetcher.spec.ts diff --git a/.changeset/tiny-badgers-brake.md b/.changeset/tiny-badgers-brake.md new file mode 100644 index 00000000000..ec517ef20a3 --- /dev/null +++ b/.changeset/tiny-badgers-brake.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/core': minor +--- + +Add `createDevFetcher`, a factory that composes a supergraph from local or remote subgraphs, with built-in caching to avoid recomposing when resolved service SDLs are unchanged. This allows a local gateway instance to automatically watch subgraphs, compose a supergraph, and set the schema using the result, which removes the need to run a separate instance of the Hive CLI. diff --git a/.gitignore b/.gitignore index 50152d0194d..77f0a6c50f9 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,4 @@ docker/docker-compose.override.yml test-results/ playwright-report +CLAUDE.md diff --git a/packages/libraries/cli/__tests__/dev.spec.ts b/packages/libraries/cli/__tests__/dev.spec.ts new file mode 100644 index 00000000000..3f1eb6b6d41 --- /dev/null +++ b/packages/libraries/cli/__tests__/dev.spec.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + InvalidRemoteCompositionResultError, + RegistryApiError, + RemoteCompositionError as CoreRemoteCompositionError, + SupergraphCompositionError, +} from '@graphql-hive/core'; +import { + APIError, + InvalidCompositionResultError, + LocalCompositionError, + RemoteCompositionError, +} from '../src/helpers/errors'; + +const mockComposeSupergraphLocally = vi.fn(); +const mockComposeSupergraphRemotely = vi.fn(); + +vi.mock('@graphql-hive/core', async () => { + const actual = await vi.importActual('@graphql-hive/core'); + return { + ...actual, + composeSupergraphLocally: (...args: unknown[]) => mockComposeSupergraphLocally(...args), + composeSupergraphRemotely: (...args: unknown[]) => mockComposeSupergraphRemotely(...args), + }; +}); + +const { default: Dev } = await import('../src/commands/dev'); + +function createDevInstance() { + const dev = Object.create(Dev.prototype) as any; + dev.logSuccess = vi.fn(); + dev.log = vi.fn(); + dev.logger = { info: vi.fn(), error: vi.fn(), debug: vi.fn() }; + dev.config = { version: '0.0.0' }; + return dev; +} + +beforeEach(() => { + mockComposeSupergraphLocally.mockReset(); + mockComposeSupergraphRemotely.mockReset(); +}); + +describe('Dev.composeLocally', () => { + it('maps a SupergraphCompositionError to a LocalCompositionError', async () => { + const compositionResult = { errors: [{ message: 'field conflict' }] } as any; + mockComposeSupergraphLocally.mockRejectedValue( + new SupergraphCompositionError(compositionResult), + ); + + const dev = createDevInstance(); + const onError = vi.fn(); + + await dev.composeLocally({ services: [], write: 'out.graphql', onError }); + + expect(onError).toHaveBeenCalledWith(expect.any(LocalCompositionError)); + }); + + it('rethrows unrecognized errors', async () => { + mockComposeSupergraphLocally.mockRejectedValue(new Error('unexpected')); + + const dev = createDevInstance(); + const onError = vi.fn(); + + await expect( + dev.composeLocally({ services: [], write: 'out.graphql', onError }), + ).rejects.toThrow('unexpected'); + expect(onError).not.toHaveBeenCalled(); + }); +}); + +describe('Dev.compose', () => { + const baseInput = { + services: [], + registry: 'http://registry.localhost', + token: 'secret-token', + write: 'out.graphql', + unstable__forceLatest: false, + target: null, + }; + + it('maps a RegistryApiError to an APIError', async () => { + mockComposeSupergraphRemotely.mockRejectedValue(new RegistryApiError('bad request')); + + const dev = createDevInstance(); + const onError = vi.fn(); + + await dev.compose({ ...baseInput, onError }); + + expect(onError).toHaveBeenCalledWith(expect.any(APIError)); + }); + + it('maps a core RemoteCompositionError to the CLI RemoteCompositionError', async () => { + mockComposeSupergraphRemotely.mockRejectedValue( + new CoreRemoteCompositionError([{ message: 'field conflict' }]), + ); + + const dev = createDevInstance(); + const onError = vi.fn(); + + await dev.compose({ ...baseInput, onError }); + + expect(onError).toHaveBeenCalledWith(expect.any(RemoteCompositionError)); + }); + + it('maps an InvalidRemoteCompositionResultError to an InvalidCompositionResultError', async () => { + mockComposeSupergraphRemotely.mockRejectedValue(new InvalidRemoteCompositionResultError(null)); + + const dev = createDevInstance(); + const onError = vi.fn(); + + await dev.compose({ ...baseInput, onError }); + + expect(onError).toHaveBeenCalledWith(expect.any(InvalidCompositionResultError)); + }); + + it('rethrows unrecognized errors', async () => { + mockComposeSupergraphRemotely.mockRejectedValue(new Error('network down')); + + const dev = createDevInstance(); + const onError = vi.fn(); + + await expect(dev.compose({ ...baseInput, onError })).rejects.toThrow('network down'); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/libraries/cli/package.json b/packages/libraries/cli/package.json index cfd3da54b0f..422f99109e1 100644 --- a/packages/libraries/cli/package.json +++ b/packages/libraries/cli/package.json @@ -75,7 +75,8 @@ "oclif": "4.22.65", "rimraf": "6.1.3", "tsx": "4.19.2", - "typescript": "5.7.3" + "typescript": "5.7.3", + "vitest": "4.1.11" }, "publishConfig": { "access": "public" diff --git a/packages/libraries/cli/src/base-command.ts b/packages/libraries/cli/src/base-command.ts index 99160479297..9b2063fd327 100644 --- a/packages/libraries/cli/src/base-command.ts +++ b/packages/libraries/cli/src/base-command.ts @@ -173,15 +173,13 @@ export default abstract class BaseCommand extends Comm } registryApi(registry: string, token: string) { - const requestHeaders = { - Authorization: `Bearer ${token}`, - 'graphql-client-name': 'Hive CLI', - 'graphql-client-version': this.config.version, - }; - return graphqlRequest({ endpoint: registry, - additionalHeaders: requestHeaders, + additionalHeaders: { + Authorization: `Bearer ${token}`, + 'graphql-client-name': 'Hive CLI', + 'graphql-client-version': this.config.version, + }, version: this.config.version, logger: this.logger, }); diff --git a/packages/libraries/cli/src/commands/dev.ts b/packages/libraries/cli/src/commands/dev.ts index 52fcd945f61..82209550209 100644 --- a/packages/libraries/cli/src/commands/dev.ts +++ b/packages/libraries/cli/src/commands/dev.ts @@ -1,14 +1,17 @@ import { writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { parse } from 'graphql'; -import { Flags } from '@oclif/core'; import { - composeServices, - compositionHasErrors, - CompositionResult, -} from '@theguild/federation-composition'; + composeSupergraphLocally, + composeSupergraphRemotely, + RemoteCompositionError as CoreRemoteCompositionError, + InvalidRemoteCompositionResultError, + RegistryApiError, + SupergraphCompositionError, + type Logger as LegacyLogger, +} from '@graphql-hive/core'; +import { Flags } from '@oclif/core'; import Command from '../base-command'; -import { graphql } from '../gql'; +import { makeFragmentData } from '../gql'; import * as GraphQLSchema from '../gql/graphql'; import { graphqlEndpoint } from '../helpers/config'; import { @@ -24,40 +27,20 @@ import { ServiceAndUrlLengthMismatch, UnexpectedError, } from '../helpers/errors'; -import { loadSchema } from '../helpers/schema'; +import { loadSchema, RenderErrors_SchemaErrorConnectionFragment } from '../helpers/schema'; import * as TargetInput from '../helpers/target-input'; import { invariant } from '../helpers/validation'; -const CLI_SchemaComposeMutation = graphql(/* GraphQL */ ` - mutation CLI_SchemaComposeMutation($input: SchemaComposeInput!) { - schemaCompose(input: $input) { - __typename - ... on SchemaComposeSuccess { - valid - compositionResult { - supergraphSdl - errors { - ...RenderErrors_SchemaErrorConnectionFragment - } - } - } - ... on SchemaComposeError { - message - } - } - } -`); - type ServiceName = string; type Sdl = string; -type ServiceInput = { +export type ServiceInput = { name: ServiceName; url: string; sdl?: string; }; -type Service = { +export type Service = { name: ServiceName; url: string; sdl: Sdl; @@ -78,6 +61,57 @@ type ServiceWithSource = { }; }; +export async function resolveServices( + services: ServiceInput[], + logger: LegacyLogger, +): Promise> { + return await Promise.all( + services.map(async input => { + if (input.sdl) { + return { + name: input.name, + url: input.url, + sdl: await resolveSdlFromPath(input.sdl, logger), + input: { + kind: 'file' as const, + path: input.sdl, + }, + }; + } + + return { + name: input.name, + url: input.url, + sdl: await resolveSdlFromUrl(input.name, input.url, logger), + input: { + kind: 'url' as const, + url: input.url, + }, + }; + }), + ); +} + +async function resolveSdlFromPath(path: string, logger: LegacyLogger) { + const sdl = await loadSchema(null, path, { logger }); + invariant(typeof sdl === 'string' && sdl.length > 0, `Read empty schema from ${path}`); + + return sdl; +} + +async function resolveSdlFromUrl(serviceName: string, url: string, logger: LegacyLogger) { + const sdl = await loadSchema('only-federation-introspection', url, { logger }).catch(err => { + logger.error(err); + throw err; + }); + + if (!sdl) { + throw new IntrospectionError(serviceName); + } + + return sdl; +} + export default class Dev extends Command { static description = [ 'Develop and compose Supergraph with your local services.', @@ -309,49 +343,28 @@ export default class Dev extends Command { } private async composeLocally(input: { - services: Array<{ - name: string; - url: string; - sdl: string; - }>; + services: Service[]; write: string; onError: (error: HiveCLIError) => void | never; }) { - const compositionResult = await new Promise((resolve, reject) => { - try { - resolve( - composeServices( - input.services.map(service => ({ - name: service.name, - url: service.url, - typeDefs: parse(service.sdl), - })), - ), - ); - } catch (error) { - // @note: composeServices should not throw. - // This reject is for the offchance that something happens under the hood that was not expected. - // Without it, if something happened then the promise would hang. - reject(error); + let supergraphSdl: string; + try { + supergraphSdl = await composeSupergraphLocally(input.services); + } catch (error) { + if (error instanceof SupergraphCompositionError) { + input.onError(new LocalCompositionError(error.compositionResult)); + return; } - }); - - if (compositionHasErrors(compositionResult)) { - input.onError(new LocalCompositionError(compositionResult)); - return; + throw error; } this.logSuccess('Composition successful'); this.log(`Saving supergraph schema to ${input.write}`); - await writeFile(resolve(process.cwd(), input.write), compositionResult.supergraphSdl, 'utf-8'); + await writeFile(resolve(process.cwd(), input.write), supergraphSdl, 'utf-8'); } private async compose(input: { - services: Array<{ - name: string; - url: string; - sdl: string; - }>; + services: Service[]; registry: string; token: string; write: string; @@ -359,52 +372,44 @@ export default class Dev extends Command { target: GraphQLSchema.TargetReferenceInput | null; onError: (error: HiveCLIError) => void | never; }) { - const result = await this.registryApi(input.registry, input.token).request({ - operation: CLI_SchemaComposeMutation, - variables: { - input: { - useLatestComposableVersion: !input.unstable__forceLatest, - services: input.services.map(service => ({ - name: service.name, - url: service.url, - sdl: service.sdl, - })), - target: input.target, - }, - }, - }); - - if (result.schemaCompose.__typename === 'SchemaComposeError') { - input.onError(new APIError(result.schemaCompose.message)); - return; - } - - const { valid, compositionResult } = result.schemaCompose; - - if (!valid) { - // @note: Can this actually be invalid without any errors? - if (compositionResult.errors) { - input.onError(new RemoteCompositionError(compositionResult.errors)); + let supergraphSdl: string; + try { + supergraphSdl = await composeSupergraphRemotely({ + services: input.services, + registry: input.registry, + token: input.token, + unstable__forceLatest: input.unstable__forceLatest, + target: input.target, + version: this.config.version, + logger: this.logger, + }); + } catch (error) { + if (error instanceof RegistryApiError) { + input.onError(new APIError(error.message)); return; } - - input.onError(new InvalidCompositionResultError(compositionResult.supergraphSdl)); - return; - } - - if (typeof compositionResult.supergraphSdl !== 'string') { - input.onError(new InvalidCompositionResultError(compositionResult.supergraphSdl)); - return; + if (error instanceof CoreRemoteCompositionError) { + input.onError( + new RemoteCompositionError( + makeFragmentData( + { edges: error.errors.map(e => ({ node: { message: e.message } })) }, + RenderErrors_SchemaErrorConnectionFragment, + ), + ), + ); + return; + } + if (error instanceof InvalidRemoteCompositionResultError) { + input.onError(new InvalidCompositionResultError(error.supergraphSdl)); + return; + } + throw error; } this.logSuccess('Composition successful'); this.log(`Saving supergraph schema to ${input.write}`); try { - await writeFile( - resolve(process.cwd(), input.write), - compositionResult.supergraphSdl, - 'utf-8', - ); + await writeFile(resolve(process.cwd(), input.write), supergraphSdl, 'utf-8'); } catch (e) { input.onError(new UnexpectedError(e)); } @@ -471,54 +476,6 @@ export default class Dev extends Command { } private async resolveServices(services: ServiceInput[]): Promise> { - return await Promise.all( - services.map(async input => { - if (input.sdl) { - return { - name: input.name, - url: input.url, - sdl: await this.resolveSdlFromPath(input.sdl), - input: { - kind: 'file' as const, - path: input.sdl, - }, - }; - } - - return { - name: input.name, - url: input.url, - sdl: await this.resolveSdlFromUrl(input.name, input.url), - input: { - kind: 'url' as const, - url: input.url, - }, - }; - }), - ); - } - - private async resolveSdlFromPath(path: string) { - const sdl = await loadSchema(null, path, { - logger: this.logger, - }); - invariant(typeof sdl === 'string' && sdl.length > 0, `Read empty schema from ${path}`); - - return sdl; - } - - private async resolveSdlFromUrl(serviceName: string, url: string) { - const sdl = await loadSchema('only-federation-introspection', url, { - logger: this.logger, - }).catch(err => { - this.logFailure(err); - throw err; - }); - - if (!sdl) { - throw new IntrospectionError(serviceName); - } - - return sdl; + return await resolveServices(services, this.logger); } } diff --git a/packages/libraries/core/package.json b/packages/libraries/core/package.json index 7620d04d50e..478faac3429 100644 --- a/packages/libraries/core/package.json +++ b/packages/libraries/core/package.json @@ -49,6 +49,7 @@ "@graphql-hive/logger": "^1.1.0", "@graphql-hive/signal": "^2.0.0", "@graphql-tools/utils": "^12.0.0", + "@theguild/federation-composition": "^0.26.1", "@whatwg-node/fetch": "^0.10.13", "async-retry": "^1.3.3", "events": "^3.3.0", diff --git a/packages/libraries/core/src/client/dev-fetcher.ts b/packages/libraries/core/src/client/dev-fetcher.ts new file mode 100644 index 00000000000..2e4aeab8d52 --- /dev/null +++ b/packages/libraries/core/src/client/dev-fetcher.ts @@ -0,0 +1,390 @@ +import { readFile } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; +import { + buildClientSchema, + getIntrospectionQuery, + parse, + printSchema, + type IntrospectionQuery, +} from 'graphql'; +import { composeServices, compositionHasErrors } from '@theguild/federation-composition'; +import type { CompositionFailure, CompositionResult } from '@theguild/federation-composition'; +import { http } from './http-client.js'; +import type { LegacyLogger } from './types.js'; + +export type FetchImplementation = typeof globalThis.fetch; + +type Service = { + name: string; + url: string; + sdl: string; +}; + +/** A target reference, already parsed from a slug or UUID. */ +export type DevFetcherTargetReference = + | { byId: string | number; bySelector?: never } + | { + byId?: never; + bySelector: { organizationSlug: string; projectSlug: string; targetSlug: string }; + }; + +export type HiveDevService = { + name: string; + url: string; +} & ( + | { + /** Read the schema from an SDL file rather than introspecting `url`. */ + source: 'file'; + /** Path to the service's SDL file. */ + schema: string; + } + | { + /** + * How to obtain the schema from `url`. + * - `federation` (default): query the federation `_service { sdl }` field. + * - `graphql`: perform standard GraphQL introspection (the `IntrospectionQuery`) and print + * the resulting schema. + */ + source?: 'federation' | 'graphql'; + } +); + +type CachedSupergraph = { + services: Service[]; + supergraphSdl: string; +}; + +export interface HiveDevFetcherOptions { + services: HiveDevService[]; + remote?: boolean; + registry?: string; + token?: string; + target?: DevFetcherTargetReference | null; + unstable__forceLatest?: boolean; + /** Reported to the registry API when composing remotely. */ + version?: string; + logger?: LegacyLogger; + /** Custom fetch implementation used for introspecting services and calling the registry. */ + fetch?: FetchImplementation; + /** Base directory used to resolve relative service schema file paths. Defaults to `process.cwd()`. */ + cwd?: string; + /** Used to avoid recomposing the supergraph when resolved service SDLs are unchanged. */ + cache?: { + get(key: string): Promise | CachedSupergraph | undefined; + set(key: string, value: CachedSupergraph): Promise | void; + }; +} + +/** Local composition (via `@theguild/federation-composition`) produced errors. */ +export class SupergraphCompositionError extends Error { + constructor(public compositionResult: CompositionFailure) { + super('Local composition failed.'); + } +} + +/** The registry API returned a GraphQL/API-level error while composing remotely. */ +export class RegistryApiError extends Error {} + +/** Remote composition finished but produced composition errors. */ +export class RemoteCompositionError extends Error { + constructor(public errors: Array<{ message: string }>) { + super(`Remote composition failed:\n${errors.map(error => error.message).join('\n')}`); + } +} + +/** Remote composition reported success but did not return a usable supergraph SDL. */ +export class InvalidRemoteCompositionResultError extends Error { + constructor(public supergraphSdl: string | null | undefined) { + super(`Remote composition resulted in an invalid supergraph: ${supergraphSdl}`); + } +} + +export async function composeSupergraphLocally(services: Service[]): Promise { + const compositionResult = await new Promise((resolvePromise, reject) => { + try { + resolvePromise( + composeServices( + services.map(service => ({ + name: service.name, + url: service.url, + typeDefs: parse(service.sdl), + })), + ), + ); + } catch (error) { + // composeServices should not throw; this reject covers the offchance that + // something unexpected happens under the hood, so the promise doesn't hang. + reject(error); + } + }); + + if (compositionHasErrors(compositionResult)) { + throw new SupergraphCompositionError(compositionResult); + } + + return compositionResult.supergraphSdl; +} + +export async function composeSupergraphRemotely(input: { + services: Service[]; + registry: string; + token: string; + unstable__forceLatest: boolean; + target: DevFetcherTargetReference | null; + version: string; + logger?: LegacyLogger; + fetch?: FetchImplementation; +}): Promise { + const response = await http.post( + input.registry, + JSON.stringify({ + query: /* GraphQL */ ` + mutation CreateDevFetcher_SchemaCompose($input: SchemaComposeInput!) { + schemaCompose(input: $input) { + __typename + ... on SchemaComposeSuccess { + valid + compositionResult { + supergraphSdl + errors { + edges { + node { + message + } + } + } + } + } + ... on SchemaComposeError { + message + } + } + } + `, + variables: { + input: { + useLatestComposableVersion: !input.unstable__forceLatest, + services: input.services.map(service => ({ + name: service.name, + url: service.url, + sdl: service.sdl, + })), + target: input.target, + }, + }, + }), + { + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${input.token}`, + 'graphql-client-name': 'Hive Dev Fetcher', + 'graphql-client-version': input.version, + }, + logger: input.logger, + fetchImplementation: input.fetch, + }, + ); + + const body: { + data?: { + schemaCompose: + | { + __typename: 'SchemaComposeSuccess'; + valid: boolean; + compositionResult: { + supergraphSdl?: string | null; + errors?: { edges: Array<{ node: { message: string } }> } | null; + }; + } + | { __typename: 'SchemaComposeError'; message: string }; + }; + errors?: Array<{ message: string }>; + } = await response.json(); + + if (body.errors?.length) { + throw new RegistryApiError(body.errors.map(error => error.message).join(', ')); + } + + const schemaCompose = body.data?.schemaCompose; + if (!schemaCompose) { + throw new RegistryApiError('Received an unexpected response from the registry.'); + } + + if (schemaCompose.__typename === 'SchemaComposeError') { + throw new RegistryApiError(schemaCompose.message); + } + + const { valid, compositionResult } = schemaCompose; + + if (!valid) { + if (compositionResult.errors) { + throw new RemoteCompositionError(compositionResult.errors.edges.map(edge => edge.node)); + } + + throw new InvalidRemoteCompositionResultError(compositionResult.supergraphSdl); + } + + if (typeof compositionResult.supergraphSdl !== 'string') { + throw new InvalidRemoteCompositionResultError(compositionResult.supergraphSdl); + } + + return compositionResult.supergraphSdl; +} + +async function introspectFederationService( + service: HiveDevService, + logger: LegacyLogger, + fetch?: FetchImplementation, +): Promise { + const response = await http.post(service.url, JSON.stringify({ query: '{ _service { sdl } }' }), { + headers: { 'content-type': 'application/json' }, + logger, + fetchImplementation: fetch, + }); + + const body: { + data?: { _service?: { sdl?: string } }; + errors?: Array<{ message: string }>; + } = await response.json(); + + if (body.errors?.length || !body.data?._service?.sdl) { + throw new Error( + `Could not get a federation introspection result from the service "${service.name}". ` + + `Make sure the service exposes a federation "_service { sdl }" field, or set its ` + + `"introspection" option to "graphql" to use standard GraphQL introspection instead.`, + ); + } + + return body.data._service.sdl; +} + +async function introspectGraphQLService( + service: HiveDevService, + logger: LegacyLogger, + fetch?: FetchImplementation, +): Promise { + const response = await http.post( + service.url, + JSON.stringify({ query: getIntrospectionQuery() }), + { + headers: { 'content-type': 'application/json' }, + logger, + fetchImplementation: fetch, + }, + ); + + const body: { + data?: IntrospectionQuery; + errors?: Array<{ message: string }>; + } = await response.json(); + + if (body.errors?.length || !body.data) { + throw new Error( + `Could not get introspection result from the service "${service.name}". Make sure introspection is enabled by the server.`, + ); + } + + return printSchema(buildClientSchema(body.data)); +} + +async function resolveService( + service: HiveDevService, + cwd: string, + logger: LegacyLogger, + fetch?: FetchImplementation, +): Promise { + if (service.source === 'file') { + const filePath = resolvePath(cwd, service.schema); + const contents = await readFile(filePath, 'utf8'); + // `parse` here only validates the file's contents; `contents` is kept as-is rather than + // reprinting it, since it's re-parsed anyway by whichever composition path consumes it. + parse(contents); + return { name: service.name, url: service.url, sdl: contents }; + } + + const sdl = + service.source === 'graphql' + ? await introspectGraphQLService(service, logger, fetch) + : await introspectFederationService(service, logger, fetch); + + return { name: service.name, url: service.url, sdl }; +} + +async function resolveServices( + services: HiveDevService[], + cwd: string, + logger: LegacyLogger, + fetch?: FetchImplementation, +): Promise { + return Promise.all(services.map(service => resolveService(service, cwd, logger, fetch))); +} + +const CACHE_KEY = 'hive:dev-fetcher:supergraph'; + +function servicesUnchanged(previous: Service[], next: Service[]): boolean { + if (previous.length !== next.length) { + return false; + } + + return next.every(service => previous.find(p => p.name === service.name)?.sdl === service.sdl); +} + +export type HiveDevFetcher = { + /** Resolve the configured services and return the (possibly cached) composed supergraph SDL. */ + fetch(): Promise; +}; + +/** + * Create a fetcher that can get subgraph definitions from a local file, graphql introspection, + * or federated introspection (default), and then compose these services with the latest schema + * stored in Hive with these subgraphs replaced (based on service name). + * + * This is an alternative to using `@graphql-hive/cli`'s dev command. + * + * The compsed supergraph is cached and is only recomposed if the provided service SDLs change. But + * introspection and file reading is ran on every call, so if using Hive Gateway's polling interval, + * set the interval accordingly. + */ +export function createDevFetcher(options: HiveDevFetcherOptions): HiveDevFetcher { + const logger: LegacyLogger = options.logger ?? { + info: () => {}, + error: () => {}, + debug: () => {}, + }; + const cwd = options.cwd ?? process.cwd(); + + return { + async fetch(): Promise { + const services = await resolveServices(options.services, cwd, logger, options.fetch); + + const cached = await options.cache?.get(CACHE_KEY); + if (cached && servicesUnchanged(cached.services, services)) { + return cached.supergraphSdl; + } + + let supergraphSdl: string; + if (options.remote) { + if (!options.registry || !options.token) { + throw new Error('`registry` and `token` are required when `remote` is enabled.'); + } + + supergraphSdl = await composeSupergraphRemotely({ + services, + registry: options.registry, + token: options.token, + unstable__forceLatest: options.unstable__forceLatest ?? false, + target: options.target ?? null, + version: options.version ?? 'unknown', + logger, + fetch: options.fetch, + }); + } else { + supergraphSdl = await composeSupergraphLocally(services); + } + + await options.cache?.set(CACHE_KEY, { services, supergraphSdl }); + + return supergraphSdl; + }, + }; +} diff --git a/packages/libraries/core/src/index.ts b/packages/libraries/core/src/index.ts index 2f1e977efc0..6a4f574be49 100644 --- a/packages/libraries/core/src/index.ts +++ b/packages/libraries/core/src/index.ts @@ -27,3 +27,18 @@ export { hideInjectedTypenames, getDefinedRootType, } from './client/add-hive-typenames.js'; +export { + createDevFetcher, + composeSupergraphLocally, + composeSupergraphRemotely, + SupergraphCompositionError, + RegistryApiError, + RemoteCompositionError, + InvalidRemoteCompositionResultError, +} from './client/dev-fetcher.js'; +export type { + HiveDevFetcher, + HiveDevFetcherOptions, + HiveDevService, + DevFetcherTargetReference, +} from './client/dev-fetcher.js'; diff --git a/packages/libraries/core/tests/dev-fetcher.spec.ts b/packages/libraries/core/tests/dev-fetcher.spec.ts new file mode 100644 index 00000000000..1a5622bbd64 --- /dev/null +++ b/packages/libraries/core/tests/dev-fetcher.spec.ts @@ -0,0 +1,359 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + composeSupergraphLocally, + composeSupergraphRemotely, + createDevFetcher, + InvalidRemoteCompositionResultError, + RegistryApiError, + RemoteCompositionError, + SupergraphCompositionError, +} from '../src/client/dev-fetcher'; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +test('composes a valid supergraph from local services', async () => { + const supergraphSdl = await composeSupergraphLocally([ + { name: 'a', url: 'http://a', sdl: 'type Query { hello: String }' }, + ]); + + expect(supergraphSdl).toContain('hello'); +}); + +test('throws a SupergraphCompositionError when local composition fails', async () => { + await expect( + composeSupergraphLocally([ + { name: 'a', url: 'http://a', sdl: 'type Query { hello: String }' }, + { name: 'b', url: 'http://b', sdl: 'type Query { hello: Int }' }, + ]), + ).rejects.toThrow(SupergraphCompositionError); +}); + +const remoteComposeArgs = { + services: [{ name: 'a', url: 'http://a', sdl: 'type Query { hello: String }' }], + registry: 'http://registry.localhost', + token: 'secret-token', + unstable__forceLatest: false, + target: null, + version: '1.2.3', +}; + +test('composes remotely and returns the supergraph SDL', async () => { + const fetch = vi.fn().mockResolvedValue( + jsonResponse({ + data: { + schemaCompose: { + __typename: 'SchemaComposeSuccess', + valid: true, + compositionResult: { supergraphSdl: 'remote supergraph sdl' }, + }, + }, + }), + ); + + const result = await composeSupergraphRemotely({ ...remoteComposeArgs, fetch }); + + expect(result).toBe('remote supergraph sdl'); + expect(fetch).toHaveBeenCalledWith( + 'http://registry.localhost', + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'Bearer secret-token' }), + }), + ); +}); + +test('throws a RegistryApiError when the registry returns a SchemaComposeError', async () => { + const fetch = vi.fn().mockResolvedValue( + jsonResponse({ + data: { + schemaCompose: { __typename: 'SchemaComposeError', message: 'something went wrong' }, + }, + }), + ); + + await expect(composeSupergraphRemotely({ ...remoteComposeArgs, fetch })).rejects.toThrow( + RegistryApiError, + ); +}); + +test('throws a RemoteCompositionError when remote composition is invalid with errors', async () => { + const fetch = vi.fn().mockResolvedValue( + jsonResponse({ + data: { + schemaCompose: { + __typename: 'SchemaComposeSuccess', + valid: false, + compositionResult: { + supergraphSdl: null, + errors: { edges: [{ node: { message: 'field conflict' } }] }, + }, + }, + }, + }), + ); + + const error = await composeSupergraphRemotely({ ...remoteComposeArgs, fetch }).catch(e => e); + + expect(error).toBeInstanceOf(RemoteCompositionError); + expect(error.errors).toEqual([{ message: 'field conflict' }]); +}); + +test('throws an InvalidRemoteCompositionResultError when composition is valid but has no supergraph SDL', async () => { + const fetch = vi.fn().mockResolvedValue( + jsonResponse({ + data: { + schemaCompose: { + __typename: 'SchemaComposeSuccess', + valid: true, + compositionResult: { supergraphSdl: null }, + }, + }, + }), + ); + + await expect(composeSupergraphRemotely({ ...remoteComposeArgs, fetch })).rejects.toThrow( + InvalidRemoteCompositionResultError, + ); +}); + +test('does not recompose when resolved service SDLs are unchanged', async () => { + const fetch = vi + .fn() + .mockImplementation(async () => + jsonResponse({ data: { _service: { sdl: 'type Query { hello: String }' } } }), + ); + const store = new Map(); + + const fetcher = createDevFetcher({ + services: [{ name: 'a', url: 'http://a' }], + fetch, + cache: { + get: async key => store.get(key) as any, + set: async (key, value) => { + store.set(key, value); + }, + }, + }); + + const first = await fetcher.fetch(); + const second = await fetcher.fetch(); + + expect(first).toBe(second); + // one introspection call per `fetch()`, but composition only runs once (cached on the 2nd). + expect(fetch).toHaveBeenCalledTimes(2); +}); + +test('recomposes when a resolved service SDL changes', async () => { + let sdl = 'type Query { hello: String }'; + const fetch = vi.fn().mockImplementation(async () => + jsonResponse({ data: { _service: { sdl } } }), + ); + const store = new Map(); + + const fetcher = createDevFetcher({ + services: [{ name: 'a', url: 'http://a' }], + fetch, + cache: { + get: async key => store.get(key) as any, + set: async (key, value) => { + store.set(key, value); + }, + }, + }); + + const first = await fetcher.fetch(); + sdl = 'type Query { hello: Int }'; + const second = await fetcher.fetch(); + + expect(first).not.toBe(second); +}); + +test('composes remotely when `remote` is enabled', async () => { + const fetch = vi.fn().mockImplementation(async (url: string) => { + if (url === 'http://a') { + return jsonResponse({ data: { _service: { sdl: 'type Query { hello: String }' } } }); + } + return jsonResponse({ + data: { + schemaCompose: { + __typename: 'SchemaComposeSuccess', + valid: true, + compositionResult: { supergraphSdl: 'remote supergraph sdl' }, + }, + }, + }); + }); + + const fetcher = createDevFetcher({ + services: [{ name: 'a', url: 'http://a' }], + remote: true, + registry: 'http://registry.localhost', + token: 'secret-token', + unstable__forceLatest: true, + version: '1.2.3', + fetch, + }); + + const result = await fetcher.fetch(); + + expect(result).toBe('remote supergraph sdl'); +}); + +test('throws when `remote` is enabled without a registry or token', async () => { + const fetcher = createDevFetcher({ + services: [], + remote: true, + }); + + await expect(fetcher.fetch()).rejects.toThrow( + '`registry` and `token` are required when `remote` is enabled.', + ); +}); + +test('resolves a relative schema file path against `cwd`', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'hive-dev-fetcher-')); + await writeFile(join(cwd, 'a.graphql'), 'type Query { hello: String }', 'utf8'); + + const fetcher = createDevFetcher({ + services: [{ name: 'a', url: 'http://a', source: 'file', schema: 'a.graphql' }], + cwd, + }); + + const supergraphSdl = await fetcher.fetch(); + + expect(supergraphSdl).toContain('hello'); +}); + +test('uses federation introspection (`_service { sdl }`) by default', async () => { + const fetch = vi.fn().mockImplementation(async () => + jsonResponse({ data: { _service: { sdl: 'type Query { hello: String }' } } }), + ); + + const fetcher = createDevFetcher({ services: [{ name: 'a', url: 'http://a' }], fetch }); + const supergraphSdl = await fetcher.fetch(); + + expect(supergraphSdl).toContain('hello'); + const [, init] = fetch.mock.calls[0]; + expect(JSON.parse(init.body as string).query).toContain('_service'); +}); + +test('uses standard GraphQL introspection when `source: "graphql"` is set', async () => { + const fetch = vi.fn().mockImplementation(async () => + jsonResponse({ + data: { + __schema: { + queryType: { name: 'Query' }, + mutationType: null, + subscriptionType: null, + types: [ + { + kind: 'OBJECT', + name: 'Query', + fields: [ + { + name: 'hello', + args: [], + type: { kind: 'SCALAR', name: 'String', ofType: null }, + isDeprecated: false, + }, + ], + interfaces: [], + }, + { kind: 'SCALAR', name: 'String' }, + ], + directives: [], + }, + }, + }), + ); + + const fetcher = createDevFetcher({ + services: [{ name: 'a', url: 'http://a', source: 'graphql' }], + fetch, + }); + + const supergraphSdl = await fetcher.fetch(); + + expect(supergraphSdl).toContain('hello'); + const [, init] = fetch.mock.calls[0]; + expect(JSON.parse(init.body as string).query).toContain('__schema'); +}); + +test('does not fall back to standard introspection when federation introspection fails', async () => { + const fetch = vi + .fn() + .mockResolvedValue( + jsonResponse({ errors: [{ message: 'Cannot query field "_service" on type "Query".' }] }), + ); + + const fetcher = createDevFetcher({ services: [{ name: 'a', url: 'http://a' }], fetch }); + + await expect(fetcher.fetch()).rejects.toThrow(/federation introspection/); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +test('resolves each service with its own source', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'hive-dev-fetcher-')); + await writeFile(join(cwd, 'a.graphql'), 'type Query { fileField: String }', 'utf8'); + + const fetch = vi.fn().mockImplementation(async (url: string, init: RequestInit) => { + const { query } = JSON.parse(init.body as string); + + if (url === 'http://b') { + expect(query).toContain('_service'); + return jsonResponse({ data: { _service: { sdl: 'type Query { federationField: String }' } } }); + } + + expect(url).toBe('http://c'); + expect(query).toContain('__schema'); + return jsonResponse({ + data: { + __schema: { + queryType: { name: 'Query' }, + mutationType: null, + subscriptionType: null, + types: [ + { + kind: 'OBJECT', + name: 'Query', + fields: [ + { + name: 'graphqlField', + args: [], + type: { kind: 'SCALAR', name: 'String', ofType: null }, + isDeprecated: false, + }, + ], + interfaces: [], + }, + { kind: 'SCALAR', name: 'String' }, + ], + directives: [], + }, + }, + }); + }); + + const fetcher = createDevFetcher({ + services: [ + { name: 'a', url: 'http://a', source: 'file', schema: 'a.graphql' }, + { name: 'b', url: 'http://b', source: 'federation' }, + { name: 'c', url: 'http://c', source: 'graphql' }, + ], + cwd, + fetch, + }); + + const supergraphSdl = await fetcher.fetch(); + + expect(supergraphSdl).toContain('fileField'); + expect(supergraphSdl).toContain('federationField'); + expect(supergraphSdl).toContain('graphqlField'); + expect(fetch).toHaveBeenCalledTimes(2); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 257dec24dbb..dcf8e118937 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -584,6 +584,9 @@ importers: typescript: specifier: 5.7.3 version: 5.7.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.10.6)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.12.7(@types/node@25.5.0)(typescript@5.7.3))(vite@8.1.5(@types/node@25.5.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.2.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.8.3)) packages/libraries/core: dependencies: @@ -596,6 +599,9 @@ importers: '@graphql-tools/utils': specifier: ^12.0.0 version: 12.0.0(graphql@16.9.0) + '@theguild/federation-composition': + specifier: ^0.26.1 + version: 0.26.2(graphql@16.9.0) '@whatwg-node/fetch': specifier: ^0.10.13 version: 0.10.13 From 2784c39d0de35da5c4e3152d5f1ecae04542858e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:07 -0700 Subject: [PATCH 2/6] Add changeset for cli; standardize dev fetcher errors --- .changeset/shaggy-ghosts-sing.md | 5 +++ packages/libraries/cli/__tests__/dev.spec.ts | 24 +++++------ packages/libraries/cli/src/commands/dev.ts | 16 +++---- .../libraries/core/src/client/dev-fetcher.ts | 24 ++++++----- packages/libraries/core/src/index.ts | 8 ++-- .../libraries/core/tests/dev-fetcher.spec.ts | 42 ++++++++++--------- 6 files changed, 65 insertions(+), 54 deletions(-) create mode 100644 .changeset/shaggy-ghosts-sing.md diff --git a/.changeset/shaggy-ghosts-sing.md b/.changeset/shaggy-ghosts-sing.md new file mode 100644 index 00000000000..47d62cf714a --- /dev/null +++ b/.changeset/shaggy-ghosts-sing.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/cli': patch +--- + +Refactor schema composition to use `@graphql-hive/core` exported functions diff --git a/packages/libraries/cli/__tests__/dev.spec.ts b/packages/libraries/cli/__tests__/dev.spec.ts index 3f1eb6b6d41..bcebffe8265 100644 --- a/packages/libraries/cli/__tests__/dev.spec.ts +++ b/packages/libraries/cli/__tests__/dev.spec.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - InvalidRemoteCompositionResultError, - RegistryApiError, - RemoteCompositionError as CoreRemoteCompositionError, - SupergraphCompositionError, + InvalidSupergraphResultError, + LocalSupergraphCompositionError, + RemoteSupergraphCompositionError, + SupergraphRegistryApiError, } from '@graphql-hive/core'; import { APIError, @@ -41,10 +41,10 @@ beforeEach(() => { }); describe('Dev.composeLocally', () => { - it('maps a SupergraphCompositionError to a LocalCompositionError', async () => { + it('maps a LocalSupergraphCompositionError to a LocalCompositionError', async () => { const compositionResult = { errors: [{ message: 'field conflict' }] } as any; mockComposeSupergraphLocally.mockRejectedValue( - new SupergraphCompositionError(compositionResult), + new LocalSupergraphCompositionError(compositionResult), ); const dev = createDevInstance(); @@ -78,8 +78,8 @@ describe('Dev.compose', () => { target: null, }; - it('maps a RegistryApiError to an APIError', async () => { - mockComposeSupergraphRemotely.mockRejectedValue(new RegistryApiError('bad request')); + it('maps a SupergraphRegistryApiError to an APIError', async () => { + mockComposeSupergraphRemotely.mockRejectedValue(new SupergraphRegistryApiError('bad request')); const dev = createDevInstance(); const onError = vi.fn(); @@ -89,9 +89,9 @@ describe('Dev.compose', () => { expect(onError).toHaveBeenCalledWith(expect.any(APIError)); }); - it('maps a core RemoteCompositionError to the CLI RemoteCompositionError', async () => { + it('maps a RemoteSupergraphCompositionError to a CLI RemoteCompositionError', async () => { mockComposeSupergraphRemotely.mockRejectedValue( - new CoreRemoteCompositionError([{ message: 'field conflict' }]), + new RemoteSupergraphCompositionError([{ message: 'field conflict' }]), ); const dev = createDevInstance(); @@ -102,8 +102,8 @@ describe('Dev.compose', () => { expect(onError).toHaveBeenCalledWith(expect.any(RemoteCompositionError)); }); - it('maps an InvalidRemoteCompositionResultError to an InvalidCompositionResultError', async () => { - mockComposeSupergraphRemotely.mockRejectedValue(new InvalidRemoteCompositionResultError(null)); + it('maps an InvalidSupergraphResultError to an InvalidCompositionResultError', async () => { + mockComposeSupergraphRemotely.mockRejectedValue(new InvalidSupergraphResultError(null)); const dev = createDevInstance(); const onError = vi.fn(); diff --git a/packages/libraries/cli/src/commands/dev.ts b/packages/libraries/cli/src/commands/dev.ts index 82209550209..d3966ed2e1a 100644 --- a/packages/libraries/cli/src/commands/dev.ts +++ b/packages/libraries/cli/src/commands/dev.ts @@ -3,10 +3,10 @@ import { resolve } from 'node:path'; import { composeSupergraphLocally, composeSupergraphRemotely, - RemoteCompositionError as CoreRemoteCompositionError, - InvalidRemoteCompositionResultError, - RegistryApiError, - SupergraphCompositionError, + InvalidSupergraphResultError, + LocalSupergraphCompositionError, + RemoteSupergraphCompositionError, + SupergraphRegistryApiError, type Logger as LegacyLogger, } from '@graphql-hive/core'; import { Flags } from '@oclif/core'; @@ -351,7 +351,7 @@ export default class Dev extends Command { try { supergraphSdl = await composeSupergraphLocally(input.services); } catch (error) { - if (error instanceof SupergraphCompositionError) { + if (error instanceof LocalSupergraphCompositionError) { input.onError(new LocalCompositionError(error.compositionResult)); return; } @@ -384,11 +384,11 @@ export default class Dev extends Command { logger: this.logger, }); } catch (error) { - if (error instanceof RegistryApiError) { + if (error instanceof SupergraphRegistryApiError) { input.onError(new APIError(error.message)); return; } - if (error instanceof CoreRemoteCompositionError) { + if (error instanceof RemoteSupergraphCompositionError) { input.onError( new RemoteCompositionError( makeFragmentData( @@ -399,7 +399,7 @@ export default class Dev extends Command { ); return; } - if (error instanceof InvalidRemoteCompositionResultError) { + if (error instanceof InvalidSupergraphResultError) { input.onError(new InvalidCompositionResultError(error.supergraphSdl)); return; } diff --git a/packages/libraries/core/src/client/dev-fetcher.ts b/packages/libraries/core/src/client/dev-fetcher.ts index 2e4aeab8d52..511b9905e0b 100644 --- a/packages/libraries/core/src/client/dev-fetcher.ts +++ b/packages/libraries/core/src/client/dev-fetcher.ts @@ -76,24 +76,24 @@ export interface HiveDevFetcherOptions { } /** Local composition (via `@theguild/federation-composition`) produced errors. */ -export class SupergraphCompositionError extends Error { +export class LocalSupergraphCompositionError extends Error { constructor(public compositionResult: CompositionFailure) { super('Local composition failed.'); } } /** The registry API returned a GraphQL/API-level error while composing remotely. */ -export class RegistryApiError extends Error {} +export class SupergraphRegistryApiError extends Error {} /** Remote composition finished but produced composition errors. */ -export class RemoteCompositionError extends Error { +export class RemoteSupergraphCompositionError extends Error { constructor(public errors: Array<{ message: string }>) { super(`Remote composition failed:\n${errors.map(error => error.message).join('\n')}`); } } /** Remote composition reported success but did not return a usable supergraph SDL. */ -export class InvalidRemoteCompositionResultError extends Error { +export class InvalidSupergraphResultError extends Error { constructor(public supergraphSdl: string | null | undefined) { super(`Remote composition resulted in an invalid supergraph: ${supergraphSdl}`); } @@ -119,7 +119,7 @@ export async function composeSupergraphLocally(services: Service[]): Promise error.message).join(', ')); + throw new SupergraphRegistryApiError(body.errors.map(error => error.message).join(', ')); } const schemaCompose = body.data?.schemaCompose; if (!schemaCompose) { - throw new RegistryApiError('Received an unexpected response from the registry.'); + throw new SupergraphRegistryApiError('Received an unexpected response from the registry.'); } if (schemaCompose.__typename === 'SchemaComposeError') { - throw new RegistryApiError(schemaCompose.message); + throw new SupergraphRegistryApiError(schemaCompose.message); } const { valid, compositionResult } = schemaCompose; if (!valid) { if (compositionResult.errors) { - throw new RemoteCompositionError(compositionResult.errors.edges.map(edge => edge.node)); + throw new RemoteSupergraphCompositionError( + compositionResult.errors.edges.map(edge => edge.node), + ); } - throw new InvalidRemoteCompositionResultError(compositionResult.supergraphSdl); + throw new InvalidSupergraphResultError(compositionResult.supergraphSdl); } if (typeof compositionResult.supergraphSdl !== 'string') { - throw new InvalidRemoteCompositionResultError(compositionResult.supergraphSdl); + throw new InvalidSupergraphResultError(compositionResult.supergraphSdl); } return compositionResult.supergraphSdl; diff --git a/packages/libraries/core/src/index.ts b/packages/libraries/core/src/index.ts index 6a4f574be49..5a569846e13 100644 --- a/packages/libraries/core/src/index.ts +++ b/packages/libraries/core/src/index.ts @@ -31,10 +31,10 @@ export { createDevFetcher, composeSupergraphLocally, composeSupergraphRemotely, - SupergraphCompositionError, - RegistryApiError, - RemoteCompositionError, - InvalidRemoteCompositionResultError, + LocalSupergraphCompositionError, + SupergraphRegistryApiError, + RemoteSupergraphCompositionError, + InvalidSupergraphResultError, } from './client/dev-fetcher.js'; export type { HiveDevFetcher, diff --git a/packages/libraries/core/tests/dev-fetcher.spec.ts b/packages/libraries/core/tests/dev-fetcher.spec.ts index 1a5622bbd64..82d193dccca 100644 --- a/packages/libraries/core/tests/dev-fetcher.spec.ts +++ b/packages/libraries/core/tests/dev-fetcher.spec.ts @@ -5,10 +5,10 @@ import { composeSupergraphLocally, composeSupergraphRemotely, createDevFetcher, - InvalidRemoteCompositionResultError, - RegistryApiError, - RemoteCompositionError, - SupergraphCompositionError, + InvalidSupergraphResultError, + LocalSupergraphCompositionError, + RemoteSupergraphCompositionError, + SupergraphRegistryApiError, } from '../src/client/dev-fetcher'; function jsonResponse(body: unknown, init?: ResponseInit) { @@ -26,13 +26,13 @@ test('composes a valid supergraph from local services', async () => { expect(supergraphSdl).toContain('hello'); }); -test('throws a SupergraphCompositionError when local composition fails', async () => { +test('throws a LocalSupergraphCompositionError when local composition fails', async () => { await expect( composeSupergraphLocally([ { name: 'a', url: 'http://a', sdl: 'type Query { hello: String }' }, { name: 'b', url: 'http://b', sdl: 'type Query { hello: Int }' }, ]), - ).rejects.toThrow(SupergraphCompositionError); + ).rejects.toThrow(LocalSupergraphCompositionError); }); const remoteComposeArgs = { @@ -68,7 +68,7 @@ test('composes remotely and returns the supergraph SDL', async () => { ); }); -test('throws a RegistryApiError when the registry returns a SchemaComposeError', async () => { +test('throws a SupergraphRegistryApiError when the registry returns a SchemaComposeError', async () => { const fetch = vi.fn().mockResolvedValue( jsonResponse({ data: { @@ -78,11 +78,11 @@ test('throws a RegistryApiError when the registry returns a SchemaComposeError', ); await expect(composeSupergraphRemotely({ ...remoteComposeArgs, fetch })).rejects.toThrow( - RegistryApiError, + SupergraphRegistryApiError, ); }); -test('throws a RemoteCompositionError when remote composition is invalid with errors', async () => { +test('throws a RemoteSupergraphCompositionError when remote composition is invalid with errors', async () => { const fetch = vi.fn().mockResolvedValue( jsonResponse({ data: { @@ -100,11 +100,11 @@ test('throws a RemoteCompositionError when remote composition is invalid with er const error = await composeSupergraphRemotely({ ...remoteComposeArgs, fetch }).catch(e => e); - expect(error).toBeInstanceOf(RemoteCompositionError); + expect(error).toBeInstanceOf(RemoteSupergraphCompositionError); expect(error.errors).toEqual([{ message: 'field conflict' }]); }); -test('throws an InvalidRemoteCompositionResultError when composition is valid but has no supergraph SDL', async () => { +test('throws an InvalidSupergraphResultError when composition is valid but has no supergraph SDL', async () => { const fetch = vi.fn().mockResolvedValue( jsonResponse({ data: { @@ -118,7 +118,7 @@ test('throws an InvalidRemoteCompositionResultError when composition is valid bu ); await expect(composeSupergraphRemotely({ ...remoteComposeArgs, fetch })).rejects.toThrow( - InvalidRemoteCompositionResultError, + InvalidSupergraphResultError, ); }); @@ -151,9 +151,9 @@ test('does not recompose when resolved service SDLs are unchanged', async () => test('recomposes when a resolved service SDL changes', async () => { let sdl = 'type Query { hello: String }'; - const fetch = vi.fn().mockImplementation(async () => - jsonResponse({ data: { _service: { sdl } } }), - ); + const fetch = vi + .fn() + .mockImplementation(async () => jsonResponse({ data: { _service: { sdl } } })); const store = new Map(); const fetcher = createDevFetcher({ @@ -231,9 +231,11 @@ test('resolves a relative schema file path against `cwd`', async () => { }); test('uses federation introspection (`_service { sdl }`) by default', async () => { - const fetch = vi.fn().mockImplementation(async () => - jsonResponse({ data: { _service: { sdl: 'type Query { hello: String }' } } }), - ); + const fetch = vi + .fn() + .mockImplementation(async () => + jsonResponse({ data: { _service: { sdl: 'type Query { hello: String }' } } }), + ); const fetcher = createDevFetcher({ services: [{ name: 'a', url: 'http://a' }], fetch }); const supergraphSdl = await fetcher.fetch(); @@ -307,7 +309,9 @@ test('resolves each service with its own source', async () => { if (url === 'http://b') { expect(query).toContain('_service'); - return jsonResponse({ data: { _service: { sdl: 'type Query { federationField: String }' } } }); + return jsonResponse({ + data: { _service: { sdl: 'type Query { federationField: String }' } }, + }); } expect(url).toBe('http://c'); From aae7e84b78d384b06397a58915386fd65e1c234c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:40:34 -0700 Subject: [PATCH 3/6] add circuit breaking to devFetcher --- .../libraries/core/src/client/dev-fetcher.ts | 50 +++++++++++------ .../libraries/core/tests/dev-fetcher.spec.ts | 54 +++++++++++++++++++ 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/packages/libraries/core/src/client/dev-fetcher.ts b/packages/libraries/core/src/client/dev-fetcher.ts index 511b9905e0b..c5947dd6a4d 100644 --- a/packages/libraries/core/src/client/dev-fetcher.ts +++ b/packages/libraries/core/src/client/dev-fetcher.ts @@ -9,6 +9,11 @@ import { } from 'graphql'; import { composeServices, compositionHasErrors } from '@theguild/federation-composition'; import type { CompositionFailure, CompositionResult } from '@theguild/federation-composition'; +import CircuitBreaker from '../circuit-breaker/circuit.js'; +import { + CircuitBreakerConfiguration, + defaultCircuitBreakerConfiguration, +} from './circuit-breaker.js'; import { http } from './http-client.js'; import type { LegacyLogger } from './types.js'; @@ -20,7 +25,6 @@ type Service = { sdl: string; }; -/** A target reference, already parsed from a slug or UUID. */ export type DevFetcherTargetReference = | { byId: string | number; bySelector?: never } | { @@ -68,6 +72,8 @@ export interface HiveDevFetcherOptions { fetch?: FetchImplementation; /** Base directory used to resolve relative service schema file paths. Defaults to `process.cwd()`. */ cwd?: string; + /** Guards composition so it isn't attempted more frequently than the circuit breaker allows. */ + circuitBreaker?: CircuitBreakerConfiguration; /** Used to avoid recomposing the supergraph when resolved service SDLs are unchanged. */ cache?: { get(key: string): Promise | CachedSupergraph | undefined; @@ -75,7 +81,6 @@ export interface HiveDevFetcherOptions { }; } -/** Local composition (via `@theguild/federation-composition`) produced errors. */ export class LocalSupergraphCompositionError extends Error { constructor(public compositionResult: CompositionFailure) { super('Local composition failed.'); @@ -334,6 +339,8 @@ function servicesUnchanged(previous: Service[], next: Service[]): boolean { export type HiveDevFetcher = { /** Resolve the configured services and return the (possibly cached) composed supergraph SDL. */ fetch(): Promise; + /** Dispose the fetcher and cleanup existing timers (e.g. used for circuit breaker) */ + dispose(): void; }; /** @@ -354,23 +361,16 @@ export function createDevFetcher(options: HiveDevFetcherOptions): HiveDevFetcher debug: () => {}, }; const cwd = options.cwd ?? process.cwd(); + const circuitBreakerConfig = options.circuitBreaker ?? defaultCircuitBreakerConfiguration; - return { - async fetch(): Promise { - const services = await resolveServices(options.services, cwd, logger, options.fetch); - - const cached = await options.cache?.get(CACHE_KEY); - if (cached && servicesUnchanged(cached.services, services)) { - return cached.supergraphSdl; - } - - let supergraphSdl: string; + const composeBreaker = new CircuitBreaker( + async (services: Service[]) => { if (options.remote) { if (!options.registry || !options.token) { throw new Error('`registry` and `token` are required when `remote` is enabled.'); } - supergraphSdl = await composeSupergraphRemotely({ + return await composeSupergraphRemotely({ services, registry: options.registry, token: options.token, @@ -380,13 +380,33 @@ export function createDevFetcher(options: HiveDevFetcherOptions): HiveDevFetcher logger, fetch: options.fetch, }); - } else { - supergraphSdl = await composeSupergraphLocally(services); } + return await composeSupergraphLocally(services); + }, + { + ...circuitBreakerConfig, + timeout: false, + }, + ); + + return { + async fetch(): Promise { + const services = await resolveServices(options.services, cwd, logger, options.fetch); + + const cached = await options.cache?.get(CACHE_KEY); + if (cached && servicesUnchanged(cached.services, services)) { + return cached.supergraphSdl; + } + + const supergraphSdl: string = await composeBreaker.fire(services); + await options.cache?.set(CACHE_KEY, { services, supergraphSdl }); return supergraphSdl; }, + dispose() { + composeBreaker.shutdown(); + }, }; } diff --git a/packages/libraries/core/tests/dev-fetcher.spec.ts b/packages/libraries/core/tests/dev-fetcher.spec.ts index 82d193dccca..003a4340b6c 100644 --- a/packages/libraries/core/tests/dev-fetcher.spec.ts +++ b/packages/libraries/core/tests/dev-fetcher.spec.ts @@ -300,6 +300,60 @@ test('does not fall back to standard introspection when federation introspection expect(fetch).toHaveBeenCalledTimes(1); }); +test('opens the circuit breaker after repeated composition failures, preventing further composition attempts', async () => { + let introspectionCalls = 0; + let composeCalls = 0; + const fetch = vi.fn().mockImplementation(async (url: string) => { + if (url === 'http://a') { + introspectionCalls++; + return jsonResponse({ data: { _service: { sdl: 'type Query { hello: String }' } } }); + } + + composeCalls++; + return jsonResponse({ + data: { + schemaCompose: { __typename: 'SchemaComposeError', message: 'composition unavailable' }, + }, + }); + }); + + const fetcher = createDevFetcher({ + services: [{ name: 'a', url: 'http://a' }], + remote: true, + registry: 'http://registry.localhost', + token: 'secret-token', + version: '1.2.3', + fetch, + circuitBreaker: { + volumeThreshold: 1, + errorThresholdPercentage: 1, + resetTimeout: 30_000, + }, + }); + + await expect(fetcher.fetch()).rejects.toThrow(SupergraphRegistryApiError); + expect(composeCalls).toBe(1); + + // The breaker is now open: composition is not attempted again until `resetTimeout` elapses. + await expect(fetcher.fetch()).rejects.toThrow('Breaker is open'); + expect(introspectionCalls).toBe(2); + expect(composeCalls).toBe(1); +}); + +test('dispose() shuts down the circuit breaker, so `fetch` can no longer compose', async () => { + const fetch = vi + .fn() + .mockImplementation(async () => + jsonResponse({ data: { _service: { sdl: 'type Query { hello: String }' } } }), + ); + + const fetcher = createDevFetcher({ services: [{ name: 'a', url: 'http://a' }], fetch }); + + fetcher.dispose(); + + await expect(fetcher.fetch()).rejects.toThrow('shutdown'); +}); + test('resolves each service with its own source', async () => { const cwd = await mkdtemp(join(tmpdir(), 'hive-dev-fetcher-')); await writeFile(join(cwd, 'a.graphql'), 'type Query { fileField: String }', 'utf8'); From 54ac3f3b7bbd1cc0e7a7c4ee7ca116256d0b9d75 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:45:55 -0700 Subject: [PATCH 4/6] Fix logger; improve description --- packages/libraries/core/src/client/dev-fetcher.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/libraries/core/src/client/dev-fetcher.ts b/packages/libraries/core/src/client/dev-fetcher.ts index c5947dd6a4d..77df3e62588 100644 --- a/packages/libraries/core/src/client/dev-fetcher.ts +++ b/packages/libraries/core/src/client/dev-fetcher.ts @@ -16,6 +16,7 @@ import { } from './circuit-breaker.js'; import { http } from './http-client.js'; import type { LegacyLogger } from './types.js'; +import { chooseLogger } from './utils.js'; export type FetchImplementation = typeof globalThis.fetch; @@ -350,16 +351,13 @@ export type HiveDevFetcher = { * * This is an alternative to using `@graphql-hive/cli`'s dev command. * - * The compsed supergraph is cached and is only recomposed if the provided service SDLs change. But + * The composed supergraph is cached and is only recomposed if the provided service SDLs change. But * introspection and file reading is ran on every call, so if using Hive Gateway's polling interval, - * set the interval accordingly. + * set the interval accordingly. Composition is also CircuitBreaked, so that the expensive composition + * request is guaranteed not to run too frequently. */ export function createDevFetcher(options: HiveDevFetcherOptions): HiveDevFetcher { - const logger: LegacyLogger = options.logger ?? { - info: () => {}, - error: () => {}, - debug: () => {}, - }; + const logger = chooseLogger(options.logger); const cwd = options.cwd ?? process.cwd(); const circuitBreakerConfig = options.circuitBreaker ?? defaultCircuitBreakerConfiguration; From e378d9c0050005f6424a474a8af3e858f20d0a63 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:39:34 -0700 Subject: [PATCH 5/6] Remove node prefix from packages --- packages/libraries/core/src/client/dev-fetcher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/libraries/core/src/client/dev-fetcher.ts b/packages/libraries/core/src/client/dev-fetcher.ts index 77df3e62588..f167d5e68e7 100644 --- a/packages/libraries/core/src/client/dev-fetcher.ts +++ b/packages/libraries/core/src/client/dev-fetcher.ts @@ -1,5 +1,5 @@ -import { readFile } from 'node:fs/promises'; -import { resolve as resolvePath } from 'node:path'; +import { readFile } from 'fs/promises'; +import { resolve as resolvePath } from 'path'; import { buildClientSchema, getIntrospectionQuery, From 4981a2fdae34bda5947fa6fe472f6021e9640876 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:40:05 -0700 Subject: [PATCH 6/6] Only load node dependencies when required for dev command --- .../libraries/core/src/client/dev-fetcher.ts | 35 +++++++++++++--- .../libraries/core/tests/dev-fetcher.spec.ts | 40 +++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/libraries/core/src/client/dev-fetcher.ts b/packages/libraries/core/src/client/dev-fetcher.ts index f167d5e68e7..9785fdb8314 100644 --- a/packages/libraries/core/src/client/dev-fetcher.ts +++ b/packages/libraries/core/src/client/dev-fetcher.ts @@ -1,5 +1,3 @@ -import { readFile } from 'fs/promises'; -import { resolve as resolvePath } from 'path'; import { buildClientSchema, getIntrospectionQuery, @@ -38,7 +36,10 @@ export type HiveDevService = { url: string; } & ( | { - /** Read the schema from an SDL file rather than introspecting `url`. */ + /** + * Read the schema from an SDL file rather than introspecting `url`. + * Only supported when running in Node.js. + */ source: 'file'; /** Path to the service's SDL file. */ schema: string; @@ -295,6 +296,29 @@ async function introspectGraphQLService( return printSchema(buildClientSchema(body.data)); } +function isNodeRuntime(): boolean { + return typeof process !== 'undefined' && typeof process.versions?.node === 'string'; +} + +async function readLocalSchemaFile(cwd: string, schema: string): Promise { + if (!isNodeRuntime()) { + throw new Error( + `Cannot resolve the "${schema}" schema from a local file: "source: 'file'" requires ` + + `Node.js and is not supported in this runtime.`, + ); + } + + // The specifiers are held in variables (not passed as literals) so bundlers targeting + // non-Node runtimes (e.g. Cloudflare Workers via esbuild/Wrangler) don't try to statically + // resolve these Node built-ins for consumers who never use the `source: 'file'` service option. + const fsPromisesSpecifier = 'fs/promises'; + const pathSpecifier = 'path'; + const { readFile } = await import(fsPromisesSpecifier); + const { resolve: resolvePath } = await import(pathSpecifier); + + return readFile(resolvePath(cwd, schema), 'utf8'); +} + async function resolveService( service: HiveDevService, cwd: string, @@ -302,8 +326,7 @@ async function resolveService( fetch?: FetchImplementation, ): Promise { if (service.source === 'file') { - const filePath = resolvePath(cwd, service.schema); - const contents = await readFile(filePath, 'utf8'); + const contents = await readLocalSchemaFile(cwd, service.schema); // `parse` here only validates the file's contents; `contents` is kept as-is rather than // reprinting it, since it's re-parsed anyway by whichever composition path consumes it. parse(contents); @@ -358,7 +381,7 @@ export type HiveDevFetcher = { */ export function createDevFetcher(options: HiveDevFetcherOptions): HiveDevFetcher { const logger = chooseLogger(options.logger); - const cwd = options.cwd ?? process.cwd(); + const cwd = options.cwd ?? (isNodeRuntime() ? process.cwd() : ''); const circuitBreakerConfig = options.circuitBreaker ?? defaultCircuitBreakerConfiguration; const composeBreaker = new CircuitBreaker( diff --git a/packages/libraries/core/tests/dev-fetcher.spec.ts b/packages/libraries/core/tests/dev-fetcher.spec.ts index 003a4340b6c..ddc372dad0b 100644 --- a/packages/libraries/core/tests/dev-fetcher.spec.ts +++ b/packages/libraries/core/tests/dev-fetcher.spec.ts @@ -230,6 +230,46 @@ test('resolves a relative schema file path against `cwd`', async () => { expect(supergraphSdl).toContain('hello'); }); +test('does not reference `process` when no file-based service is configured outside Node.js', async () => { + const originalVersions = process.versions; + // @ts-expect-error - simulating a non-Node runtime (e.g. Cloudflare Workers), where `process` + // either doesn't exist at all or lacks `.versions.node`. + delete process.versions; + + try { + const fetch = vi + .fn() + .mockImplementation(async () => + jsonResponse({ data: { _service: { sdl: 'type Query { hello: String }' } } }), + ); + + const fetcher = createDevFetcher({ services: [{ name: 'a', url: 'http://a' }], fetch }); + const supergraphSdl = await fetcher.fetch(); + + expect(supergraphSdl).toContain('hello'); + } finally { + process.versions = originalVersions; + } +}); + +test('throws a clear error when a file-based service is used outside Node.js', async () => { + const originalVersions = process.versions; + // @ts-expect-error - simulating a non-Node runtime (e.g. Cloudflare Workers) + delete process.versions; + + try { + const fetcher = createDevFetcher({ + services: [{ name: 'a', url: 'http://a', source: 'file', schema: 'a.graphql' }], + }); + + await expect(fetcher.fetch()).rejects.toThrow( + /requires Node\.js and is not supported in this runtime/, + ); + } finally { + process.versions = originalVersions; + } +}); + test('uses federation introspection (`_service { sdl }`) by default', async () => { const fetch = vi .fn()