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 * 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 operationName = operation?.operationName || "anonymous";
|
||||||
|
const ctx = operation?.getContext?.() || {};
|
||||||
|
const callerStack = ctx?.callerStack;
|
||||||
|
|
||||||
if (graphQLErrors?.length) {
|
const variables = redactVariables(operation?.variables);
|
||||||
graphQLErrors.forEach(({ message, locations, path }) => {
|
const queryText = safePrintQuery(operation?.query);
|
||||||
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
|
|
||||||
|
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) => {
|
if (import.meta.env.DEV) {
|
||||||
scope.setLevel("error");
|
console.groupCollapsed?.(`[GraphQL error details] ${operationName}`);
|
||||||
scope.setTag("graphql.operationName", operationName);
|
console.log({
|
||||||
scope.setContext("graphql", {
|
operationName,
|
||||||
operationName
|
variables,
|
||||||
// variables can contain PII; include only if you’re comfortable with it
|
query: queryText,
|
||||||
// variables: operation.variables
|
callerStack
|
||||||
});
|
});
|
||||||
|
gqlErrors.forEach((e, i) => {
|
||||||
graphQLErrors.forEach((err) => {
|
console.log(`Error #${i + 1}:`, {
|
||||||
scope.setContext("graphql.error", {
|
message: e.message,
|
||||||
message: err.message,
|
path: e.path,
|
||||||
path: err.path,
|
locations: e.locations,
|
||||||
locations: err.locations
|
extensions: e.extensions
|
||||||
});
|
});
|
||||||
Sentry.captureMessage(`[GraphQL] ${err.message}`);
|
|
||||||
});
|
});
|
||||||
});
|
console.groupEnd?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (networkError) {
|
|
||||||
console.log(`[Network error]: ${JSON.stringify(networkError)}`);
|
|
||||||
|
|
||||||
Sentry.withScope((scope) => {
|
Sentry.withScope((scope) => {
|
||||||
scope.setLevel("error");
|
scope.setLevel("error");
|
||||||
scope.setTag("graphql.operationName", operationName);
|
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;
|
export default errorLink;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// file: client/src/utils/GraphQLClient.js
|
|
||||||
import { ApolloClient, ApolloLink, InMemoryCache, split } from "@apollo/client";
|
import { ApolloClient, ApolloLink, InMemoryCache, split } from "@apollo/client";
|
||||||
import { setContext } from "@apollo/client/link/context";
|
import { setContext } from "@apollo/client/link/context";
|
||||||
import { HttpLink } from "@apollo/client/link/http";
|
import { HttpLink } from "@apollo/client/link/http";
|
||||||
@@ -13,45 +12,73 @@ import { map } from "rxjs/operators";
|
|||||||
import { auth } from "../firebase/firebase.utils";
|
import { auth } from "../firebase/firebase.utils";
|
||||||
import errorLink from "../graphql/apollo-error-handling";
|
import errorLink from "../graphql/apollo-error-handling";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP transport
|
||||||
|
*/
|
||||||
const httpLink = new HttpLink({
|
const httpLink = new HttpLink({
|
||||||
uri: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT
|
uri: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT
|
||||||
});
|
});
|
||||||
|
|
||||||
const wsLink = new GraphQLWsLink(
|
/**
|
||||||
createClient({
|
* WS transport (graphql-ws)
|
||||||
url: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT_WS,
|
* - Auth header is established at connect-time via connectionParams.
|
||||||
lazy: true,
|
* - Reconnect behavior is handled here (NOT via RetryLink).
|
||||||
connectionParams: async () => {
|
*/
|
||||||
const token = auth.currentUser && (await auth.currentUser.getIdToken());
|
const wsClient = createClient({
|
||||||
return {
|
url: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT_WS,
|
||||||
headers: {
|
lazy: true,
|
||||||
authorization: token ? `Bearer ${token}` : ""
|
lazyCloseTimeout: 5000,
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Split based on operation type (subscription -> ws, everything else -> http)
|
retryAttempts: Infinity,
|
||||||
const terminatingLink = split(
|
shouldRetry: (eventOrError) => {
|
||||||
({ query }) => {
|
// Don't retry a clean close (1000); retry everything else
|
||||||
const definition = getMainDefinition(query);
|
const code = eventOrError?.code;
|
||||||
return definition.kind === "OperationDefinition" && definition.operation === "subscription";
|
if (typeof code === "number") return code !== 1000;
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
wsLink,
|
|
||||||
httpLink
|
|
||||||
);
|
|
||||||
|
|
||||||
// function TrackExecutionTime(operationName, timeMs) {
|
connectionParams: async () => {
|
||||||
// keep your existing implementation/commented code here
|
const token = await auth.currentUser?.getIdToken();
|
||||||
// This signature now matches the call site.
|
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) => {
|
const roundTripLink = new ApolloLink((operation, forward) => {
|
||||||
operation.setContext({ start: Date.now() });
|
operation.setContext({ start: Date.now() });
|
||||||
|
|
||||||
return forward(operation).pipe(
|
const obs = forward?.(operation);
|
||||||
|
if (!obs) return null;
|
||||||
|
|
||||||
|
return obs.pipe(
|
||||||
map((result) => {
|
map((result) => {
|
||||||
// const start = operation.getContext().start;
|
// const start = operation.getContext().start;
|
||||||
// const timeMs = Date.now() - 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 }) => {
|
const authLink = setContext(async (_, { headers }) => {
|
||||||
try {
|
try {
|
||||||
const token = auth.currentUser && (await auth.currentUser.getIdToken());
|
const token = await auth.currentUser?.getIdToken();
|
||||||
if (!token) return { headers };
|
if (!token) return { headers };
|
||||||
|
|
||||||
return {
|
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({
|
const retryLink = new RetryLink({
|
||||||
delay: {
|
delay: {
|
||||||
initial: 500,
|
initial: 500,
|
||||||
// Keeping your intent (a cap), but make it milliseconds (5ms is almost certainly not intended).
|
|
||||||
max: 5000,
|
max: 5000,
|
||||||
jitter: true
|
jitter: true
|
||||||
},
|
},
|
||||||
attempts: {
|
attempts: {
|
||||||
max: 5,
|
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) {
|
const isSubscriptionOperation = ({ query }) => {
|
||||||
links.push(apolloLogger);
|
const definition = getMainDefinition(query);
|
||||||
}
|
return definition.kind === "OperationDefinition" && definition.operation === "subscription";
|
||||||
|
};
|
||||||
|
|
||||||
// Order: timing -> retry -> error -> auth -> network split
|
const terminatingLink = split(isSubscriptionOperation, wsChain, httpChain);
|
||||||
links.push(roundTripLink, retryLink, errorLink, authLink, terminatingLink);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({
|
const cache = new InMemoryCache({
|
||||||
typePolicies: {
|
typePolicies: {
|
||||||
Query: {
|
Query: {
|
||||||
@@ -114,6 +181,7 @@ const cache = new InMemoryCache({
|
|||||||
merged[offset + i] = incoming[i];
|
merged[offset + i] = incoming[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate by id (important when you also upsert via sockets)
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return merged.filter((ref) => {
|
return merged.filter((ref) => {
|
||||||
const id = readField("id", ref);
|
const id = readField("id", ref);
|
||||||
@@ -125,20 +193,30 @@ const cache = new InMemoryCache({
|
|||||||
},
|
},
|
||||||
|
|
||||||
notifications: {
|
notifications: {
|
||||||
merge(existing = [], incoming = [], { readField }) {
|
// Keep "respect the current query filter" behavior (i.e., don't union with existing),
|
||||||
const merged = new Map();
|
// 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) => {
|
for (const item of incoming) {
|
||||||
const ref = readField("__ref", item);
|
const typename = readField("__typename", item);
|
||||||
if (ref) merged.set(ref, item);
|
const id = readField("id", item);
|
||||||
});
|
|
||||||
|
|
||||||
incoming.forEach((item) => {
|
const key = typename && id ? `${typename}:${id}` : null;
|
||||||
const ref = readField("__ref", item);
|
|
||||||
if (ref) merged.set(ref, item);
|
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
const client = new ApolloClient({
|
||||||
link: ApolloLink.from(links),
|
link,
|
||||||
cache,
|
cache,
|
||||||
devtools: {
|
devtools: {
|
||||||
name: "Imex Client",
|
name: "Imex Client",
|
||||||
|
|||||||
Reference in New Issue
Block a user