UNPKG

@graphql-mesh/transport-http-callback

Version:
284 lines (280 loc) • 9.5 kB
'use strict'; var crossHelpers = require('@graphql-mesh/cross-helpers'); var stringInterpolation = require('@graphql-mesh/string-interpolation'); var utils = require('@graphql-mesh/utils'); var executorCommon = require('@graphql-tools/executor-common'); var utils$1 = require('@graphql-tools/utils'); var repeater = require('@repeaterjs/repeater'); var fetch = require('@whatwg-node/fetch'); var promiseHelpers = require('@whatwg-node/promise-helpers'); function createTimeoutError() { return utils$1.createGraphQLError("Subscription timed out", { extensions: { code: "TIMEOUT_ERROR", http: { status: 504 } } }); } var index = { getSubgraphExecutor({ transportEntry, fetch: fetch$1, pubsub, logger }) { let headersInConfig; if (typeof transportEntry.headers === "string") { headersInConfig = JSON.parse(transportEntry.headers); } if (Array.isArray(transportEntry.headers)) { headersInConfig = Object.fromEntries(transportEntry.headers); } const headersFactory = stringInterpolation.getInterpolatedHeadersFactory(headersInConfig); const verifier = fetch.crypto.randomUUID(); if (!pubsub) { throw new Error(`HTTP Callback Transport: You must provide a pubsub instance to http-callbacks transport! Example: import { PubSub } from '@graphql-hive/gateway' export const gatewayConfig = defineConfig({ pubsub: new PubSub(), }) See documentation: https://graphql-hive.com/docs/gateway/pubsub`); } const reqAbortCtrls = /* @__PURE__ */ new Set(); const heartbeats = /* @__PURE__ */ new Map(); const stopFnSet = /* @__PURE__ */ new Set(); const publicUrl = transportEntry.options?.public_url || "http://localhost:4000"; const callbackPath = transportEntry.options?.path || "/callback"; const heartbeatIntervalMs = transportEntry.options?.heartbeat_interval || 5e4; const httpCallbackExecutor = function httpCallbackExecutor2(executionRequest) { const subscriptionId = fetch.crypto.randomUUID(); const subscriptionLogger = logger?.child({ executor: "http-callback", subscription: subscriptionId }); const callbackUrl = `${publicUrl}${callbackPath}/${subscriptionId}`; const subscriptionCallbackPath = `${callbackPath}/${subscriptionId}`; const serializedParams = executorCommon.serializeExecutionRequest({ executionRequest }); const fetchBody = JSON.stringify({ ...serializedParams, extensions: { ...serializedParams || {}, subscription: { callbackUrl, subscriptionId, verifier, heartbeatIntervalMs } } }); let stopSubscription = (error) => { if (error) { throw error; } }; heartbeats.set( subscriptionId, setTimeout(() => { stopSubscription(createTimeoutError()); }, heartbeatIntervalMs) ); subscriptionLogger?.debug( `Subscribing to ${transportEntry.location} with callbackUrl: ${callbackUrl}` ); let pushFn = () => { throw new Error( "HTTP Callback Transport: Subgraph does not look like configured correctly. Check your subgraph setup." ); }; const reqAbortCtrl = new AbortController(); if (!fetch$1) { throw new Error( "HTTP Callback Transport: `fetch` implementation is missing!" ); } if (!transportEntry.location) { throw new Error( `HTTP Callback Transport: \`location\` is missing in the transport entry!` ); } let signal = executionRequest.signal || executionRequest.info?.signal; if (signal) { signal = AbortSignal.any([reqAbortCtrl.signal, signal]); } const subFetchCall$ = promiseHelpers.handleMaybePromise( () => fetch$1( transportEntry.location, { method: "POST", headers: { "Content-Type": "application/json", ...headersFactory({ env: crossHelpers.process.env, root: executionRequest.rootValue, context: executionRequest.context, info: executionRequest.info }), Accept: "application/json;callbackSpec=1.0; charset=utf-8" }, body: fetchBody, signal }, executionRequest.context, executionRequest.info ), (res) => promiseHelpers.handleMaybePromise( () => res.text(), (resText) => { let resJson; try { resJson = JSON.parse(resText); } catch (e) { if (!res.ok) { stopSubscription( new Error( `Subscription request failed with an HTTP Error: ${res.status} ${resText}` ) ); } else { stopSubscription(e); } return; } logger?.debug(`Subscription request received`, resJson); if (resJson.errors) { if (resJson.errors.length === 1 && resJson.errors[0]) { const error = resJson.errors[0]; stopSubscription(utils$1.createGraphQLError(error.message, error)); } else { stopSubscription( new AggregateError( resJson.errors.map( (err) => utils$1.createGraphQLError(err.message, err) ), resJson.errors.map((err) => err.message).join("\n") ) ); } } else if (resJson.data != null) { pushFn(resJson.data); stopSubscription(); } } ), (e) => { logger?.debug(`Subscription request failed`, e); stopSubscription(e); } ); executionRequest.context?.waitUntil?.(subFetchCall$); return new repeater.Repeater((push, stop) => { if (signal) { if (signal.aborted) { stop(signal?.reason); return; } signal.addEventListener( "abort", () => { stop(signal?.reason); }, { once: true } ); } pushFn = push; stopSubscription = stop; stopFnSet.add(stop); logger?.debug(`Listening to ${subscriptionCallbackPath}`); const subId = pubsub.subscribe( `webhook:post:${subscriptionCallbackPath}`, (message) => { logger?.debug( `Received message from ${subscriptionCallbackPath}`, message ); if (message.verifier !== verifier) { return; } const existingHeartbeat = heartbeats.get(subscriptionId); if (existingHeartbeat) { clearTimeout(existingHeartbeat); } heartbeats.set( subscriptionId, setTimeout(() => { stopSubscription(createTimeoutError()); }, heartbeatIntervalMs) ); switch (message.action) { case "check": break; case "next": push(message.payload); break; case "complete": if (message.errors) { if (message.errors.length === 1 && message.errors[0]) { const error = message.errors[0]; stopSubscription( utils$1.createGraphQLError(error.message, { ...error, extensions: { ...error.extensions, code: "DOWNSTREAM_SERVICE_ERROR" } }) ); } else { stopSubscription( new AggregateError( message.errors.map( (err) => utils$1.createGraphQLError(err.message, { ...err, extensions: { ...err.extensions, code: "DOWNSTREAM_SERVICE_ERROR" } }) ) ) ); } } else { stopSubscription(); } break; } } ); stop.finally(() => { pubsub.unsubscribe(subId); clearTimeout(heartbeats.get(subscriptionId)); heartbeats.delete(subscriptionId); stopFnSet.delete(stop); if (!reqAbortCtrl.signal.aborted) { reqAbortCtrl.abort(); } }); }); }; function disposeFn() { for (const stop of stopFnSet) { stop(); } for (const interval of heartbeats.values()) { clearTimeout(interval); } for (const ctrl of reqAbortCtrls) { if (!ctrl.signal.aborted) { ctrl.abort(); } } } return utils.makeDisposable(httpCallbackExecutor, disposeFn); } }; module.exports = index;