Skip to content

Commit

Permalink
feat: Multipart Subscriptions (#10592)
Browse files Browse the repository at this point in the history
  • Loading branch information
alessbell authored Mar 30, 2023
1 parent 00b33bf commit cdb98ae
Show file tree
Hide file tree
Showing 17 changed files with 823 additions and 238 deletions.
5 changes: 5 additions & 0 deletions .changeset/nervous-tomatoes-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Adds support for multipart subscriptions in `HttpLink`.
2 changes: 1 addition & 1 deletion config/bundlesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from "path";
import { gzipSync } from "zlib";
import bytes from "bytes";

const gzipBundleByteLengthLimit = bytes("32.75KB");
const gzipBundleByteLengthLimit = bytes("33.02KB");
const minFile = join("dist", "apollo-client.min.cjs");
const minPath = join(__dirname, "..", minFile);
const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength;
Expand Down
3 changes: 1 addition & 2 deletions config/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,14 @@ const react18TestFileIgnoreList = [
// to avoid running them twice with both react versions
// since they do not import react
ignoreTSFiles,
// failing hoc tests (8)
// failing hoc tests (7)
'src/react/hoc/__tests__/mutations/queries.test.tsx',
'src/react/hoc/__tests__/mutations/recycled-queries.test.tsx',
'src/react/hoc/__tests__/queries/errors.test.tsx',
'src/react/hoc/__tests__/queries/lifecycle.test.tsx',
'src/react/hoc/__tests__/queries/loading.test.tsx',
'src/react/hoc/__tests__/queries/observableQuery.test.tsx',
'src/react/hoc/__tests__/queries/skip.test.tsx',
'src/react/hoc/__tests__/subscriptions/subscriptions.test.tsx',
// failing components tests (1)
'src/react/components/__tests__/client/Query.test.tsx',
];
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/__snapshots__/exports.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ Array [
exports[`exports of public entry points @apollo/client/errors 1`] = `
Array [
"ApolloError",
"PROTOCOL_ERRORS_SYMBOL",
"graphQLResultHasProtocolErrors",
"isApolloError",
]
`;
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/__snapshots__/graphqlSubscriptions.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

exports[`GraphQL Subscriptions should throw an error if the result has errors on it 1`] = `[ApolloError: This is an error]`;

exports[`GraphQL Subscriptions should throw an error if the result has errors on it 2`] = `[ApolloError: This is an error]`;
exports[`GraphQL Subscriptions should throw an error if the result has protocolErrors on it 1`] = `[ApolloError: cannot read message from websocket]`;
73 changes: 56 additions & 17 deletions src/__tests__/graphqlSubscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import gql from 'graphql-tag';

import { ApolloClient } from '../core';
import { InMemoryCache } from '../cache';
import { PROTOCOL_ERRORS_SYMBOL } from '../errors';
import { QueryManager } from '../core/QueryManager';
import { itAsync, mockObservableLink } from '../testing';

Expand Down Expand Up @@ -187,22 +188,17 @@ describe('GraphQL Subscriptions', () => {

const obs = queryManager.startGraphQLSubscription(options);

const promises = [];
for (let i = 0; i < 2; i += 1) {
promises.push(
new Promise<void>((resolve, reject) => {
obs.subscribe({
next(result) {
reject('Should have hit the error block');
},
error(error) {
expect(error).toMatchSnapshot();
resolve();
},
});
}),
);
}
const promise = new Promise<void>((resolve, reject) => {
obs.subscribe({
next(result) {
reject('Should have hit the error block');
},
error(error) {
expect(error).toMatchSnapshot();
resolve();
},
});
});

const errorResult = {
result: {
Expand All @@ -223,7 +219,7 @@ describe('GraphQL Subscriptions', () => {
};

link.simulateResult(errorResult);
return Promise.all(promises);
return Promise.resolve(promise);
});

it('should call complete handler when the subscription completes', () => {
Expand Down Expand Up @@ -261,4 +257,47 @@ describe('GraphQL Subscriptions', () => {

link.simulateResult(results[0]);
});

it('should throw an error if the result has protocolErrors on it', () => {
const link = mockObservableLink();
const queryManager = new QueryManager({
link,
cache: new InMemoryCache({ addTypename: false }),
});

const obs = queryManager.startGraphQLSubscription(options);

const promise = new Promise<void>((resolve, reject) => {
obs.subscribe({
next(result) {
reject("Should have hit the error block");
},
error(error) {
expect(error).toMatchSnapshot();
resolve();
},
});
});

const errorResult = {
result: {
data: null,
extensions: {
[PROTOCOL_ERRORS_SYMBOL]: [
{
message: 'cannot read message from websocket',
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR"
}
],
} as any,
],
}
},
};

link.simulateResult(errorResult);
return Promise.resolve(promise);
});
});
20 changes: 14 additions & 6 deletions src/core/QueryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
isNonNullObject,
} from '../utilities';
import { mergeIncrementalData } from '../utilities/common/incrementalResult';
import { ApolloError, isApolloError } from '../errors';
import { ApolloError, isApolloError, graphQLResultHasProtocolErrors } from '../errors';
import {
QueryOptions,
WatchQueryOptions,
Expand Down Expand Up @@ -61,6 +61,7 @@ import {
shouldWriteResult,
CacheWriteBehavior,
} from './QueryInfo';
import { PROTOCOL_ERRORS_SYMBOL, ApolloErrorOptions } from '../errors';

const { hasOwnProperty } = Object.prototype;

Expand Down Expand Up @@ -936,10 +937,17 @@ export class QueryManager<TStore> {
this.broadcastQueries();
}

if (graphQLResultHasError(result)) {
throw new ApolloError({
graphQLErrors: result.errors,
});
const hasErrors = graphQLResultHasError(result);
const hasProtocolErrors = graphQLResultHasProtocolErrors(result);
if (hasErrors || hasProtocolErrors) {
const errors: ApolloErrorOptions = {};
if (hasErrors) {
errors.graphQLErrors = result.errors;
}
if (hasProtocolErrors) {
errors.protocolErrors = result.extensions[PROTOCOL_ERRORS_SYMBOL];
}
throw new ApolloError(errors);
}

return result;
Expand Down Expand Up @@ -1235,7 +1243,7 @@ export class QueryManager<TStore> {
setTimeout(() => concast.cancel(reason));
});

let concast: Concast<ApolloQueryResult<TData>>,
let concast: Concast<ApolloQueryResult<TData>>,
containsDataFromLink: boolean;
// If the query has @export(as: ...) directives, then we need to
// process those directives asynchronously. When there are no
Expand Down
34 changes: 34 additions & 0 deletions src/errors/__tests__/ApolloError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,26 @@ describe('ApolloError', () => {
new GraphQLError('Something went wrong with GraphQL'),
new GraphQLError('Something else went wrong with GraphQL'),
];
const protocolErrors = [
{
message: "cannot read message from websocket",
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR",
},
],
}
];
const networkError = new Error('Network error');
const errorMessage = 'this is an error message';
const apolloError = new ApolloError({
graphQLErrors: graphQLErrors,
protocolErrors: protocolErrors,
networkError: networkError,
errorMessage: errorMessage,
});
expect(apolloError.graphQLErrors).toEqual(graphQLErrors);
expect(apolloError.protocolErrors).toEqual(protocolErrors);
expect(apolloError.networkError).toEqual(networkError);
expect(apolloError.message).toBe(errorMessage);
});
Expand Down Expand Up @@ -61,6 +73,28 @@ describe('ApolloError', () => {
expect(messages[1]).toMatch('network error message');
});

