Merged in feature/IO-3499-React-19 (pull request #2858)
feature/IO-3499-React-19 checkpoint
This commit is contained in:
@@ -1,45 +1,201 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { onError } from "@apollo/client/link/error";
|
||||
import { ErrorLink } from "@apollo/client/link/error";
|
||||
import { CombinedGraphQLErrors, CombinedProtocolErrors } from "@apollo/client/errors";
|
||||
import { print } from "graphql";
|
||||
import { getMainDefinition } from "@apollo/client/utilities";
|
||||
import { auth } from "../firebase/firebase.utils";
|
||||
import { from, throwError } from "rxjs";
|
||||
import { catchError, mergeMap } from "rxjs/operators";
|
||||
|
||||
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
"password",
|
||||
"token",
|
||||
"authorization",
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
"idToken",
|
||||
"session"
|
||||
]);
|
||||
|
||||
let refreshInFlight = null;
|
||||
|
||||
const refreshTokenOnce = () => {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = auth.currentUser ? auth.currentUser.getIdToken(true) : Promise.resolve(null);
|
||||
refreshInFlight.finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
const redactVariables = (variables) => {
|
||||
if (!variables || typeof variables !== "object") return variables;
|
||||
const out = { ...variables };
|
||||
for (const key of Object.keys(out)) {
|
||||
if (SENSITIVE_KEYS.has(key)) out[key] = "[REDACTED]";
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const safePrintQuery = (query) => {
|
||||
try {
|
||||
return query ? print(query) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const isSubscriptionOperation = (operation) => {
|
||||
try {
|
||||
const def = getMainDefinition(operation.query);
|
||||
return def?.kind === "OperationDefinition" && def?.operation === "subscription";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const extractGraphQLErrors = ({ error, result }) => {
|
||||
// Primary: Apollo Client 4+ GraphQL errors
|
||||
if (CombinedGraphQLErrors.is(error)) return error.errors;
|
||||
|
||||
// Fallback: raw GraphQL response errors (what apollo-link-logger shows)
|
||||
if (Array.isArray(result?.errors) && result.errors.length) return result.errors;
|
||||
|
||||
// Fallback: some “network-ish” errors still carry result.errors
|
||||
if (Array.isArray(error?.result?.errors) && error.result.errors.length) return error.result.errors;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasUnauthCode = (errs) => Array.isArray(errs) && errs.some((e) => e?.extensions?.code === "UNAUTHENTICATED");
|
||||
|
||||
const errorLink = new ErrorLink(({ error, result, operation, forward }) => {
|
||||
const operationName = operation?.operationName || "anonymous";
|
||||
const ctx = operation?.getContext?.() || {};
|
||||
const callerStack = ctx?.callerStack;
|
||||
|
||||
if (graphQLErrors?.length) {
|
||||
graphQLErrors.forEach(({ message, locations, path }) => {
|
||||
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
|
||||
const variables = redactVariables(operation?.variables);
|
||||
const queryText = safePrintQuery(operation?.query);
|
||||
|
||||
const gqlErrors = extractGraphQLErrors({ error, result });
|
||||
const protocolErrors = CombinedProtocolErrors.is(error) ? error.errors : null;
|
||||
|
||||
// ---- Single auth-refresh retry (HTTP only, once) ----
|
||||
// Only retry if:
|
||||
// - we have GraphQL errors with UNAUTHENTICATED
|
||||
// - not a subscription
|
||||
// - not already retried
|
||||
// - forward exists (so we can re-run)
|
||||
if (forward && !isSubscriptionOperation(operation) && !ctx.__didAuthRetry && hasUnauthCode(gqlErrors)) {
|
||||
operation.setContext({ ...ctx, __didAuthRetry: true });
|
||||
|
||||
return from(refreshTokenOnce()).pipe(
|
||||
mergeMap(() => forward(operation)),
|
||||
catchError(() => throwError(() => error))
|
||||
);
|
||||
}
|
||||
|
||||
// ---- GraphQL errors ----
|
||||
if (gqlErrors?.length) {
|
||||
// Make it show up in the “Errors” filter (red)
|
||||
console.error(`[GraphQL error] ${operationName}`, {
|
||||
message: gqlErrors[0]?.message
|
||||
});
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setLevel("error");
|
||||
scope.setTag("graphql.operationName", operationName);
|
||||
scope.setContext("graphql", {
|
||||
operationName
|
||||
// variables can contain PII; include only if you’re comfortable with it
|
||||
// variables: operation.variables
|
||||
if (import.meta.env.DEV) {
|
||||
console.groupCollapsed?.(`[GraphQL error details] ${operationName}`);
|
||||
console.log({
|
||||
operationName,
|
||||
variables,
|
||||
query: queryText,
|
||||
callerStack
|
||||
});
|
||||
|
||||
graphQLErrors.forEach((err) => {
|
||||
scope.setContext("graphql.error", {
|
||||
message: err.message,
|
||||
path: err.path,
|
||||
locations: err.locations
|
||||
gqlErrors.forEach((e, i) => {
|
||||
console.log(`Error #${i + 1}:`, {
|
||||
message: e.message,
|
||||
path: e.path,
|
||||
locations: e.locations,
|
||||
extensions: e.extensions
|
||||
});
|
||||
Sentry.captureMessage(`[GraphQL] ${err.message}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (networkError) {
|
||||
console.log(`[Network error]: ${JSON.stringify(networkError)}`);
|
||||
console.groupEnd?.();
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setLevel("error");
|
||||
scope.setTag("graphql.operationName", operationName);
|
||||
Sentry.captureException(networkError);
|
||||
|
||||
scope.setContext("graphql.request", {
|
||||
operationName,
|
||||
variables,
|
||||
query: import.meta.env.DEV ? queryText : undefined
|
||||
});
|
||||
|
||||
if (callerStack) scope.setContext("graphql.caller", { stack: callerStack });
|
||||
|
||||
scope.setContext(
|
||||
"graphql.errors",
|
||||
gqlErrors.map((e) => ({
|
||||
message: e.message,
|
||||
path: e.path,
|
||||
locations: e.locations,
|
||||
extensions: e.extensions
|
||||
}))
|
||||
);
|
||||
|
||||
Sentry.captureMessage(`[GraphQL] ${operationName}`);
|
||||
});
|
||||
|
||||
// IMPORTANT: do NOT return forward(operation) unless intentionally retrying
|
||||
return;
|
||||
}
|
||||
|
||||
return forward(operation);
|
||||
// ---- Protocol errors (rare, but good to log) ----
|
||||
if (protocolErrors?.length) {
|
||||
console.error(`[Protocol error] ${operationName}`, {
|
||||
message: protocolErrors[0]?.message
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.groupCollapsed?.(`[Protocol error details] ${operationName}`);
|
||||
console.log({ operationName, variables, query: queryText, callerStack });
|
||||
protocolErrors.forEach((e, i) => console.log(`Protocol Error #${i + 1}:`, e));
|
||||
console.groupEnd?.();
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setLevel("error");
|
||||
scope.setTag("graphql.operationName", operationName);
|
||||
scope.setContext("graphql.request", {
|
||||
operationName,
|
||||
variables,
|
||||
query: import.meta.env.DEV ? queryText : undefined
|
||||
});
|
||||
if (callerStack) scope.setContext("graphql.caller", { stack: callerStack });
|
||||
scope.setContext("graphql.protocolErrors", protocolErrors);
|
||||
Sentry.captureMessage(`[GraphQL Protocol] ${operationName}`);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Network / other errors ----
|
||||
if (error) {
|
||||
console.error(`[Network error] ${operationName}`, error);
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setLevel("error");
|
||||
scope.setTag("graphql.operationName", operationName);
|
||||
scope.setContext("graphql.request", {
|
||||
operationName,
|
||||
variables,
|
||||
query: import.meta.env.DEV ? queryText : undefined
|
||||
});
|
||||
if (callerStack) scope.setContext("graphql.caller", { stack: callerStack });
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default errorLink;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// file: client/src/utils/GraphQLClient.js
|
||||
import { ApolloClient, ApolloLink, InMemoryCache, split } from "@apollo/client";
|
||||
import { setContext } from "@apollo/client/link/context";
|
||||
import { HttpLink } from "@apollo/client/link/http";
|
||||
@@ -13,45 +12,73 @@ 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
|
||||
});
|
||||
|
||||
const wsLink = new GraphQLWsLink(
|
||||
createClient({
|
||||
url: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT_WS,
|
||||
lazy: true,
|
||||
connectionParams: async () => {
|
||||
const token = auth.currentUser && (await auth.currentUser.getIdToken());
|
||||
return {
|
||||
headers: {
|
||||
authorization: token ? `Bearer ${token}` : ""
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
/**
|
||||
* 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,
|
||||
|
||||
// Split based on operation type (subscription -> ws, everything else -> http)
|
||||
const terminatingLink = split(
|
||||
({ query }) => {
|
||||
const definition = getMainDefinition(query);
|
||||
return definition.kind === "OperationDefinition" && definition.operation === "subscription";
|
||||
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;
|
||||
},
|
||||
wsLink,
|
||||
httpLink
|
||||
);
|
||||
|
||||
// function TrackExecutionTime(operationName, timeMs) {
|
||||
// keep your existing implementation/commented code here
|
||||
// This signature now matches the call site.
|
||||
// }
|
||||
connectionParams: async () => {
|
||||
const token = await auth.currentUser?.getIdToken();
|
||||
return token
|
||||
? {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
: {};
|
||||
}
|
||||
});
|
||||
|
||||
// Apollo Client 4 uses RxJS under the hood; use pipe(map(...)) instead of .map(...)
|
||||
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() });
|
||||
|
||||
return forward(operation).pipe(
|
||||
const obs = forward?.(operation);
|
||||
if (!obs) return null;
|
||||
|
||||
return obs.pipe(
|
||||
map((result) => {
|
||||
// const start = operation.getContext().start;
|
||||
// const timeMs = Date.now() - start;
|
||||
@@ -61,9 +88,12 @@ const roundTripLink = new ApolloLink((operation, forward) => {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth for HTTP requests (per-request token)
|
||||
*/
|
||||
const authLink = setContext(async (_, { headers }) => {
|
||||
try {
|
||||
const token = auth.currentUser && (await auth.currentUser.getIdToken());
|
||||
const token = await auth.currentUser?.getIdToken();
|
||||
if (!token) return { headers };
|
||||
|
||||
return {
|
||||
@@ -78,28 +108,65 @@ const authLink = setContext(async (_, { headers }) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Retry (HTTP only) — transient/network failures only.
|
||||
* Avoid retrying deterministic GraphQL errors (validation, missing variables, etc).
|
||||
*/
|
||||
const retryLink = new RetryLink({
|
||||
delay: {
|
||||
initial: 500,
|
||||
// Keeping your intent (a cap), but make it milliseconds (5ms is almost certainly not intended).
|
||||
max: 5000,
|
||||
jitter: true
|
||||
},
|
||||
attempts: {
|
||||
max: 5,
|
||||
retryIf: (error) => !!error
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const links = [];
|
||||
/**
|
||||
* 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]);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
links.push(apolloLogger);
|
||||
}
|
||||
const isSubscriptionOperation = ({ query }) => {
|
||||
const definition = getMainDefinition(query);
|
||||
return definition.kind === "OperationDefinition" && definition.operation === "subscription";
|
||||
};
|
||||
|
||||
// Order: timing -> retry -> error -> auth -> network split
|
||||
links.push(roundTripLink, retryLink, errorLink, authLink, terminatingLink);
|
||||
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: {
|
||||
Query: {
|
||||
@@ -114,6 +181,7 @@ const cache = new InMemoryCache({
|
||||
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);
|
||||
@@ -125,20 +193,30 @@ const cache = new InMemoryCache({
|
||||
},
|
||||
|
||||
notifications: {
|
||||
merge(existing = [], incoming = [], { readField }) {
|
||||
const merged = new Map();
|
||||
// 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 = [];
|
||||
|
||||
existing.forEach((item) => {
|
||||
const ref = readField("__ref", item);
|
||||
if (ref) merged.set(ref, item);
|
||||
});
|
||||
for (const item of incoming) {
|
||||
const typename = readField("__typename", item);
|
||||
const id = readField("id", item);
|
||||
|
||||
incoming.forEach((item) => {
|
||||
const ref = readField("__ref", item);
|
||||
if (ref) merged.set(ref, item);
|
||||
});
|
||||
const key = typename && id ? `${typename}:${id}` : null;
|
||||
|
||||
return incoming;
|
||||
if (!key) {
|
||||
out.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,8 +224,11 @@ const cache = new InMemoryCache({
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Client
|
||||
*/
|
||||
const client = new ApolloClient({
|
||||
link: ApolloLink.from(links),
|
||||
link,
|
||||
cache,
|
||||
devtools: {
|
||||
name: "Imex Client",
|
||||
|
||||
Reference in New Issue
Block a user