UNPKG

@graphql-mesh/serve-runtime

Version:
483 lines (481 loc) 22.4 kB
import { parse } from 'graphql'; import { createYoga, isAsyncIterable, mergeSchemas, useReadinessCheck, } from 'graphql-yoga'; import { createSupergraphSDLFetcher } from '@graphql-hive/apollo'; import { process } from '@graphql-mesh/cross-helpers'; import { getOnSubgraphExecute, getStitchingDirectivesTransformerForSubschema, handleFederationSubschema, handleFederationSupergraph, restoreExtraDirectives, UnifiedGraphManager, } from '@graphql-mesh/fusion-runtime'; import useMeshHive from '@graphql-mesh/plugin-hive'; import { DefaultLogger, getDirectiveExtensions, getHeadersObj, isDisposable, LogLevel, makeAsyncDisposable, mapMaybePromise, wrapFetchWithHooks, } from '@graphql-mesh/utils'; import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; import { delegateToSchema } from '@graphql-tools/delegate'; import { useExecutor } from '@graphql-tools/executor-yoga'; import { mergeDeep, parseSelectionSet, } from '@graphql-tools/utils'; import { wrapSchema } from '@graphql-tools/wrap'; import { AsyncDisposableStack } from '@whatwg-node/disposablestack'; import { getProxyExecutor } from './getProxyExecutor.js'; import { getUnifiedGraphSDL, handleUnifiedGraphConfig } from './handleUnifiedGraphConfig.js'; import landingPageHtml from './landing-page-html.js'; import { useChangingSchema } from './useChangingSchema.js'; import { useCompleteSubscriptionsOnDispose } from './useCompleteSubscriptionsOnDispose.js'; import { useCompleteSubscriptionsOnSchemaChange } from './useCompleteSubscriptionsOnSchemaChange.js'; import { useFetchDebug } from './useFetchDebug.js'; import { useRequestId } from './useRequestId.js'; import { useSubgraphExecuteDebug } from './useSubgraphExecuteDebug.js'; import { checkIfDataSatisfiesSelectionSet } from './utils.js'; export function createServeRuntime(config = {}) { let fetchAPI = config.fetchAPI; let logger; if (config.logging == null) { logger = new DefaultLogger(); } else if (typeof config.logging === 'boolean') { logger = config.logging ? new DefaultLogger() : new DefaultLogger('', LogLevel.silent); } if (typeof config.logging === 'number') { logger = new DefaultLogger(undefined, config.logging); } else if (typeof config.logging === 'object') { logger = config.logging; } const onFetchHooks = []; const wrappedFetchFn = wrapFetchWithHooks(onFetchHooks); const configContext = { fetch: wrappedFetchFn, logger, cwd: 'cwd' in config ? config.cwd : process.cwd?.(), cache: 'cache' in config ? config.cache : undefined, pubsub: 'pubsub' in config ? config.pubsub : undefined, }; let unifiedGraphPlugin; const readinessCheckEndpoint = config.readinessCheckEndpoint || '/readiness'; const onSubgraphExecuteHooks = []; // TODO: Will be deleted after v0 const onDelegateHooks = []; let unifiedGraph; let schemaInvalidator; let getSchema = () => unifiedGraph; let schemaChanged; let contextBuilder; let readinessChecker; let registryPlugin = {}; let subgraphInformationHTMLRenderer = () => ''; const disposableStack = new AsyncDisposableStack(); if ('proxy' in config) { const proxyExecutor = getProxyExecutor({ config, configContext, getSchema: () => unifiedGraph, onSubgraphExecuteHooks, disposableStack, }); const executorPlugin = useExecutor(proxyExecutor); executorPlugin.onSchemaChange = function onSchemaChange(payload) { unifiedGraph = payload.schema; }; if (config.skipValidation) { executorPlugin.onValidate = function ({ setResult }) { setResult([]); }; } unifiedGraphPlugin = executorPlugin; readinessChecker = () => { const res$ = proxyExecutor({ document: parse(`query { __typename }`), }); return mapMaybePromise(res$, res => !isAsyncIterable(res) && !!res.data?.__typename); }; schemaInvalidator = () => executorPlugin.invalidateUnifiedGraph(); subgraphInformationHTMLRenderer = () => { const endpoint = config.proxy.endpoint || '#'; return `<section class="supergraph-information"><h3>Proxy (<a href="${endpoint}">${endpoint}</a>): ${unifiedGraph ? 'Loaded ✅' : 'Not yet ❌'}</h3></section>`; }; } else if ('subgraph' in config) { const subgraphInConfig = config.subgraph; let getSubschemaConfig$; let subschemaConfig; function getSubschemaConfig() { return mapMaybePromise(handleUnifiedGraphConfig(subgraphInConfig, configContext), newUnifiedGraph => { unifiedGraph = newUnifiedGraph; unifiedGraph = restoreExtraDirectives(unifiedGraph); subschemaConfig = { name: getDirectiveExtensions(unifiedGraph)?.transport?.[0]?.subgraph, schema: unifiedGraph, }; const transportEntryMap = {}; const additionalTypeDefs = []; const additionalResolvers = []; const stitchingDirectivesTransformer = getStitchingDirectivesTransformerForSubschema(); const onSubgraphExecute = getOnSubgraphExecute({ onSubgraphExecuteHooks, transports: config.transports, transportContext: configContext, transportEntryMap, getSubgraphSchema() { return unifiedGraph; }, transportExecutorStack: disposableStack, }); subschemaConfig = handleFederationSubschema({ subschemaConfig, transportEntryMap, additionalTypeDefs, additionalResolvers, stitchingDirectivesTransformer, onSubgraphExecute, }); // TODO: Find better alternative later unifiedGraph = wrapSchema(subschemaConfig); unifiedGraph = mergeSchemas({ assumeValid: true, assumeValidSDL: true, schemas: [unifiedGraph], typeDefs: [ parse(/* GraphQL */ ` type Query { _entities(representations: [_Any!]!): [_Entity]! _service: _Service! } scalar _Any union _Entity = ${Object.keys(subschemaConfig.merge || {}).join(' | ')} type _Service { sdl: String } `), ], resolvers: { Query: { _entities(_root, args, context, info) { if (Array.isArray(args.representations)) { return args.representations.map(representation => { const typeName = representation.__typename; const mergeConfig = subschemaConfig.merge[typeName]; const entryPoints = mergeConfig?.entryPoints || [mergeConfig]; const satisfiedEntryPoint = entryPoints.find(entryPoint => { if (entryPoint.selectionSet) { const selectionSet = parseSelectionSet(entryPoint.selectionSet, { noLocation: true, }); return checkIfDataSatisfiesSelectionSet(selectionSet, representation); } return true; }); if (satisfiedEntryPoint) { if (satisfiedEntryPoint.key) { return mapMaybePromise(batchDelegateToSchema({ schema: subschemaConfig, fieldName: satisfiedEntryPoint.fieldName, key: satisfiedEntryPoint.key(representation), argsFromKeys: satisfiedEntryPoint.argsFromKeys, valuesFromResults: satisfiedEntryPoint.valuesFromResults, context, info, }), res => mergeDeep([representation, res])); } if (satisfiedEntryPoint.args) { return mapMaybePromise(delegateToSchema({ schema: subschemaConfig, fieldName: satisfiedEntryPoint.fieldName, args: satisfiedEntryPoint.args(representation), context, info, }), res => mergeDeep([representation, res])); } } return representation; }); } return []; }, _service() { return { sdl() { return getUnifiedGraphSDL(newUnifiedGraph); }, }; }, }, }, }); schemaChanged(unifiedGraph); return true; }); } unifiedGraphPlugin = { // @ts-expect-error PromiseLike is not compatible with Promise onRequestParse() { if (!subschemaConfig) { getSubschemaConfig$ ||= getSubschemaConfig(); return getSubschemaConfig$; } }, }; } else { let unifiedGraphFetcher; if ('supergraph' in config) { unifiedGraphFetcher = () => handleUnifiedGraphConfig(config.supergraph, configContext); } else if (('hive' in config && config.hive.endpoint) || process.env.HIVE_CDN_ENDPOINT) { const cdnEndpoint = 'hive' in config ? config.hive.endpoint : process.env.HIVE_CDN_ENDPOINT; const cdnKey = 'hive' in config ? config.hive.key : process.env.HIVE_CDN_KEY; if (!cdnKey) { throw new Error('You must provide HIVE_CDN_KEY environment variables or `key` in the hive config'); } const fetcher = createSupergraphSDLFetcher({ endpoint: cdnEndpoint, key: cdnKey, }); unifiedGraphFetcher = () => fetcher().then(({ supergraphSdl }) => supergraphSdl); } else { const errorMessage = 'You must provide a supergraph schema in the `supergraph` config or point to a supergraph file with `--supergraph` parameter or `HIVE_CDN_ENDPOINT` environment variable or `./supergraph.graphql` file'; // Falls back to `./supergraph.graphql` by default unifiedGraphFetcher = () => { try { const res$ = handleUnifiedGraphConfig('./supergraph.graphql', configContext); if ('catch' in res$ && typeof res$.catch === 'function') { return res$.catch(e => { if (e.code === 'ENOENT') { throw new Error(errorMessage); } throw e; }); } return res$; } catch (e) { if (e.code === 'ENOENT') { throw new Error(errorMessage); } throw e; } }; } const hiveToken = 'hive' in config ? config.hive.token : process.env.HIVE_REGISTRY_TOKEN; if (hiveToken) { registryPlugin = useMeshHive({ enabled: true, ...configContext, logger: configContext.logger.child('Hive'), ...('hive' in config ? config.hive : {}), token: hiveToken, }); } const unifiedGraphManager = new UnifiedGraphManager({ getUnifiedGraph: unifiedGraphFetcher, handleUnifiedGraph: opts => { // when handleUnifiedGraph is called, we're sure that the schema // _really_ changed, we can therefore confidently notify about the schema change schemaChanged(opts.unifiedGraph); return handleFederationSupergraph(opts); }, transports: config.transports, transportEntryAdditions: config.transportEntries, polling: config.polling, additionalResolvers: config.additionalResolvers, transportContext: configContext, onDelegateHooks, onSubgraphExecuteHooks, }); getSchema = () => unifiedGraphManager.getUnifiedGraph(); readinessChecker = () => mapMaybePromise(unifiedGraphManager.getUnifiedGraph(), schema => !!schema); schemaInvalidator = () => unifiedGraphManager.invalidateUnifiedGraph(); contextBuilder = base => unifiedGraphManager.getContext(base); disposableStack.use(unifiedGraphManager); subgraphInformationHTMLRenderer = async () => { const htmlParts = []; let supergraphLoadedPlace = './supergraph.graphql'; if ('hive' in config && config.hive.endpoint) { supergraphLoadedPlace = 'Hive CDN <br>' + config.hive.endpoint; } else if ('supergraph' in config) { if (typeof config.supergraph === 'function') { const fnName = config.supergraph.name || ''; supergraphLoadedPlace = `a custom loader ${fnName}`; } else if (typeof config.supergraph === 'string') { supergraphLoadedPlace = config.supergraph; } } let loaded = false; let loadError; try { // TODO: Workaround for the issue // When you go to landing page, then GraphiQL, GW stops working const schema = await getSchema(); schemaChanged(schema); loaded = true; } catch (e) { loaded = false; loadError = e; } if (loaded) { htmlParts.push(`<h3>Supergraph Status: Loaded ✅</h3>`); htmlParts.push(`<p><strong>Source: </strong> <i>${supergraphLoadedPlace}</i></p>`); htmlParts.push(`<table>`); htmlParts.push(`<tr><th>Subgraph</th><th>Transport</th><th>Location</th></tr>`); for (const subgraphName in unifiedGraphManager._transportEntryMap) { const transportEntry = unifiedGraphManager._transportEntryMap[subgraphName]; htmlParts.push(`<tr>`); htmlParts.push(`<td>${subgraphName}</td>`); htmlParts.push(`<td>${transportEntry.kind}</td>`); htmlParts.push(`<td><a href="${transportEntry.location}">${transportEntry.location}</a></td>`); htmlParts.push(`</tr>`); } htmlParts.push(`</table>`); } else { htmlParts.push(`<h3>Status: Failed ❌</h3>`); htmlParts.push(`<p><strong>Source: </strong> <i>${supergraphLoadedPlace}</i></p>`); htmlParts.push(`<h3>Error:</h3>`); htmlParts.push(`<pre>${loadError.stack}</pre>`); } return `<section class="supergraph-information">${htmlParts.join('')}</section>`; }; } const readinessCheckPlugin = useReadinessCheck({ endpoint: readinessCheckEndpoint, // @ts-expect-error PromiseLike is not compatible with Promise check: readinessChecker, }); const defaultMeshPlugin = { onFetch({ setFetchFn }) { setFetchFn(fetchAPI.fetch); }, onPluginInit({ plugins }) { onFetchHooks.splice(0, onFetchHooks.length); onSubgraphExecuteHooks.splice(0, onSubgraphExecuteHooks.length); onDelegateHooks.splice(0, onDelegateHooks.length); for (const plugin of plugins) { if (plugin.onFetch) { onFetchHooks.push(plugin.onFetch); } if (plugin.onSubgraphExecute) { onSubgraphExecuteHooks.push(plugin.onSubgraphExecute); } // @ts-expect-error For backward compatibility if (plugin.onDelegate) { // @ts-expect-error For backward compatibility onDelegateHooks.push(plugin.onDelegate); } if (isDisposable(plugin)) { disposableStack.use(plugin); } } }, }; let graphiqlOptionsOrFactory; if (config.graphiql == null || config.graphiql === true) { graphiqlOptionsOrFactory = { title: 'GraphiQL Mesh', }; } else if (config.graphiql === false) { graphiqlOptionsOrFactory = false; } else if (typeof config.graphiql === 'object') { graphiqlOptionsOrFactory = { title: 'GraphiQL Mesh', ...config.graphiql, }; } else if (typeof config.graphiql === 'function') { const userGraphiqlFactory = config.graphiql; // @ts-expect-error PromiseLike is not compatible with Promise graphiqlOptionsOrFactory = function graphiqlOptionsFactoryForMesh(...args) { const options = userGraphiqlFactory(...args); return mapMaybePromise(options, resolvedOpts => { if (resolvedOpts === false) { return false; } if (resolvedOpts === true) { return { title: 'GraphiQL Mesh', }; } return { title: 'GraphiQL Mesh', ...resolvedOpts, }; }); }; } let landingPageRenderer; if (config.landingPage == null || config.landingPage === true) { landingPageRenderer = async function meshLandingPageRenderer(opts) { return new opts.fetchAPI.Response(landingPageHtml .replace(/__GRAPHIQL_LINK__/g, opts.graphqlEndpoint) .replace(/__REQUEST_PATH__/g, opts.url.pathname) .replace(/__SUBGRAPH_HTML__/g, await subgraphInformationHTMLRenderer()), { status: 200, statusText: 'OK', headers: { 'Content-Type': 'text/html', }, }); }; } else if (typeof config.landingPage === 'function') { landingPageRenderer = config.landingPage; } else if (config.landingPage === false) { landingPageRenderer = false; } const yoga = createYoga({ fetchAPI: config.fetchAPI, logging: logger, plugins: [ defaultMeshPlugin, unifiedGraphPlugin, readinessCheckPlugin, registryPlugin, useChangingSchema(getSchema, cb => (schemaChanged = cb)), useCompleteSubscriptionsOnDispose(disposableStack), useCompleteSubscriptionsOnSchemaChange(), useRequestId(), useSubgraphExecuteDebug(configContext), useFetchDebug(configContext), ...(config.plugins?.(configContext) || []), ], // @ts-expect-error PromiseLike is not compatible with Promise context({ request, params, ...rest }) { // TODO: I dont like this cast, but it's necessary const { req, connectionParams } = rest; const baseContext = { ...configContext, request, params, headers: // Maybe Node-like environment req?.headers ? getHeadersObj(req.headers) : // Fetch environment request?.headers ? getHeadersObj(request.headers) : // Unknown environment {}, connectionParams, }; if (contextBuilder) { return contextBuilder(baseContext); } return baseContext; }, cors: config.cors, graphiql: graphiqlOptionsOrFactory, batching: config.batching, graphqlEndpoint: config.graphqlEndpoint, maskedErrors: config.maskedErrors, healthCheckEndpoint: config.healthCheckEndpoint || '/healthcheck', landingPage: landingPageRenderer, }); fetchAPI ||= yoga.fetchAPI; Object.defineProperties(yoga, { invalidateUnifiedGraph: { value: schemaInvalidator, configurable: true, }, }); return makeAsyncDisposable(yoga, () => disposableStack.disposeAsync()); }