import { ApolloClient, ApolloLink, InMemoryCache, split } from "@apollo/client"; import { setContext } from "@apollo/client/link/context"; import { HttpLink } from "@apollo/client/link/http"; import { RetryLink } from "@apollo/client/link/retry"; import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; import { getMainDefinition } from "@apollo/client/utilities"; import apolloLogger from "apollo-link-logger"; import { createClient } from "graphql-ws"; import { map } from "rxjs/operators"; import { auth } from "../firebase/firebase.utils"; import errorLink from "../graphql/apollo-error-handling"; /** * HTTP transport */ const httpLink = new HttpLink({ uri: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT }); /** * WS transport (graphql-ws) * - Auth header is established at connect-time via connectionParams. * - Reconnect behavior is handled here (NOT via RetryLink). */ const wsClient = createClient({ url: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT_WS, lazy: true, lazyCloseTimeout: 5000, retryAttempts: Infinity, shouldRetry: (eventOrError) => { // Don't retry a clean close (1000); retry everything else const code = eventOrError?.code; if (typeof code === "number") return code !== 1000; return true; }, connectionParams: async () => { const token = await auth.currentUser?.getIdToken(); return token ? { headers: { authorization: `Bearer ${token}` } } : {}; } }); const wsLink = new GraphQLWsLink(wsClient); /** * DEV-only: attach a caller stack to every operation so errors can point back * to the initiating module/component. */ const callerStackLink = import.meta.env.DEV ? new ApolloLink((operation, forward) => { const stack = new Error().stack; operation.setContext((prev) => ({ ...prev, callerStack: stack })); return forward(operation); }) : ApolloLink.empty(); /** * Round-trip timing (HTTP only) * NOTE: Subscriptions are long-lived streams; timing/retry should not wrap them. */ const roundTripLink = new ApolloLink((operation, forward) => { operation.setContext({ start: Date.now() }); const obs = forward?.(operation); if (!obs) return null; return obs.pipe( map((result) => { // const start = operation.getContext().start; // const timeMs = Date.now() - start; // TrackExecutionTime(operation.operationName, timeMs); return result; }) ); }); /** * Auth for HTTP requests (per-request token) */ const authLink = setContext(async (_, { headers }) => { try { const token = await auth.currentUser?.getIdToken(); if (!token) return { headers }; return { headers: { ...headers, authorization: `Bearer ${token}` } }; } catch (error) { console.error("Authentication error. Unable to add authorization token.", error?.message || error); return { headers }; } }); /** * Retry (HTTP only) — transient/network failures only. * Avoid retrying deterministic GraphQL errors (validation, missing variables, etc). */ const retryLink = new RetryLink({ delay: { initial: 500, max: 5000, jitter: true }, attempts: { max: 5, retryIf: (error) => { // Apollo's network errors often expose statusCode/response.status const status = error?.statusCode ?? error?.response?.status; if (typeof status === "number") { // Common transient HTTP statuses return [408, 429, 500, 502, 503, 504].includes(status); } // Fallback heuristic for fetch/socket transient failures const msg = `${error?.message || ""}`; return /network|fetch|timeout|ECONNRESET|ENOTFOUND|EAI_AGAIN/i.test(msg); } } }); /** * Branch chains * - HTTP branch includes timing + retry + auth. * - WS branch is intentionally lean; graphql-ws handles reconnect. * * NOTE: * errorLink is applied OUTSIDE the split so it sees both HTTP + WS operations. * Placing errorLink upstream of retryLink means it generally logs only after retries are exhausted. */ const httpChain = ApolloLink.from([roundTripLink, retryLink, authLink, httpLink]); const wsChain = ApolloLink.from([wsLink]); const isSubscriptionOperation = ({ query }) => { const definition = getMainDefinition(query); return definition.kind === "OperationDefinition" && definition.operation === "subscription"; }; const terminatingLink = split(isSubscriptionOperation, wsChain, httpChain); /** * Final link chain * - errorLink wraps both branches (HTTP + WS) * - callerStackLink adds call-site stack (DEV only) * - apollo-link-logger only in DEV */ const link = ApolloLink.from( import.meta.env.DEV ? [apolloLogger, callerStackLink, errorLink, terminatingLink] : [errorLink, terminatingLink] ); /** * Cache policies */ const cache = new InMemoryCache({ typePolicies: { users: { keyFields: ["email"] }, masterdata: { keyFields: ["key"] }, Query: { fields: { job_watchers: { merge(_existing, incoming) { return incoming; } }, conversations: { keyArgs: ["where", "order_by"], merge(existing = [], incoming = [], { args, readField }) { const offset = args?.offset ?? 0; const merged = existing ? existing.slice(0) : []; for (let i = 0; i < incoming.length; i++) { merged[offset + i] = incoming[i]; } // Deduplicate by id (important when you also upsert via sockets) const seen = new Set(); return merged.filter((ref) => { const id = readField("id", ref); if (!id || seen.has(id)) return false; seen.add(id); return true; }); } }, notifications: { // Keep "respect the current query filter" behavior (i.e., don't union with existing), // but dedupe within the incoming page/list so UI doesn't flicker with duplicates. // eslint-disable-next-line no-unused-vars merge(_existing = [], incoming = [], { readField }) { const seen = new Set(); const out = []; for (const item of incoming) { const typename = readField("__typename", item); const id = readField("id", item); const key = typename && id ? `${typename}:${id}` : null; if (!key) { out.push(item); continue; } if (seen.has(key)) continue; seen.add(key); out.push(item); } return out; } } } } } }); /** * Client */ const client = new ApolloClient({ link, cache, devtools: { name: "Imex Client", enabled: import.meta.env.DEV }, defaultOptions: { watchQuery: { fetchPolicy: "network-only", nextFetchPolicy: "network-only", errorPolicy: "ignore", notifyOnNetworkStatusChange: false }, query: { fetchPolicy: "network-only", errorPolicy: "all" }, mutate: { errorPolicy: "all" } } }); export default client;