@graphql-tools/executor-http
Version:
A set of utils for faster development of GraphQL tools
882 lines (839 loc) • 26.8 kB
JavaScript
import { defaultPrintFn, serializeExecutionRequest } from '@graphql-tools/executor-common';
import { isAsyncIterable, createGraphQLError, inspect, mapAsyncIterator, mergeIncrementalResult, memoize1, getOperationASTFromRequest } from '@graphql-tools/utils';
import { DisposableSymbols } from '@whatwg-node/disposablestack';
import { FormData, File as File$1, TextEncoder, crypto, TextDecoder, fetch } from '@whatwg-node/fetch';
import { isPromise, handleMaybePromise } from '@whatwg-node/promise-helpers';
import { Repeater } from '@repeaterjs/repeater';
import { GraphQLError } from 'graphql';
import { meros as meros$1 } from 'meros/browser';
import { meros } from 'meros/node';
function isPlainObject(value) {
if (typeof value !== 'object' || value === null) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(Symbol.toStringTag in value) && !(Symbol.iterator in value);
}
// @ts-check
/** @typedef {import("./isExtractableFile.mjs").default} isExtractableFile */
/**
* Recursively extracts files and their {@link ObjectPath object paths} within a
* value, replacing them with `null` in a deep clone without mutating the
* original value.
* [`FileList`](https://developer.mozilla.org/en-US/docs/Web/API/Filelist)
* instances are treated as
* [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) instance
* arrays.
* @template Extractable Extractable file type.
* @param {unknown} value Value to extract files from. Typically an object tree.
* @param {(value: unknown) => value is Extractable} isExtractable Matches
* extractable files. Typically {@linkcode isExtractableFile}.
* @param {ObjectPath} [path] Prefix for object paths for extracted files.
* Defaults to `""`.
* @returns {Extraction<Extractable>} Extraction result.
* @example
* Extracting files from an object.
*
* For the following:
*
* ```js
* import extractFiles from "extract-files/extractFiles.mjs";
* import isExtractableFile from "extract-files/isExtractableFile.mjs";
*
* const file1 = new File(["1"], "1.txt", { type: "text/plain" });
* const file2 = new File(["2"], "2.txt", { type: "text/plain" });
* const value = {
* a: file1,
* b: [file1, file2],
* };
*
* const { clone, files } = extractFiles(value, isExtractableFile, "prefix");
* ```
*
* `value` remains the same.
*
* `clone` is:
*
* ```json
* {
* "a": null,
* "b": [null, null]
* }
* ```
*
* `files` is a
* [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map)
* instance containing:
*
* | Key | Value |
* | :------ | :--------------------------- |
* | `file1` | `["prefix.a", "prefix.b.0"]` |
* | `file2` | `["prefix.b.1"]` |
*/
function extractFiles(value, isExtractable, path = "") {
if (!arguments.length) throw new TypeError("Argument 1 `value` is required.");
if (typeof isExtractable !== "function")
throw new TypeError("Argument 2 `isExtractable` must be a function.");
if (typeof path !== "string")
throw new TypeError("Argument 3 `path` must be a string.");
/**
* Deeply clonable value.
* @typedef {Array<unknown> | FileList | {
* [key: PropertyKey]: unknown
* }} Cloneable
*/
/**
* Clone of a {@link Cloneable deeply cloneable value}.
* @typedef {Exclude<Cloneable, FileList>} Clone
*/
/**
* Map of values recursed within the input value and their clones, for reusing
* clones of values that are referenced multiple times within the input value.
* @type {Map<Cloneable, Clone>}
*/
const clones = new Map();
/**
* Extracted files and their object paths within the input value.
* @type {Extraction<Extractable>["files"]}
*/
const files = new Map();
/**
* Recursively clones the value, extracting files.
* @param {unknown} value Value to extract files from.
* @param {ObjectPath} path Prefix for object paths for extracted files.
* @param {Set<Cloneable>} recursed Recursed values for avoiding infinite
* recursion of circular references within the input value.
* @returns {unknown} Clone of the value with files replaced with `null`.
*/
function recurse(value, path, recursed) {
if (isExtractable(value)) {
const filePaths = files.get(value);
filePaths ? filePaths.push(path) : files.set(value, [path]);
return null;
}
const valueIsList =
Array.isArray(value) ||
(typeof FileList !== "undefined" && value instanceof FileList);
const valueIsPlainObject = isPlainObject(value);
if (valueIsList || valueIsPlainObject) {
let clone = clones.get(value);
const uncloned = !clone;
if (uncloned) {
clone = valueIsList
? []
: // Replicate if the plain object is an `Object` instance.
value instanceof /** @type {any} */ (Object)
? {}
: Object.create(null);
clones.set(value, /** @type {Clone} */ (clone));
}
if (!recursed.has(value)) {
const pathPrefix = path ? `${path}.` : "";
const recursedDeeper = new Set(recursed).add(value);
if (valueIsList) {
let index = 0;
for (const item of value) {
const itemClone = recurse(
item,
pathPrefix + index++,
recursedDeeper
);
if (uncloned) /** @type {Array<unknown>} */ (clone).push(itemClone);
}
} else
for (const key in value) {
const propertyClone = recurse(
value[key],
pathPrefix + key,
recursedDeeper
);
if (uncloned)
/** @type {{ [key: PropertyKey]: unknown }} */ (clone)[key] =
propertyClone;
}
}
return clone;
}
return value;
}
return {
clone: recurse(value, path, new Set()),
files,
};
}
/**
* An extraction result.
* @template [Extractable=unknown] Extractable file type.
* @typedef {object} Extraction
* @prop {unknown} clone Clone of the original value with extracted files
* recursively replaced with `null`.
* @prop {Map<Extractable, Array<ObjectPath>>} files Extracted files and their
* object paths within the original value.
*/
/**
* String notation for the path to a node in an object tree.
* @typedef {string} ObjectPath
* @see [`object-path` on npm](https://npm.im/object-path).
* @example
* An object path for object property `a`, array index `0`, object property `b`:
*
* ```
* a.0.b
* ```
*/
// @ts-check
/**
* Checks if a value is an {@link ExtractableFile extractable file}.
* @param {unknown} value Value to check.
* @returns {value is ExtractableFile} Is the value an
* {@link ExtractableFile extractable file}.
*/
function isExtractableFile(value) {
return (
(typeof File !== "undefined" && value instanceof File) ||
(typeof Blob !== "undefined" && value instanceof Blob)
);
}
/**
* An extractable file.
* @typedef {File | Blob} ExtractableFile
*/
function isGraphQLUpload(upload) {
return typeof upload.createReadStream === "function";
}
function collectAsyncIterableValues(asyncIterable) {
const values = [];
const iterator = asyncIterable[Symbol.asyncIterator]();
function iterate() {
return handleMaybePromise(
() => iterator.next(),
({ value, done }) => {
if (value != null) {
values.push(value);
}
if (done) {
return values;
}
return iterate();
}
);
}
return iterate();
}
function createFormDataFromVariables(body, {
File: FileCtor = File$1,
FormData: FormDataCtor = FormData
}) {
if (!body.variables) {
return JSON.stringify(body);
}
const vars = Object.assign({}, body.variables);
const { clone, files } = extractFiles(
vars,
(v) => isExtractableFile(v) || v?.promise || isAsyncIterable(v) || v?.then || typeof v?.arrayBuffer === "function",
"variables"
);
if (files.size === 0) {
return JSON.stringify(body);
}
const map = {};
const uploads = [];
let currIndex = 0;
for (const [file, curr] of files) {
map[currIndex] = curr;
uploads[currIndex] = file;
currIndex++;
}
const form = new FormDataCtor();
form.append(
"operations",
JSON.stringify({
...body,
variables: clone
})
);
form.append("map", JSON.stringify(map));
function handleUpload(upload, i) {
const indexStr = i.toString();
if (upload != null) {
return handleMaybePromise(
() => upload?.promise || upload,
(upload2) => {
const filename = upload2.filename || upload2.name || upload2.path || `blob-${indexStr}`;
if (isBlob(upload2)) {
form.append(indexStr, upload2, filename);
} else if (isAsyncIterable(upload2)) {
return handleMaybePromise(
() => collectAsyncIterableValues(upload2),
(chunks) => {
const blobPart = new Uint8Array(chunks);
form.append(
indexStr,
new FileCtor([blobPart], filename),
filename
);
}
);
} else if (isGraphQLUpload(upload2)) {
return handleMaybePromise(
() => collectAsyncIterableValues(upload2.createReadStream()),
(chunks) => {
const blobPart = new Uint8Array(chunks);
form.append(
indexStr,
new FileCtor([blobPart], filename, { type: upload2.mimetype }),
filename
);
}
);
} else {
form.append(indexStr, new FileCtor([upload2], filename), filename);
}
}
);
}
}
const jobs = [];
for (const i in uploads) {
const upload = uploads[i];
const job = handleUpload(upload, Number(i));
if (isPromise(job)) {
jobs.push(job);
}
}
if (jobs.length > 0) {
return Promise.all(jobs).then(() => form);
}
return form;
}
function isBlob(obj) {
return typeof obj.arrayBuffer === "function";
}
function createGraphQLErrorForAbort(reason, extensions) {
if (reason instanceof GraphQLError) {
return reason;
}
if (reason?.name === "TimeoutError") {
return createGraphQLError(reason.message, {
extensions: {
http: {
status: 504,
...extensions?.["http"] || {}
},
code: "TIMEOUT_ERROR",
...extensions || {}
},
originalError: reason
});
}
return createGraphQLError(reason.message, {
extensions,
originalError: reason
});
}
function createResultForAbort(reason, extensions) {
return {
errors: [createGraphQLErrorForAbort(reason, extensions)]
};
}
function hashSHA256(str) {
const textEncoder = new TextEncoder();
const utf8 = textEncoder.encode(str);
return handleMaybePromise(
() => crypto.subtle.digest("SHA-256", utf8),
(hashBuffer) => {
let hashHex = "";
for (const bytes of new Uint8Array(hashBuffer)) {
hashHex += bytes.toString(16).padStart(2, "0");
}
return hashHex;
}
);
}
const DELIM = "\n\n";
function isReadableStream(value) {
return value && typeof value.getReader === "function";
}
function handleEventStreamResponse(response, subscriptionCtrl, signal) {
const body = response.body;
if (!isReadableStream(body)) {
throw new Error(
"Response body is expected to be a readable stream but got; " + inspect(body)
);
}
return new Repeater((push, stop) => {
const decoder = new TextDecoder();
const reader = body.getReader();
reader.closed.then(stop).catch(stop);
stop.then(() => {
subscriptionCtrl?.abort();
if (body.locked) {
reader.releaseLock();
}
}).catch((err) => {
reader.cancel(err);
});
let currChunk = "";
async function pump() {
if (signal?.aborted) {
await push(createResultForAbort(signal.reason));
return stop();
}
if (!body?.locked) {
return stop();
}
const { done, value: chunk } = await reader.read();
if (done) {
return stop();
}
currChunk += typeof chunk === "string" ? chunk : decoder.decode(chunk);
for (; ; ) {
const delimIndex = currChunk.indexOf(DELIM);
if (delimIndex === -1) {
break;
}
const msg = currChunk.slice(0, delimIndex);
currChunk = currChunk.slice(delimIndex + DELIM.length);
const dataStr = msg.split("data:")[1]?.trim();
if (dataStr) {
const data = JSON.parse(dataStr);
await push(data.payload || data);
}
const event = msg.split("event:")[1]?.trim();
if (event === "complete") {
return stop();
}
}
return pump();
}
return pump();
});
}
function isIncomingMessage(body) {
return body != null && typeof body === "object" && "pipe" in body;
}
async function handleMultipartMixedResponse(response) {
const body = response.body;
const contentType = response.headers.get("content-type") || "";
let asyncIterator;
if (isIncomingMessage(body)) {
body.headers = {
"content-type": contentType
};
const result = await meros(body);
if ("next" in result) {
asyncIterator = result;
}
} else {
const result = await meros$1(response);
if ("next" in result) {
asyncIterator = result;
}
}
const executionResult = {};
if (asyncIterator == null) {
return executionResult;
}
const resultStream = mapAsyncIterator(asyncIterator, (part) => {
if (part.json) {
const incrementalResult = part.body;
mergeIncrementalResult({
incrementalResult,
executionResult
});
}
return executionResult;
});
return resultStream;
}
const isLiveQueryOperationDefinitionNode = memoize1(
function isLiveQueryOperationDefinitionNode2(node) {
return node.operation === "query" && node.directives?.some((directive) => directive.name.value === "live");
}
);
function prepareGETUrl({
baseUrl = "",
body
}) {
const dummyHostname = "https://dummyhostname.com";
const validUrl = baseUrl.startsWith("http") ? baseUrl : baseUrl?.startsWith("/") ? `${dummyHostname}${baseUrl}` : `${dummyHostname}/${baseUrl}`;
const urlObj = new URL(validUrl);
if (body.query) {
urlObj.searchParams.set("query", body.query);
}
if (body.variables && Object.keys(body.variables).length > 0) {
urlObj.searchParams.set("variables", JSON.stringify(body.variables));
}
if (body.operationName) {
urlObj.searchParams.set("operationName", body.operationName);
}
if (body.extensions) {
urlObj.searchParams.set("extensions", JSON.stringify(body.extensions));
}
const finalUrl = urlObj.toString().replace(dummyHostname, "");
return finalUrl;
}
function buildHTTPExecutor(options) {
const printFn = options?.print ?? defaultPrintFn;
const disposeCtrl = new AbortController();
const serviceName = options?.serviceName;
const baseExecutor = (request, excludeQuery) => {
if (disposeCtrl.signal.aborted) {
return createResultForAbort(disposeCtrl.signal.reason);
}
const fetchFn = request.extensions?.fetch ?? options?.fetch ?? fetch;
let method = request.extensions?.method || options?.method;
const operationAst = getOperationASTFromRequest(request);
const operationType = operationAst.operation;
if ((options?.useGETForQueries || request.extensions?.useGETForQueries) && operationType === "query") {
method = "GET";
}
let accept = "application/graphql-response+json, application/json, multipart/mixed";
let subscriptionCtrl;
if (operationType === "subscription" || isLiveQueryOperationDefinitionNode(operationAst)) {
method ||= "GET";
accept = "text/event-stream";
subscriptionCtrl = new AbortController();
} else {
method ||= "POST";
}
const endpoint = request.extensions?.endpoint || options?.endpoint || "/graphql";
const headers = { accept };
if (options?.headers) {
Object.assign(
headers,
typeof options?.headers === "function" ? options.headers(request) : options?.headers
);
}
if (request.extensions?.headers) {
const { headers: headersFromExtensions, ...restExtensions } = request.extensions;
Object.assign(headers, headersFromExtensions);
request.extensions = restExtensions;
}
const signals = [disposeCtrl.signal];
const signalFromRequest = request.signal || request.info?.signal;
if (signalFromRequest) {
if (signalFromRequest.aborted) {
return createResultForAbort(signalFromRequest.reason);
}
signals.push(signalFromRequest);
}
if (options?.timeout) {
signals.push(AbortSignal.timeout(options.timeout));
}
if (subscriptionCtrl) {
signals.push(subscriptionCtrl.signal);
}
const signal = AbortSignal.any(signals);
const upstreamErrorExtensions = {
serviceName,
request: {
method
}
};
const query = printFn(request.document);
let serializeFn = function serialize() {
return serializeExecutionRequest({
executionRequest: request,
excludeQuery,
printFn
});
};
if (options?.apq) {
serializeFn = function serializeWithAPQ() {
return handleMaybePromise(
() => hashSHA256(query),
(sha256Hash) => {
const extensions = request.extensions || {};
extensions["persistedQuery"] = {
version: 1,
sha256Hash
};
return serializeExecutionRequest({
executionRequest: {
...request,
extensions
},
excludeQuery,
printFn
});
}
);
};
}
function handleError(e) {
if (e.name === "AggregateError") {
return {
errors: e.errors.map(
(e2) => coerceFetchError(e2, {
signal,
endpoint,
upstreamErrorExtensions
})
)
};
}
return {
errors: [
coerceFetchError(e, {
signal,
endpoint,
upstreamErrorExtensions
})
]
};
}
return handleMaybePromise(
() => serializeFn(),
(body) => handleMaybePromise(
() => {
switch (method) {
case "GET": {
const finalUrl = prepareGETUrl({
baseUrl: endpoint,
body
});
const fetchOptions = {
method: "GET",
headers,
signal
};
if (options?.credentials != null) {
fetchOptions.credentials = options.credentials;
}
upstreamErrorExtensions.request.url = finalUrl;
return fetchFn(
finalUrl,
fetchOptions,
request.context,
request.info
);
}
case "POST": {
upstreamErrorExtensions.request.body = body;
return handleMaybePromise(
() => createFormDataFromVariables(body, {
File: options?.File,
FormData: options?.FormData
}),
(body2) => {
if (typeof body2 === "string" && !headers["content-type"]) {
upstreamErrorExtensions.request.body = body2;
headers["content-type"] = "application/json";
}
const fetchOptions = {
method: "POST",
body: body2,
headers,
signal
};
if (options?.credentials != null) {
fetchOptions.credentials = options.credentials;
}
return fetchFn(
endpoint,
fetchOptions,
request.context,
request.info
);
},
handleError
);
}
}
},
(fetchResult) => handleMaybePromise(
() => {
upstreamErrorExtensions.response ||= {};
upstreamErrorExtensions.response.status = fetchResult.status;
upstreamErrorExtensions.response.statusText = fetchResult.statusText;
Object.defineProperty(
upstreamErrorExtensions.response,
"headers",
{
get() {
return Object.fromEntries(fetchResult.headers.entries());
}
}
);
if (options?.retry != null && !fetchResult.status.toString().startsWith("2")) {
throw new Error(
fetchResult.statusText || `Upstream HTTP Error: ${fetchResult.status}`
);
}
const contentType = fetchResult.headers.get("content-type");
if (contentType?.includes("text/event-stream")) {
return handleEventStreamResponse(
fetchResult,
subscriptionCtrl,
signal
);
} else if (contentType?.includes("multipart/mixed")) {
return handleMultipartMixedResponse(fetchResult);
}
return fetchResult.text();
},
(result) => {
if (typeof result === "string") {
upstreamErrorExtensions.response ||= {};
upstreamErrorExtensions.response.body = result;
if (result) {
try {
const parsedResult = JSON.parse(result);
upstreamErrorExtensions.response.body = parsedResult;
if (parsedResult.data == null && (parsedResult.errors == null || parsedResult.errors.length === 0)) {
return {
errors: [
createGraphQLError(
'Unexpected empty "data" and "errors" fields in result: ' + result,
{
extensions: upstreamErrorExtensions
}
)
]
};
}
if (Array.isArray(parsedResult.errors)) {
return {
...parsedResult,
errors: parsedResult.errors.map(
({
message,
...options2
}) => createGraphQLError(message, {
...options2,
extensions: {
code: "DOWNSTREAM_SERVICE_ERROR",
serviceName,
...options2.extensions || {}
}
})
)
};
}
return parsedResult;
} catch (e) {
return {
errors: [
createGraphQLError(
`Unexpected response: ${JSON.stringify(result)}`,
{
extensions: upstreamErrorExtensions,
originalError: e
}
)
]
};
}
}
} else {
return result;
}
},
handleError
),
handleError
),
handleError
);
};
let executor = baseExecutor;
if (options?.apq != null) {
executor = function apqExecutor(request) {
return handleMaybePromise(
() => baseExecutor(request, true),
(res) => {
if (res.errors?.some(
(error) => error.extensions["code"] === "PERSISTED_QUERY_NOT_FOUND" || error.message === "PersistedQueryNotFound"
)) {
return baseExecutor(request, false);
}
return res;
}
);
};
}
if (options?.retry != null) {
const prevExecutor = executor;
executor = function retryExecutor(request) {
let result;
let attempt = 0;
function retryAttempt() {
if (disposeCtrl.signal.aborted) {
return createResultForAbort(disposeCtrl.signal.reason);
}
attempt++;
if (attempt > options.retry) {
if (result != null) {
return result;
}
return {
errors: [createGraphQLError("No response returned from fetch")]
};
}
return handleMaybePromise(
() => prevExecutor(request),
(res) => {
result = res;
if (result?.errors?.length) {
return retryAttempt();
}
return result;
}
);
}
return retryAttempt();
};
}
Object.defineProperties(executor, {
[DisposableSymbols.dispose]: {
get() {
return function dispose() {
return disposeCtrl.abort(options?.getDisposeReason?.());
};
}
},
[DisposableSymbols.asyncDispose]: {
get() {
return function asyncDispose() {
return disposeCtrl.abort(options?.getDisposeReason?.());
};
}
}
});
return executor;
}
function coerceFetchError(e, {
signal,
endpoint,
upstreamErrorExtensions
}) {
if (typeof e === "string") {
return createGraphQLError(e, {
extensions: upstreamErrorExtensions
});
} else if (e.name === "GraphQLError") {
return e;
} else if (e.name === "TypeError" && e.message === "fetch failed") {
return createGraphQLError(`fetch failed to ${endpoint}`, {
extensions: upstreamErrorExtensions,
originalError: e
});
} else if (e.name === "AbortError") {
return createGraphQLErrorForAbort(
signal?.reason || e,
upstreamErrorExtensions
);
} else if (e.message) {
return createGraphQLError(e.message, {
extensions: upstreamErrorExtensions,
originalError: e
});
} else {
return createGraphQLError("Unknown error", {
extensions: upstreamErrorExtensions,
originalError: e
});
}
}
export { buildHTTPExecutor, isLiveQueryOperationDefinitionNode };