it('should add both protocol and graphql errors to the message', () => {
const graphQLErrors = [new GraphQLError('graphql error message')];
const protocolErrors = [
{
message: "cannot read message from websocket",
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR",
},
],
}
];
const apolloError = new ApolloError({
graphQLErrors,
protocolErrors,
});
const messages = apolloError.message.split('\n');
expect(messages.length).toBe(2);
expect(messages[0]).toMatch('graphql error message');
expect(messages[1]).toMatch('cannot read message from websocket');
});

it('should contain a stack trace', () => {
const graphQLErrors = [new GraphQLError('graphql error message')];
const networkError = new Error('network error message');
Expand Down
61 changes: 51 additions & 10 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,46 @@
import '../utilities/globals';

import { GraphQLError } from 'graphql';
import { GraphQLError, GraphQLErrorExtensions } from 'graphql';

import { isNonEmptyArray } from '../utilities';
import { ServerParseError } from '../link/http';
import { ServerError } from '../link/utils';
import { FetchResult } from "../link/core";

// This Symbol allows us to pass transport-specific errors from the link chain
// into QueryManager/client internals without risking a naming collision within
// extensions (which implementers can use as they see fit).
export const PROTOCOL_ERRORS_SYMBOL: unique symbol = Symbol();

type FetchResultWithSymbolExtensions<T> = FetchResult<T> & {
extensions: Record<string | symbol, any>
};

export interface ApolloErrorOptions {
graphQLErrors?: ReadonlyArray<GraphQLError>;
protocolErrors?: ReadonlyArray<{
message: string;
extensions?: GraphQLErrorExtensions[];
}>;
clientErrors?: ReadonlyArray<Error>;
networkError?: Error | ServerParseError | ServerError | null;
errorMessage?: string;
extraInfo?: any;
}

export function graphQLResultHasProtocolErrors<T>(
result: FetchResult<T>
): result is FetchResultWithSymbolExtensions<T> {
if (result.extensions) {
return Array.isArray(
(result as FetchResultWithSymbolExtensions<T>).extensions[
PROTOCOL_ERRORS_SYMBOL
]
);
}
return false;
}


export function isApolloError(err: Error): err is ApolloError {
return err.hasOwnProperty('graphQLErrors');
Expand All @@ -17,9 +53,14 @@ export function isApolloError(err: Error): err is ApolloError {
const generateErrorMessage = (err: ApolloError) => {
let message = '';
// If we have GraphQL errors present, add that to the error message.
if (isNonEmptyArray(err.graphQLErrors) || isNonEmptyArray(err.clientErrors)) {
if (
isNonEmptyArray(err.graphQLErrors) ||
isNonEmptyArray(err.clientErrors) ||
isNonEmptyArray(err.protocolErrors)
) {
const errors = ((err.graphQLErrors || []) as readonly Error[])
.concat(err.clientErrors || []);
.concat(err.clientErrors || [])
.concat((err.protocolErrors || []) as readonly Error[]);
errors.forEach((error: Error) => {
const errorMessage = error
? error.message
Expand All @@ -45,6 +86,10 @@ export class ApolloError extends Error {
public name: string;
public message: string;
public graphQLErrors: GraphQLErrors;
public protocolErrors: ReadonlyArray<{
message: string;
extensions?: GraphQLErrorExtensions[];
}>;
public clientErrors: ReadonlyArray<Error>;
public networkError: Error | ServerParseError | ServerError | null;

Expand All @@ -58,20 +103,16 @@ export class ApolloError extends Error {
// value or the constructed error will be meaningless.
constructor({
graphQLErrors,
protocolErrors,
clientErrors,
networkError,
errorMessage,
extraInfo,
}: {
graphQLErrors?: ReadonlyArray<GraphQLError>;
clientErrors?: ReadonlyArray<Error>;
networkError?: Error | ServerParseError | ServerError | null;
errorMessage?: string;
extraInfo?: any;
}) {
}: ApolloErrorOptions) {
super(errorMessage);
this.name = 'ApolloError';
this.graphQLErrors = graphQLErrors || [];
this.protocolErrors = protocolErrors || [];
this.clientErrors = clientErrors || [];
this.networkError = networkError || null;
this.message = errorMessage || generateErrorMessage(this);
Expand Down
15 changes: 11 additions & 4 deletions src/link/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@ export interface ExecutionPatchInitialResult<
extensions?: TExtensions;
}

export interface IncrementalPayload<
TData,
TExtensions,
> {
export interface IncrementalPayload<TData, TExtensions> {
// data and path must both be present
// https://github.com/graphql/graphql-spec/pull/742/files#diff-98d0cd153b72b63c417ad4238e8cc0d3385691ccbde7f7674bc0d2a718b896ecR288-R293
data: TData | null;
Expand All @@ -48,6 +45,16 @@ export interface ExecutionPatchIncrementalResult<
extensions?: never;
}

export interface ApolloPayloadResult<
TData = Record<string, any>,
TExtensions = Record<string, any>
> {
payload: SingleExecutionResult | ExecutionPatchResult | null;
// Transport layer errors (as distinct from GraphQL or NetworkErrors),
// these are fatal errors that will include done: true.
errors?: ReadonlyArray<Error | string>;
}

export type ExecutionPatchResult<
TData = Record<string, any>,
TExtensions = Record<string, any>
Expand Down
Loading

0 comments on commit cdb98ae

Please sign in to comment.