import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client/core'; import { getMainDefinition } from '@apollo/client/utilities'; import { WebSocketLink } from '@apollo/client/link/ws'; import { setContext } from '@apollo/client/link/context'; import { onError } from '@apollo/client/link/error'; import { RetryLink } from '@apollo/client/link/retry'; import { fromPromise } from '@apollo/client/link/utils/fromPromise'; import { setup } from 'meteor/swydo:blaze-apollo'; // use the origin defined by platformOrigin, or fall back to this default if undefined const platformOrigin = Meteor?.settings?.public?.platformOrigin || "https://platform.pitchly.com"; const httpURI = platformOrigin + "/graphql"; const wsURI = platformOrigin.replace(/^http/i, "ws") + "/subscriptions"; const httpLink = new HttpLink({ uri: httpURI }); // attach authorization header to each HTTP request const authLink = setContext((_, { headers }) => { const token = Meteor.user()?.services?.pitchly?.accessToken; return { headers: { ...headers, authorization: token ? `Bearer ${token}` : "", } } }); const wsLink = new WebSocketLink({ uri: wsURI, options: { reconnect: true } }); // attach authorization header to each websocket subscription (not just on initial connection) wsLink.subscriptionClient.use([{ applyMiddleware: (options, next) => { const token = Meteor.user()?.services?.pitchly?.accessToken; options.authorization = token ? `Bearer ${token}` : ""; next(); } }]); // If an HTTP request returns an "UNAUTHENTICATED" error, this will automatically // refresh the access token and retry the original request with the new access // token without any interruption. // Despite the accounts-pitchly package automatically refreshing access tokens // on page load and on a regular interval, this is still important because: // // 1) Requests may be made prior to the token being totally refreshed. This will // cause the request to "wait" until a valid access token is acquired. // // 2) Access tokens may be invalidated at any time by Pitchly, even prior to the // accessTokenExpiresAt date. This ensures that we get a new access token as // soon as we know it's invalid and we don't have to wait until the interval // in accounts-pitchly runs again (only runs every 6 minutes). // Inspired from: https://stackoverflow.com/a/62872754/2658450 const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => { if (graphQLErrors) { // we need to convert a Meteor.call to a function returning a promise const callWithPromise = (method, params) => { return new Promise((resolve, reject) => { Meteor.call(method, params, (err, res) => { if (err) { reject(err); return; } resolve(res); }); }); } for (let err of graphQLErrors) { switch (err.extensions.code) { // this should match whatever error code Pitchly sends back when the // access token is invalid or expired case 'UNAUTHENTICATED': return fromPromise( callWithPromise("Pitchly.refreshAccessToken", { force: true }).catch((error) => { // Handle token refresh errors e.g clear stored tokens, redirect to login, ... return; }) ).filter((value) => Boolean(value)) .flatMap(({ refreshed, accessToken }) => { const oldHeaders = operation.getContext().headers; // modify the operation context with a new token operation.setContext({ headers: { ...oldHeaders, authorization: `Bearer ${accessToken}`, }, }); // retry the request, returning the new observable if (Meteor.isDevelopment) { console.log("Retrying GraphQL request because server returned 'UNAUTHENTICATED'..."); } return forward(operation); }); } } } }); // Will automatically retry requests that fail due to network errors (e.g. the user // loses internet connectivity). By default, the request will be retried in 300ms // with exponential backoff up to 5 times, which means the request will only throw // back an error if it repeatedly fails for at least 4800ms (almost 5 seconds). const retryLink = new RetryLink(); const splitLink = split( ({ query }) => { const definition = getMainDefinition(query); return ( definition.kind === 'OperationDefinition' && definition.operation === 'subscription' ); }, wsLink, // retryLink executes first, httpLink last retryLink.concat(errorLink.concat(authLink.concat(httpLink))) ); const client = new ApolloClient({ link: splitLink, cache: new InMemoryCache() }); // Convert an array of errors from Apollo into a single Meteor error object. // This expects an array like the one received from result.getErrors() using // blaze-apollo and returns a Meteor.Error object if an error is present. If // there are no errors, will return undefined. const normalizeGraphQLErrors = function(errors) { if (errors && errors.length > 0) { const error = errors[0]; if (error.networkError) { // network error, e.g. failed to connect to server because of internet connectivity issues... return new Meteor.Error("NETWORK_ERROR", "Couldn't connect to Pitchly. Please try again.", error.networkError); } else { if (error.graphQLErrors && error.graphQLErrors.length > 0) { // application error... const gqlError = error.graphQLErrors[0]; return new Meteor.Error(gqlError.extensions.code, gqlError.message, gqlError); } else { // probably won't happen, but just in case... return new Meteor.Error("INTERNAL_SERVER_ERROR", "There was an internal error. Please try again.", error); } } } }; // clear Apollo Client cache on Meteor logout Accounts.onLogout(() => { client.resetStore(); }); // make Apollo Client work with Blaze setup({ client }); // export these variables so they can be imported and used elsewhere export { client as apolloClient, normalizeGraphQLErrors };