GQLClient.ts 8.9 KB

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