Files
twenty/packages/twenty-front/src/modules/apollo/utils/streamingRestLink.ts
T
Félix Malfait b470cb21a1 Upgrade Apollo Client to v4 and refactor error handling (#18584)
## Summary
This PR upgrades Apollo Client from v3.10.0 to v4 and refactors error
handling patterns across the codebase to use a new centralized
`useSnackBarOnQueryError` hook.

## Key Changes

- **Dependency Update**: Upgraded `@apollo/client` from `^3.10.0` to
`^3.11.0` in root package.json
- **New Hook**: Added `useSnackBarOnQueryError` hook for centralized
Apollo query error handling with snack bar notifications
- **Error Handling Refactor**: Updated 100+ files to use the new error
handling pattern:
  - Removed direct `ApolloError` imports where no longer needed
- Replaced manual error handling logic with `useSnackBarOnQueryError`
hook
- Simplified error handling in hooks and components across multiple
modules
- **GraphQL Codegen**: Updated codegen configuration files to work with
Apollo Client v3.11.0
- **Type Definitions**: Added TypeScript declaration file for
`apollo-upload-client` module
- **Test Updates**: Updated test files to reflect new error handling
patterns

## Notable Implementation Details

- The new `useSnackBarOnQueryError` hook provides a consistent way to
handle Apollo query errors with automatic snack bar notifications
- Changes span across multiple feature areas: auth, object records,
settings, workflows, billing, and more
- All changes maintain backward compatibility while improving code
maintainability and reducing duplication
- Jest configuration updated to work with the new Apollo Client version

https://claude.ai/code/session_019WGZ6Rd7sEHuBg9sTrXRqJ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-13 14:59:46 +01:00

224 lines
5.5 KiB
TypeScript

import {
ApolloLink,
Observable,
type Operation,
type FetchResult,
} from '@apollo/client';
import { ServerError } from '@apollo/client/errors';
import { type ArgumentNode, type DirectiveNode } from 'graphql';
import { isDefined } from 'twenty-shared/utils';
type StreamingRestLinkOptions = {
uri: string;
headers?: Record<string, string>;
credentials?: RequestCredentials;
};
type StreamDirective = {
type?: string;
path?: string;
method?: string;
bodyKey?: string;
headers?: Record<string, string>;
};
export class StreamingRestLink extends ApolloLink {
private readonly baseUri: string;
private readonly defaultHeaders: Record<string, string>;
constructor(options: StreamingRestLinkOptions) {
super();
this.baseUri = options.uri;
this.defaultHeaders = options.headers || {};
}
public request(
operation: Operation,
forward: (operation: Operation) => Observable<FetchResult>,
): Observable<FetchResult> {
const streamDirective = this.extractStreamDirective(operation);
if (!streamDirective) {
return forward(operation);
}
const { uri, onChunk, headers } = operation.getContext();
return new Observable((observer) => {
const controller = new AbortController();
const url = this.buildUrl({
uri,
streamDirective,
operation,
});
const requestConfig = this.buildRequestConfig({
operation,
streamDirective,
headers,
signal: controller.signal,
});
fetch(url, requestConfig)
.then(async (response) => {
if (!response.ok) {
throw new ServerError(`HTTP error! status: ${response.status}`, {
response,
bodyText: '',
});
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let isStreaming = true;
while (isStreaming) {
const { done, value } = await reader.read();
if (done) {
observer.complete();
isStreaming = false;
continue;
}
const decodedChunk = decoder.decode(value, { stream: true });
if (isDefined(onChunk) && typeof onChunk === 'function') {
onChunk(decodedChunk);
}
}
})
.catch((error) => {
observer.error(error);
});
return () => controller.abort();
});
}
private extractStreamDirective(operation: Operation): StreamDirective | null {
try {
const definition = operation.query.definitions[0];
if (!isDefined(definition) || definition.kind !== 'OperationDefinition') {
return null;
}
if (
!isDefined(definition.selectionSet) ||
!isDefined(definition.selectionSet.selections) ||
definition.selectionSet.selections.length === 0
) {
return null;
}
const selection = definition.selectionSet.selections[0];
if (!isDefined(selection) || !isDefined(selection.directives)) {
return null;
}
const streamDirective = selection.directives.find(
(d: DirectiveNode) => d.name.value === 'stream',
);
if (!isDefined(streamDirective)) {
return null;
}
const args = streamDirective.arguments || [];
const directive: StreamDirective = {};
args.forEach((arg: ArgumentNode) => {
if (arg.value.kind === 'StringValue') {
const value = arg.value.value;
switch (arg.name.value) {
case 'path':
directive.path = value;
break;
case 'type':
directive.type = value;
break;
case 'method':
directive.method = value;
break;
case 'bodyKey':
directive.bodyKey = value;
break;
}
}
});
return directive;
} catch {
return null;
}
}
private buildUrl({
uri,
streamDirective,
operation,
}: {
uri?: string;
streamDirective?: StreamDirective | null;
operation?: Operation;
}): string {
if (isDefined(uri)) {
return uri.startsWith('http') ? uri : `${this.baseUri}${uri}`;
}
if (isDefined(streamDirective?.path)) {
let path = streamDirective.path;
if (isDefined(operation?.variables)) {
Object.entries(operation.variables).forEach(([key, value]) => {
path = path.replace(`{args.${key}}`, String(value));
});
}
return `${this.baseUri}${path}`;
}
throw new Error('No valid URL found');
}
private buildRequestConfig({
operation,
streamDirective,
headers,
signal,
}: {
operation: Operation;
streamDirective?: StreamDirective | null;
headers?: Record<string, string>;
signal?: AbortSignal;
}): RequestInit {
const method = streamDirective?.method || 'POST';
let body: string | undefined;
if (isDefined(streamDirective?.bodyKey) && isDefined(operation.variables)) {
body = JSON.stringify(operation.variables[streamDirective.bodyKey]);
} else {
body = JSON.stringify(operation.variables);
}
return {
method,
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...this.defaultHeaders,
...headers,
...streamDirective?.headers,
},
body,
signal,
};
}
}