Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wip: sse single connection mode [non-spec compliant for reasons] #3205

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
10 changes: 10 additions & 0 deletions .changeset/graphql-yoga-3197-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'graphql-yoga': patch
---
dependencies updates:
- Updated dependency [`@graphql-tools/executor@^1.2.1`
↗︎](https://www.npmjs.com/package/@graphql-tools/executor/v/1.2.1) (from `^1.0.0`, in
`dependencies`)
- Updated dependency [`@whatwg-node/server@^0.9.27`
↗︎](https://www.npmjs.com/package/@whatwg-node/server/v/0.9.27) (from `^0.9.1`, in
`dependencies`)
8 changes: 8 additions & 0 deletions .changeset/green-badgers-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'graphql-yoga': minor
---

Abort GraphQL execution when HTTP request is canceled.

The execution of subsequent GraphQL resolvers is now aborted if the incoming HTTP request is canceled from the client side.
This reduces the load of your API in case incoming requests with deep GraphQL operation selection sets are canceled.
2 changes: 1 addition & 1 deletion examples/apollo-federation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
},
"devDependencies": {
"@apollo/gateway": "2.4.7",
"@whatwg-node/fetch": "^0.9.0"
"@whatwg-node/fetch": "^0.9.17"
}
}
2 changes: 1 addition & 1 deletion examples/bun/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
"graphql-yoga": "5.2.0"
},
"devDependencies": {
"@whatwg-node/fetch": "^0.9.0"
"@whatwg-node/fetch": "^0.9.17"
}
}
2 changes: 1 addition & 1 deletion examples/cloudflare-modules/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "4.20230518.0",
"@whatwg-node/fetch": "^0.9.7",
"@whatwg-node/fetch": "^0.9.17",
"typescript": "5.1.6",
"wrangler": "3.1.0"
}
Expand Down
2 changes: 1 addition & 1 deletion examples/error-handling/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"start": "ts-node src/index.ts"
},
"dependencies": {
"@whatwg-node/fetch": "^0.9.7",
"@whatwg-node/fetch": "^0.9.17",
"graphql": "^16.1.0",
"graphql-yoga": "5.2.0"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/file-upload-nextjs-pothos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"devDependencies": {
"@types/react": "^18.0.17",
"@whatwg-node/fetch": "^0.9.7",
"@whatwg-node/fetch": "^0.9.17",
"eslint": "8.42.0",
"eslint-config-next": "13.4.12",
"typescript": "5.1.6"
Expand Down
2 changes: 1 addition & 1 deletion examples/file-upload-nexus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"nexus": "^1.3.0"
},
"devDependencies": {
"@whatwg-node/fetch": "^0.9.7",
"@whatwg-node/fetch": "^0.9.17",
"ts-node": "10.9.1",
"typescript": "5.1.6"
}
Expand Down
2 changes: 1 addition & 1 deletion examples/file-upload/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
"ts-node": "10.9.1"
},
"devDependencies": {
"@whatwg-node/fetch": "^0.9.0"
"@whatwg-node/fetch": "^0.9.17"
}
}
2 changes: 1 addition & 1 deletion examples/generic-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"devDependencies": {
"@types/node": "18.16.16",
"@whatwg-node/fetch": "^0.9.7",
"@whatwg-node/fetch": "^0.9.17",
"cross-env": "7.0.3",
"ts-node": "10.9.1",
"ts-node-dev": "2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion examples/hapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"graphql-yoga": "5.2.0"
},
"devDependencies": {
"@whatwg-node/fetch": "^0.9.7",
"@whatwg-node/fetch": "^0.9.17",
"ts-node": "10.9.1",
"typescript": "5.1.6"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,9 @@ describe('NextJS Legacy Pages', () => {
...Object.fromEntries(response.headers.entries()),
date: null,
'keep-alive': null,
}).toMatchInlineSnapshot(`
{
"connection": "close",
"content-length": "79",
"content-type": "application/json; charset=utf-8",
"date": null,
"keep-alive": null,
"vary": "Accept-Encoding",
}
`);
}).toMatchObject({
'content-type': 'application/json; charset=utf-8',
});

