Skip to content

Instantly share code, notes, and snippets.

@lorenries
Created April 16, 2020 16:38
Show Gist options
  • Save lorenries/3e6713d22bcf468ee14acf7f344bb5f4 to your computer and use it in GitHub Desktop.
Save lorenries/3e6713d22bcf468ee14acf7f344bb5f4 to your computer and use it in GitHub Desktop.
import { Kind, DocumentNode, OperationDefinitionNode, print } from "graphql";
import { filter, make, merge, mergeMap, pipe, share, takeUntil } from "wonka";
import {
Exchange,
Operation,
OperationResult,
makeResult,
makeErrorResult,
} from "@urql/core";
import sha256 from "hash.js/lib/hash/sha/256";
interface Body {
query: string;
variables: void | object;
extensions?: object;
operationName?: string;
}
enum QueryStatus {
PersistedQueryNotFound = "PersistedQueryNotFound",
PersistedQueryNotSupported = "PersistedQueryNotSupported",
Other = "Other",
}
function createHash(input: string) {
return sha256().update(input).digest("hex");
}
function getQueryStatus(result?: OperationResult): QueryStatus {
if (result && result.error) {
if (
result.error.graphQLErrors.some(
(x) => x.message === "PersistedQueryNotFound"
)
) {
return QueryStatus.PersistedQueryNotFound;
}
if (
result.error.graphQLErrors.some(
(x) => x.message === "PersistedQueryNotSupported"
)
) {
return QueryStatus.PersistedQueryNotSupported;
}
}
return QueryStatus.Other;
}
function convertToGet(url: string, body: Body): string {
const queryParams: string[] = [];
if (body.query) {
queryParams.push(`query=${encodeURIComponent(body.query)}`);
}
if (body.variables) {
queryParams.push(
`variables=${encodeURIComponent(JSON.stringify(body.variables))}`
);
}
if (body.extensions) {
queryParams.push(
`extensions=${encodeURIComponent(JSON.stringify(body.extensions))}`
);
}
return url + "?" + queryParams.join("&");
}
const getOperationName = (query: DocumentNode): string | null => {
const node = query.definitions.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(node: any): node is OperationDefinitionNode => {
return node.kind === Kind.OPERATION_DEFINITION && node.name;
}
);
return node ? node.name.value : null;
};
const executeFetch = async (
operation: Operation,
abortController: AbortController | undefined,
canUseGet = true
): Promise<OperationResult> => {
const { url, fetch: fetcher, preferGetMethod } = operation.context;
let statusNotOk = false;
let response: Response;
const useGet = preferGetMethod && canUseGet;
const extraOptions =
typeof operation.context.fetchOptions === "function"
? operation.context.fetchOptions()
: operation.context.fetchOptions || {};
const operationName = getOperationName(operation.query);
const queryString = print(operation.query);
const body: Body = {
query: useGet ? undefined : queryString,
variables: operation.variables,
extensions: useGet
? {
persistedQuery: {
version: 1,
sha256hash: createHash(queryString),
},
}
: undefined,
};
if (operationName !== null) {
body.operationName = operationName;
}
const fetchOptions: RequestInit = {
...extraOptions,
body: useGet ? undefined : JSON.stringify(body),
method: useGet ? "GET" : "POST",
headers: {
"content-type": "application/json",
...extraOptions.headers,
},
signal: abortController !== undefined ? abortController.signal : undefined,
};
const fetchUrl = useGet ? convertToGet(url, body) : url;
try {
const res: Response = await (fetcher || fetch)(fetchUrl, fetchOptions);
response = res;
statusNotOk =
res.status < 200 ||
res.status >= (fetchOptions.redirect === "manual" ? 400 : 300);
const data = await res.json();
const result = makeResult(operation, data, response);
if (!("data" in data) && !("errors" in data)) {
throw new Error("No Content");
}
const queryStatus = getQueryStatus(result);
switch (queryStatus) {
case QueryStatus.PersistedQueryNotSupported:
// Re-run the request, but this time include the query text
return await executeFetch(operation, abortController, false);
case QueryStatus.PersistedQueryNotFound:
// Re-run the request, but this time include the query text
return await executeFetch(operation, abortController, false);
case QueryStatus.Other:
break;
}
return result;
} catch (err) {
if (err.name !== "AbortError") {
return makeErrorResult(
operation,
statusNotOk ? new Error(response.statusText) : err,
response
);
}
}
};
const createFetchSource = (operation: Operation) => {
if (
process.env.NODE_ENV !== "production" &&
(operation.operationName === "subscription" ||
operation.operationName === "mutation")
) {
throw new Error(
`Received a ${operation.operationName} operation in the persistedQueryExchange.`
);
}
return make<OperationResult>(({ next, complete }) => {
const abortController =
typeof AbortController !== "undefined"
? new AbortController()
: undefined;
let ended = false;
Promise.resolve()
.then(() =>
ended ? undefined : executeFetch(operation, abortController)
)
.then((result: OperationResult | undefined) => {
if (!ended) {
ended = true;
if (result) next(result);
complete();
}
});
return () => {
ended = true;
if (abortController !== undefined) {
abortController.abort();
}
};
});
};
export const persistedFetchExchange: Exchange = ({ forward }) => {
return (ops$) => {
const sharedOps$ = share(ops$);
const fetchResults$ = pipe(
sharedOps$,
filter((operation) => operation.operationName === "query"),
mergeMap((operation) => {
return pipe(
createFetchSource(operation),
takeUntil(
pipe(
sharedOps$,
filter(
(op) =>
op.operationName === "teardown" && op.key === operation.key
)
)
)
);
})
);
const forward$ = pipe(
sharedOps$,
filter((operation) => operation.operationName !== "query"),
forward
);
return merge([fetchResults$, forward$]);
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment