Created
April 16, 2020 16:38
-
-
Save lorenries/3e6713d22bcf468ee14acf7f344bb5f4 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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