GQLClient.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import {
  2. ref,
  3. reactive,
  4. Ref,
  5. unref,
  6. watchEffect,
  7. watchSyncEffect,
  8. WatchStopHandle,
  9. set,
  10. isRef,
  11. } from "@nuxtjs/composition-api"
  12. import {
  13. createClient,
  14. TypedDocumentNode,
  15. OperationResult,
  16. dedupExchange,
  17. OperationContext,
  18. fetchExchange,
  19. makeOperation,
  20. GraphQLRequest,
  21. createRequest,
  22. subscriptionExchange,
  23. } from "@urql/core"
  24. import { authExchange } from "@urql/exchange-auth"
  25. import { offlineExchange } from "@urql/exchange-graphcache"
  26. import { makeDefaultStorage } from "@urql/exchange-graphcache/default-storage"
  27. import { devtoolsExchange } from "@urql/devtools"
  28. import { SubscriptionClient } from "subscriptions-transport-ws"
  29. import * as E from "fp-ts/Either"
  30. import * as TE from "fp-ts/TaskEither"
  31. import { pipe, constVoid } from "fp-ts/function"
  32. import { Source, subscribe, pipe as wonkaPipe, onEnd } from "wonka"
  33. import { keyDefs } from "./caching/keys"
  34. import { optimisticDefs } from "./caching/optimistic"
  35. import { updatesDef } from "./caching/updates"
  36. import { resolversDef } from "./caching/resolvers"
  37. import schema from "./backend-schema.json"
  38. import {
  39. authIdToken$,
  40. getAuthIDToken,
  41. probableUser$,
  42. waitProbableLoginToConfirm,
  43. } from "~/helpers/fb/auth"
  44. const BACKEND_GQL_URL =
  45. process.env.context === "production"
  46. ? "https://api.hoppscotch.io/graphql"
  47. : "https://api.hoppscotch.io/graphql"
  48. const storage = makeDefaultStorage({
  49. idbName: "hoppcache-v1",
  50. maxAge: 7,
  51. })
  52. const subscriptionClient = new SubscriptionClient(
  53. process.env.context === "production"
  54. ? "wss://api.hoppscotch.io/graphql"
  55. : "wss://api.hoppscotch.io/graphql",
  56. {
  57. reconnect: true,
  58. connectionParams: () => {
  59. return {
  60. authorization: `Bearer ${authIdToken$.value}`,
  61. }
  62. },
  63. }
  64. )
  65. authIdToken$.subscribe(() => {
  66. subscriptionClient.client?.close()
  67. })
  68. const createHoppClient = () =>
  69. createClient({
  70. url: BACKEND_GQL_URL,
  71. exchanges: [
  72. devtoolsExchange,
  73. dedupExchange,
  74. offlineExchange({
  75. schema: schema as any,
  76. keys: keyDefs,
  77. optimistic: optimisticDefs,
  78. updates: updatesDef,
  79. resolvers: resolversDef,
  80. storage,
  81. }),
  82. authExchange({
  83. addAuthToOperation({ authState, operation }) {
  84. if (!authState || !authState.authToken) {
  85. return operation
  86. }
  87. const fetchOptions =
  88. typeof operation.context.fetchOptions === "function"
  89. ? operation.context.fetchOptions()
  90. : operation.context.fetchOptions || {}
  91. return makeOperation(operation.kind, operation, {
  92. ...operation.context,
  93. fetchOptions: {
  94. ...fetchOptions,
  95. headers: {
  96. ...fetchOptions.headers,
  97. Authorization: `Bearer ${authState.authToken}`,
  98. },
  99. },
  100. })
  101. },
  102. willAuthError({ authState }) {
  103. return !authState || !authState.authToken
  104. },
  105. getAuth: async () => {
  106. if (!probableUser$.value) return { authToken: null }
  107. await waitProbableLoginToConfirm()
  108. return {
  109. authToken: getAuthIDToken(),
  110. }
  111. },
  112. }),
  113. fetchExchange,
  114. subscriptionExchange({
  115. forwardSubscription: (operation) =>
  116. // @ts-expect-error: An issue with the Urql typing
  117. subscriptionClient.request(operation),
  118. }),
  119. ],
  120. })
  121. export const client = ref(createHoppClient())
  122. authIdToken$.subscribe(() => {
  123. client.value = createHoppClient()
  124. })
  125. type MaybeRef<X> = X | Ref<X>
  126. type UseQueryOptions<T = any, V = object> = {
  127. query: TypedDocumentNode<T, V>
  128. variables?: MaybeRef<V>
  129. updateSubs?: MaybeRef<GraphQLRequest<any, object>[]>
  130. defer?: boolean
  131. pollDuration?: number | undefined
  132. }
  133. /**
  134. * A wrapper type for defining errors possible in a GQL operation
  135. */
  136. export type GQLError<T extends string> =
  137. | {
  138. type: "network_error"
  139. error: Error
  140. }
  141. | {
  142. type: "gql_error"
  143. error: T
  144. }
  145. export const useGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
  146. _args: UseQueryOptions<DocType, DocVarType>
  147. ) => {
  148. const stops: WatchStopHandle[] = []
  149. const args = reactive(_args)
  150. const loading: Ref<boolean> = ref(true)
  151. const isStale: Ref<boolean> = ref(true)
  152. const data: Ref<E.Either<GQLError<DocErrorType>, DocType>> = ref() as any
  153. if (!args.updateSubs) set(args, "updateSubs", [])
  154. const isPaused: Ref<boolean> = ref(args.defer ?? false)
  155. const pollDuration: Ref<number | null> = ref(args.pollDuration ?? null)
  156. const request: Ref<GraphQLRequest<DocType, DocVarType>> = ref(
  157. createRequest<DocType, DocVarType>(
  158. args.query,
  159. unref<DocVarType>(args.variables as any) as any
  160. )
  161. ) as any
  162. const source: Ref<Source<OperationResult> | undefined> = ref()
  163. // Toggles between true and false to cause the polling operation to tick
  164. const pollerTick: Ref<boolean> = ref(true)
  165. stops.push(
  166. watchEffect((onInvalidate) => {
  167. if (pollDuration.value !== null && !isPaused.value) {
  168. const handle = setInterval(() => {
  169. pollerTick.value = !pollerTick.value
  170. }, pollDuration.value)
  171. onInvalidate(() => {
  172. clearInterval(handle)
  173. })
  174. }
  175. })
  176. )
  177. stops.push(
  178. watchEffect(
  179. () => {
  180. const newRequest = createRequest<DocType, DocVarType>(
  181. args.query,
  182. unref<DocVarType>(args.variables as any) as any
  183. )
  184. if (request.value.key !== newRequest.key) {
  185. request.value = newRequest
  186. }
  187. },
  188. { flush: "pre" }
  189. )
  190. )
  191. stops.push(
  192. watchEffect(
  193. () => {
  194. // Just listen to the polling ticks
  195. // eslint-disable-next-line no-unused-expressions
  196. pollerTick.value
  197. source.value = !isPaused.value
  198. ? client.value.executeQuery<DocType, DocVarType>(request.value, {
  199. requestPolicy: "cache-and-network",
  200. })
  201. : undefined
  202. },
  203. { flush: "pre" }
  204. )
  205. )
  206. watchSyncEffect((onInvalidate) => {
  207. if (source.value) {
  208. loading.value = true
  209. isStale.value = false
  210. const invalidateStops = args.updateSubs!.map((sub) => {
  211. return wonkaPipe(
  212. client.value.executeSubscription(sub),
  213. onEnd(() => {
  214. if (source.value) execute()
  215. }),
  216. subscribe(() => {
  217. return execute()
  218. })
  219. ).unsubscribe
  220. })
  221. invalidateStops.push(
  222. wonkaPipe(
  223. source.value,
  224. onEnd(() => {
  225. loading.value = false
  226. isStale.value = false
  227. }),
  228. subscribe((res) => {
  229. if (res.operation.key === request.value.key) {
  230. data.value = pipe(
  231. // The target
  232. res.data as DocType | undefined,
  233. // Define what happens if data does not exist (it is an error)
  234. E.fromNullable(
  235. pipe(
  236. // Take the network error value
  237. res.error?.networkError,
  238. // If it null, set the left to the generic error name
  239. E.fromNullable(res.error?.message),
  240. E.match(
  241. // The left case (network error was null)
  242. (gqlErr) =>
  243. <GQLError<DocErrorType>>{
  244. type: "gql_error",
  245. error: parseGQLErrorString(
  246. gqlErr ?? ""
  247. ) as DocErrorType,
  248. },
  249. // The right case (it was a GraphQL Error)
  250. (networkErr) =>
  251. <GQLError<DocErrorType>>{
  252. type: "network_error",
  253. error: networkErr,
  254. }
  255. )
  256. )
  257. )
  258. )
  259. loading.value = false
  260. }
  261. })
  262. ).unsubscribe
  263. )
  264. onInvalidate(() => invalidateStops.forEach((unsub) => unsub()))
  265. }
  266. })
  267. const execute = (updatedVars?: DocVarType) => {
  268. if (updatedVars) {
  269. if (isRef(args.variables)) {
  270. args.variables.value = updatedVars
  271. } else {
  272. set(args, "variables", updatedVars)
  273. }
  274. }
  275. isPaused.value = false
  276. }
  277. const response = reactive({
  278. loading,
  279. data,
  280. isStale,
  281. execute,
  282. })
  283. return response
  284. }
  285. const parseGQLErrorString = (s: string) =>
  286. s.startsWith("[GraphQL] ") ? s.split("[GraphQL] ")[1] : s
  287. export const runMutation = <
  288. DocType,
  289. DocVariables extends object | undefined,
  290. DocErrors extends string
  291. >(
  292. mutation: TypedDocumentNode<DocType, DocVariables>,
  293. variables?: DocVariables,
  294. additionalConfig?: Partial<OperationContext>
  295. ): TE.TaskEither<GQLError<DocErrors>, DocType> =>
  296. pipe(
  297. TE.tryCatch(
  298. () =>
  299. client.value
  300. .mutation(mutation, variables, {
  301. requestPolicy: "cache-and-network",
  302. ...additionalConfig,
  303. })
  304. .toPromise(),
  305. () => constVoid() as never // The mutation function can never fail, so this will never be called ;)
  306. ),
  307. TE.chainEitherK((result) =>
  308. pipe(
  309. result.data,
  310. E.fromNullable(
  311. // Result is null
  312. pipe(
  313. result.error?.networkError,
  314. E.fromNullable(result.error?.message),
  315. E.match(
  316. // The left case (network error was null)
  317. (gqlErr) =>
  318. <GQLError<DocErrors>>{
  319. type: "gql_error",
  320. error: parseGQLErrorString(gqlErr ?? ""),
  321. },
  322. // The right case (it was a network error)
  323. (networkErr) =>
  324. <GQLError<DocErrors>>{
  325. type: "network_error",
  326. error: networkErr,
  327. }
  328. )
  329. )
  330. )
  331. )
  332. )
  333. )