const json = await response.json();

Expand Down
2 changes: 1 addition & 1 deletion examples/response-cache/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"devDependencies": {
"@types/node": "18.16.16",
"@whatwg-node/fetch": "^0.9.7",
"@whatwg-node/fetch": "^0.9.17",
"cross-env": "7.0.3",
"ts-node": "10.9.1",
"ts-node-dev": "2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion examples/service-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"graphql-yoga": "5.2.0"
},
"devDependencies": {
"@whatwg-node/fetch": "^0.9.7",
"@whatwg-node/fetch": "^0.9.17",
"typescript": "5.1.6",
"wrangler": "3.1.0"
}
Expand Down
2 changes: 1 addition & 1 deletion examples/uwebsockets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"devDependencies": {
"@types/ws": "8.5.4",
"@whatwg-node/fetch": "^0.9.7",
"@whatwg-node/fetch": "^0.9.17",
"ws": "8.13.0"
}
}
7 changes: 4 additions & 3 deletions packages/event-target/redis-event-target/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@
if (callbacks === undefined) {
callbacks = new Set();
callbacksForTopic.set(topic, callbacks);

subscribeClient.subscribe(topic);
callbacks.add(callback);
return subscribeClient.subscribe(topic).then(() => undefined);
}
callbacks.add(callback);
return;
}

function removeCallback(topic: string, callback: (event: TEvent) => void) {
Expand All @@ -61,11 +62,11 @@
}

