Files
bodyshop/client/src/utils/GraphQLClient.js

265 lines
7.1 KiB
JavaScript

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;