265 lines
7.1 KiB
JavaScript
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;
|