return {
addEventListener(topic, callbackOrOptions) {

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / integration / nodejs v18 / graphql v16.6.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / integration / nodejs v18 / graphql v15.8.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / leaks / nodejs v18 / graphql v16.6.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / apollo-federation-compatibility

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / unit / nodejs v18 / graphql v15.8.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / leaks / nodejs v18 / graphql v15.8.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / unit / nodejs v18 / graphql v16.6.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / integration / nodejs v20 / graphql v15.8.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / integration / nodejs v20 / graphql v16.6.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / benchmarks

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / e2e / cf-worker

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / leaks / nodejs v21 / graphql v15.8.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / e2e / cf-modules

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / esm

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / typecheck

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / leaks / nodejs v21 / graphql v16.6.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / e2e / aws-lambda

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / e2e / azure-function

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / integration / nodejs v21 / graphql v16.6.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / integration / nodejs v21 / graphql v15.8.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / unit / nodejs v20 / graphql v15.8.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / unit / nodejs v20 / graphql v16.6.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / unit / nodejs v21 / graphql v15.8.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / unit / nodejs v21 / graphql v16.6.0

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / alpha / snapshot

Not all code paths return a value.

Check failure on line 65 in packages/event-target/redis-event-target/src/index.ts

View workflow job for this annotation

GitHub Actions / release-candidate / snapshot

Not all code paths return a value.
if (callbackOrOptions != null) {
const callback =
'handleEvent' in callbackOrOptions ? callbackOrOptions.handleEvent : callbackOrOptions;
addCallback(topic, callback);
return addCallback(topic, callback);
}
},
dispatchEvent(event: TEvent) {
Expand Down
5 changes: 4 additions & 1 deletion packages/event-target/typed-event-target/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ export type TypedEventListenerOrEventListenerObject<TEvent extends CustomEvent>
| TypedEventListenerObject<TEvent>;

export interface TypedEventTarget<TEvent extends CustomEvent> extends EventTarget {
/**
* If the return value is a promise, the promise will resolve once the event listener has been set up.
*/
addEventListener(
type: string,
callback: TypedEventListenerOrEventListenerObject<TEvent> | null,
options?: AddEventListenerOptions | boolean,
): void;
): void | Promise<void>;
dispatchEvent(event: TEvent): boolean;
removeEventListener(
type: string,
Expand Down
65 changes: 64 additions & 1 deletion packages/graphql-yoga/__tests__/error-masking.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { inspect } from '@graphql-tools/utils';
import { createGraphQLError, createSchema, createYoga } from '../src/index.js';
import { createGraphQLError, createLogger, createSchema, createYoga } from '../src/index.js';
import { eventStream } from './utilities.js';

describe('error masking', () => {
Expand Down Expand Up @@ -693,4 +693,67 @@ describe('error masking', () => {

expect(counter).toBe(3);
});

it('AbortSignal cancelation within resolver is not treated as a execution request cancelation by the yoga error handler', async () => {
const schema = createSchema({
typeDefs: /* GraphQL */ `
type Query {
root: A!
}
type A {
a: String!
}
`,
resolvers: {
Query: {
async root() {
/** we just gonna throw a DOMException here to see what happens */
const abortController = new AbortController();
abortController.abort();
expect(abortController.signal.reason?.constructor.name).toBe('DOMException');
throw abortController.signal.reason;
},
},
},
});

const logger = createLogger('silent');
const error = jest.fn();
const debug = jest.fn();
logger.debug = debug;
logger.error = error;
const yoga = createYoga({ schema, logging: logger });

const result = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
body: JSON.stringify({ query: '{ root { a } }' }),
headers: {
'Content-Type': 'application/json',
},
});

expect(result.status).toEqual(200);
expect(await result.json()).toEqual({
data: null,
errors: [
{
locations: [
{
column: 3,
line: 1,
},
],
message: 'Unexpected error.',
path: ['root'],
},
],
});
// in the future this might change as we decide to within our graphql-tools/executor error handler treat DOMException similar to a normal Error
expect(error.mock.calls).toMatchObject([[{ message: 'Unexpected error value: {}' }]]);
expect(debug.mock.calls).toEqual([
['Parsing request to extract GraphQL parameters'],
['Processing GraphQL Parameters'],
['Processing GraphQL Parameters done.'],
]);
});
});
70 changes: 70 additions & 0 deletions packages/graphql-yoga/__tests__/request-cancellation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { createSchema, createYoga } from '../src/index';

describe('request cancellation', () => {
it('request cancellation stops invocation of subsequent resolvers', async () => {
const rootResolverGotInvokedD = createDeferred();
const requestGotCancelledD = createDeferred();
let aResolverGotInvoked = false;
let rootResolverGotInvoked = false;
const schema = createSchema({
typeDefs: /* GraphQL */ `
type Query {
root: A!
}
type A {
a: String!
}
`,
resolvers: {
Query: {
async root() {
rootResolverGotInvoked = true;
rootResolverGotInvokedD.resolve();
await requestGotCancelledD.promise;
return { a: 'a' };
},
},
A: {
a() {
aResolverGotInvoked = true;
return 'a';
},
},
},
});
const yoga = createYoga({ schema });
const abortController = new AbortController();
const promise = Promise.resolve(
yoga.fetch('http://yoga/graphql', {
method: 'POST',
body: JSON.stringify({ query: '{ root { a } }' }),
headers: {
'Content-Type': 'application/json',
},
signal: abortController.signal,
}),
);
await rootResolverGotInvokedD.promise;
abortController.abort();
requestGotCancelledD.resolve();
await expect(promise).rejects.toThrow('This operation was aborted');
expect(rootResolverGotInvoked).toBe(true);
expect(aResolverGotInvoked).toBe(false);
await requestGotCancelledD.promise;
});
});

type Deferred<T = void> = {
resolve: (value: T) => void;
reject: (value: unknown) => void;
promise: Promise<T>;
};

function createDeferred<T = void>(): Deferred<T> {
const d = {} as Deferred<T>;
d.promise = new Promise<T>((resolve, reject) => {
d.resolve = resolve;
d.reject = reject;
});
return d;
}