diff --git a/src/__testUtils__/resolveOnNextTick.ts b/src/__testUtils__/resolveOnNextTick.ts index 6dd50b3982..35d065578e 100644 --- a/src/__testUtils__/resolveOnNextTick.ts +++ b/src/__testUtils__/resolveOnNextTick.ts @@ -1,3 +1,7 @@ -export function resolveOnNextTick(): Promise { - return Promise.resolve(undefined); +export function resolveOnNextTick(): Promise; +export function resolveOnNextTick(value: T): Promise; +export function resolveOnNextTick( + value: unknown = undefined, +): Promise { + return Promise.resolve(value); } diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index c29b4ae60d..f6a2017fa7 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -4,6 +4,7 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; +import type { IAbortSignal, IEvent } from '../../jsutils/AbortController.js'; import { inspect } from '../../jsutils/inspect.js'; import { Kind } from '../../language/kinds.js'; @@ -222,6 +223,7 @@ describe('Execute: Handles basic execution tasks', () => { 'rootValue', 'operation', 'variableValues', + 'signal', ); const operation = document.definitions[0]; @@ -1317,4 +1319,197 @@ describe('Execute: Handles basic execution tasks', () => { expect(result).to.deep.equal({ data: { foo: { bar: 'bar' } } }); expect(possibleTypes).to.deep.equal([fooObject]); }); + + /* c8 ignore start */ + if (typeof AbortController !== 'undefined') { + it('stops execution and throws an error when signal is aborted', async () => { + // TODO: use real Event once we can finally drop node14 support + class MockAbortEvent implements IEvent { + cancelable = false; + bubbles = false; + composed = false; + currentTarget = null; + cancelBubble = false; + defaultPrevented = false; + isTrusted = true; + returnValue = false; + srcElement = null; + type = 'abort'; + eventPhase = 0; + timeStamp = 0; + AT_TARGET = 0; + BUBBLING_PHASE = 0; + CAPTURING_PHASE = 0; + NONE = 0; + + target: IAbortSignal; + + constructor(abortSignal: IAbortSignal) { + this.target = abortSignal; + } + + composedPath = () => { + throw new Error('Not mocked!'); + }; + + initEvent = () => { + throw new Error('Not mocked!'); + }; + + preventDefault = () => { + throw new Error(''); + }; + + stopImmediatePropagation = () => { + throw new Error(''); + }; + + stopPropagation = () => { + throw new Error(''); + }; + } + + class MockAbortSignal implements IAbortSignal { + aborted: boolean = false; + onabort: ((ev: IEvent) => any) | null = null; + reason: unknown; + + throwIfAborted() { + if (this.aborted) { + throw this.reason; + } + } + + addEventListener(type: string, cb: unknown) { + expect(type).to.equal('abort'); + expect(this.onabort).to.equal(null); + expect(cb).to.be.a('function'); + this.onabort = cb as any; + } + + removeEventListener(type: string, cb: unknown) { + expect(type).to.equal('abort'); + expect(cb).to.be.a('function'); + this.onabort = null; + } + + dispatchEvent(event: IEvent): boolean { + expect(this.onabort).to.be.a('function'); + this.onabort?.(event); + return true; + } + + dispatchMockAbortEvent(reason?: unknown) { + this.reason = reason; + mockAbortSignal.dispatchEvent(new MockAbortEvent(this)); + } + } + + const mockAbortSignal = new MockAbortSignal(); + + const TestType: GraphQLObjectType = new GraphQLObjectType({ + name: 'TestType', + fields: () => ({ + resolveOnNextTick: { + type: TestType, + resolve: () => resolveOnNextTick({}), + }, + string: { + type: GraphQLString, + args: { + value: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: (_, { value }) => value, + }, + abortExecution: { + type: GraphQLString, + resolve: () => { + const abortError = new Error('Custom abort error'); + mockAbortSignal.dispatchMockAbortEvent(abortError); + return 'aborted'; + }, + }, + shouldNotBeResolved: { + type: GraphQLString, + /* c8 ignore next */ + resolve: () => 'This should not be executed!', + }, + }), + }); + + const schema = new GraphQLSchema({ + query: TestType, + }); + + const document = parse(` + query { + value1: string(value: "1") + resolveOnNextTick { + value2: string(value: "2") + resolveOnNextTick { + resolveOnNextTick { + shouldNotBeResolved + } + abortExecution + } + } + alternativeBranch: resolveOnNextTick { + value3: string(value: "3") + resolveOnNextTick { + shouldNotBeResolved + } + } + } + `); + + const result = await execute({ + schema, + document, + signal: mockAbortSignal, + }); + + expectJSON(result).toDeepEqual({ + data: { + value1: '1', + resolveOnNextTick: { + value2: '2', + resolveOnNextTick: { + resolveOnNextTick: { + shouldNotBeResolved: null, + }, + abortExecution: 'aborted', + }, + }, + alternativeBranch: { + value3: '3', + resolveOnNextTick: { + shouldNotBeResolved: null, + }, + }, + }, + errors: [ + { + message: 'Custom abort error', + path: [ + 'alternativeBranch', + 'resolveOnNextTick', + 'shouldNotBeResolved', + ], + locations: [{ line: 16, column: 13 }], + }, + { + message: 'Custom abort error', + path: [ + 'resolveOnNextTick', + 'resolveOnNextTick', + 'resolveOnNextTick', + 'shouldNotBeResolved', + ], + locations: [{ line: 8, column: 15 }], + }, + ], + }); + }); + } + /* c8 ignore stop */ }); diff --git a/src/execution/__tests__/mapAsyncIterable-test.ts b/src/execution/__tests__/mapAsyncIterable-test.ts index 0b26ab7327..f3d98af841 100644 --- a/src/execution/__tests__/mapAsyncIterable-test.ts +++ b/src/execution/__tests__/mapAsyncIterable-test.ts @@ -3,6 +3,8 @@ import { describe, it } from 'mocha'; import { expectPromise } from '../../__testUtils__/expectPromise.js'; +import { noop } from '../../jsutils/AbortController.js'; + import { mapAsyncIterable } from '../mapAsyncIterable.js'; /* eslint-disable @typescript-eslint/require-await */ @@ -14,15 +16,24 @@ describe('mapAsyncIterable', () => { yield 3; } - const doubles = mapAsyncIterable(source(), (x) => x + x); + let calledFinishedCallback = false; + const doubles = mapAsyncIterable( + source(), + (x) => x + x, + () => { + calledFinishedCallback = true; + }, + ); expect(await doubles.next()).to.deep.equal({ value: 2, done: false }); expect(await doubles.next()).to.deep.equal({ value: 4, done: false }); expect(await doubles.next()).to.deep.equal({ value: 6, done: false }); + expect(calledFinishedCallback).to.equal(false); expect(await doubles.next()).to.deep.equal({ value: undefined, done: true, }); + expect(calledFinishedCallback).to.equal(true); }); it('maps over async iterable', async () => { @@ -44,15 +55,24 @@ describe('mapAsyncIterable', () => { }, }; - const doubles = mapAsyncIterable(iterable, (x) => x + x); + let calledFinishedCallback = false; + const doubles = mapAsyncIterable( + iterable, + (x) => x + x, + () => { + calledFinishedCallback = true; + }, + ); expect(await doubles.next()).to.deep.equal({ value: 2, done: false }); expect(await doubles.next()).to.deep.equal({ value: 4, done: false }); expect(await doubles.next()).to.deep.equal({ value: 6, done: false }); + expect(calledFinishedCallback).to.equal(false); expect(await doubles.next()).to.deep.equal({ value: undefined, done: true, }); + expect(calledFinishedCallback).to.equal(true); }); it('compatible with for-await-of', async () => { @@ -62,12 +82,21 @@ describe('mapAsyncIterable', () => { yield 3; } - const doubles = mapAsyncIterable(source(), (x) => x + x); + let calledFinishedCallback = false; + const doubles = mapAsyncIterable( + source(), + (x) => x + x, + () => { + calledFinishedCallback = true; + }, + ); const result = []; for await (const x of doubles) { result.push(x); + expect(calledFinishedCallback).to.equal(false); } + expect(calledFinishedCallback).to.equal(true); expect(result).to.deep.equal([2, 4, 6]); }); @@ -78,7 +107,11 @@ describe('mapAsyncIterable', () => { yield 3; } - const doubles = mapAsyncIterable(source(), (x) => Promise.resolve(x + x)); + const doubles = mapAsyncIterable( + source(), + (x) => Promise.resolve(x + x), + noop, + ); expect(await doubles.next()).to.deep.equal({ value: 2, done: false }); expect(await doubles.next()).to.deep.equal({ value: 4, done: false }); @@ -102,7 +135,7 @@ describe('mapAsyncIterable', () => { } } - const doubles = mapAsyncIterable(source(), (x) => x + x); + const doubles = mapAsyncIterable(source(), (x) => x + x, noop); expect(await doubles.next()).to.deep.equal({ value: 2, done: false }); expect(await doubles.next()).to.deep.equal({ value: 4, done: false }); @@ -141,7 +174,7 @@ describe('mapAsyncIterable', () => { }, }; - const doubles = mapAsyncIterable(iterable, (x) => x + x); + const doubles = mapAsyncIterable(iterable, (x) => x + x, noop); expect(await doubles.next()).to.deep.equal({ value: 2, done: false }); expect(await doubles.next()).to.deep.equal({ value: 4, done: false }); @@ -166,7 +199,7 @@ describe('mapAsyncIterable', () => { } } - const doubles = mapAsyncIterable(source(), (x) => x + x); + const doubles = mapAsyncIterable(source(), (x) => x + x, noop); expect(await doubles.next()).to.deep.equal({ value: 'aa', done: false }); expect(await doubles.next()).to.deep.equal({ value: 'bb', done: false }); @@ -205,7 +238,7 @@ describe('mapAsyncIterable', () => { }, }; - const doubles = mapAsyncIterable(iterable, (x) => x + x); + const doubles = mapAsyncIterable(iterable, (x) => x + x, noop); expect(await doubles.next()).to.deep.equal({ value: 2, done: false }); expect(await doubles.next()).to.deep.equal({ value: 4, done: false }); @@ -228,7 +261,7 @@ describe('mapAsyncIterable', () => { } } - const doubles = mapAsyncIterable(source(), (x) => x + x); + const doubles = mapAsyncIterable(source(), (x) => x + x, noop); expect(await doubles.next()).to.deep.equal({ value: 2, done: false }); expect(await doubles.next()).to.deep.equal({ value: 4, done: false }); @@ -255,7 +288,7 @@ describe('mapAsyncIterable', () => { throw new Error('Goodbye'); } - const doubles = mapAsyncIterable(source(), (x) => x + x); + const doubles = mapAsyncIterable(source(), (x) => x + x, noop); expect(await doubles.next()).to.deep.equal({ value: 'HelloHello', @@ -280,7 +313,7 @@ describe('mapAsyncIterable', () => { } } - const throwOver1 = mapAsyncIterable(source(), mapper); + const throwOver1 = mapAsyncIterable(source(), mapper, noop); expect(await throwOver1.next()).to.deep.equal({ value: 1, done: false }); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index a19a51a217..e17dd37e89 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -1,3 +1,8 @@ +import type { + IAbortController, + IAbortSignal, + IEvent, +} from '../jsutils/AbortController.js'; import { inspect } from '../jsutils/inspect.js'; import { invariant } from '../jsutils/invariant.js'; import { isAsyncIterable } from '../jsutils/isAsyncIterable.js'; @@ -141,6 +146,7 @@ export interface ExecutionContext { typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; incrementalPublisher: IncrementalPublisher; + executionController: ExecutionController; } export interface ExecutionArgs { @@ -153,6 +159,7 @@ export interface ExecutionArgs { fieldResolver?: Maybe>; typeResolver?: Maybe>; subscribeFieldResolver?: Maybe>; + signal?: IAbortSignal | undefined; } export interface StreamUsage { @@ -261,6 +268,7 @@ function executeImpl( incrementalPublisher.buildErrorResponse(initialResultRecord, error), ); } + return incrementalPublisher.buildDataResponse(initialResultRecord, data); } catch (error) { return incrementalPublisher.buildErrorResponse(initialResultRecord, error); @@ -305,6 +313,7 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, + signal, } = args; // If the schema used for execution is invalid, throw an error. @@ -369,9 +378,44 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, incrementalPublisher: new IncrementalPublisher(), + executionController: new ExecutionController(signal), }; } +class ExecutionController { + /** For performance reason we can't use `signal.isAborted` so we cache it here */ + isAborted: boolean = false; + + private readonly _passedInAbortSignal: IAbortSignal | undefined; + + // We don't have AbortController in node 14 so we need to use this hack + // It can be removed once we drop support for node 14 + /* c8 ignore start */ + private readonly _abortController: IAbortController | undefined = + typeof AbortController !== 'undefined' + ? (new AbortController() as IAbortController) + : undefined; + /* c8 ignore stop */ + + constructor(signal?: IAbortSignal) { + this._passedInAbortSignal = signal; + this._passedInAbortSignal?.addEventListener('abort', this._abortCB); + } + + get signal(): IAbortSignal | undefined { + return this._abortController?.signal; + } + + abort(reason?: unknown) { + this._passedInAbortSignal?.removeEventListener('abort', this._abortCB); + this._abortController?.abort(reason); + this.isAborted = true; + } + + private readonly _abortCB = (event: IEvent) => + this.abort(event.target.reason); +} + function buildPerEventExecutionContext( exeContext: ExecutionContext, payload: unknown, @@ -379,6 +423,9 @@ function buildPerEventExecutionContext( return { ...exeContext, rootValue: payload, + executionController: new ExecutionController( + exeContext.executionController.signal, + ), }; } @@ -610,6 +657,10 @@ function executeField( // Get the resolve function, regardless of if its result is normal or abrupt (error). try { + if (exeContext.executionController.isAborted) { + exeContext.executionController.signal?.throwIfAborted(); + } + // Build a JS object of arguments from the field.arguments AST, using the // variables scope to fulfill any variable references. // TODO: find a way to memoize, in case this field is within a List type. @@ -705,6 +756,7 @@ export function buildResolveInfo( rootValue: exeContext.rootValue, operation: exeContext.operation, variableValues: exeContext.variableValues, + signal: exeContext.executionController.signal, }; } @@ -1683,6 +1735,16 @@ export function subscribe( ): PromiseOrValue< AsyncGenerator | ExecutionResult > { + // Until we have execution cancelling support in Subscriptions, + // throw an error if client provides abort signal. + /* c8 ignore start */ + if (args.signal) { + return { + errors: [new GraphQLError('Subscriptions do not support abort signals.')], + }; + } + /* c8 ignore stop */ + // If a valid execution context cannot be created due to incorrect arguments, // a "Response" with only errors is returned. const exeContext = buildExecutionContext(args); @@ -1726,6 +1788,7 @@ function mapSourceToResponse( // ExperimentalIncrementalExecutionResults when // exeContext.operation is 'subscription'. ) as ExecutionResult, + () => exeContext.executionController.abort(), ); } @@ -1834,6 +1897,14 @@ function executeSubscription( ); try { + // Until we have execution cancelling support in Subscriptions, + // ignore test coverage. + /* c8 ignore start */ + if (exeContext.executionController.isAborted) { + exeContext.executionController.signal?.throwIfAborted(); + } + /* c8 ignore stop */ + // Implements the "ResolveFieldEventStream" algorithm from GraphQL specification. // It differs from "ResolveFieldValue" due to providing a different `resolveFn`. diff --git a/src/execution/mapAsyncIterable.ts b/src/execution/mapAsyncIterable.ts index 0f6fd78c2d..9616c6c2d0 100644 --- a/src/execution/mapAsyncIterable.ts +++ b/src/execution/mapAsyncIterable.ts @@ -6,7 +6,8 @@ import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js'; */ export function mapAsyncIterable( iterable: AsyncGenerator | AsyncIterable, - callback: (value: T) => PromiseOrValue, + valueCallback: (value: T) => PromiseOrValue, + finallyCallback: () => void, ): AsyncGenerator { const iterator = iterable[Symbol.asyncIterator](); @@ -14,11 +15,12 @@ export function mapAsyncIterable( result: IteratorResult, ): Promise> { if (result.done) { + finallyCallback(); return result; } try { - return { value: await callback(result.value), done: false }; + return { value: await valueCallback(result.value), done: false }; } catch (error) { /* c8 ignore start */ // FIXME: add test case @@ -40,14 +42,17 @@ export function mapAsyncIterable( }, async return(): Promise> { // If iterator.return() does not exist, then type R must be undefined. - return typeof iterator.return === 'function' - ? mapResult(await iterator.return()) - : { value: undefined as any, done: true }; + const result = + typeof iterator.return === 'function' + ? await iterator.return() + : { value: undefined as any, done: true }; + return mapResult(result); }, async throw(error?: unknown) { if (typeof iterator.throw === 'function') { return mapResult(await iterator.throw(error)); } + finallyCallback(); throw error; }, [Symbol.asyncIterator]() { diff --git a/src/graphql.ts b/src/graphql.ts index 0c8187ae0e..f3e71e7016 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -1,3 +1,4 @@ +import type { IAbortSignal } from './jsutils/AbortController.js'; import { isPromise } from './jsutils/isPromise.js'; import type { Maybe } from './jsutils/Maybe.js'; import type { PromiseOrValue } from './jsutils/PromiseOrValue.js'; @@ -57,6 +58,9 @@ import type { ExecutionResult } from './execution/IncrementalPublisher.js'; * A type resolver function to use when none is provided by the schema. * If not provided, the default type resolver is used (which looks for a * `__typename` field or alternatively calls the `isTypeOf` method). + * signal: + * An AbortSignal that can be used to abort the execution of the query. + * If the signal is aborted, the execution will stop and an abort error will be thrown. */ export interface GraphQLArgs { schema: GraphQLSchema; @@ -67,6 +71,7 @@ export interface GraphQLArgs { operationName?: Maybe; fieldResolver?: Maybe>; typeResolver?: Maybe>; + signal?: IAbortSignal; } export function graphql(args: GraphQLArgs): Promise { @@ -101,6 +106,7 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { operationName, fieldResolver, typeResolver, + signal, } = args; // Validate Schema @@ -133,5 +139,6 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { operationName, fieldResolver, typeResolver, + signal, }); } diff --git a/src/jsutils/AbortController.ts b/src/jsutils/AbortController.ts new file mode 100644 index 0000000000..6ecf3182c8 --- /dev/null +++ b/src/jsutils/AbortController.ts @@ -0,0 +1,25 @@ +export interface IAbortController { + abort: (reason?: unknown) => void; + signal: IAbortSignal; +} + +export interface IEvent { + target: { reason: unknown }; +} + +type EventListener = (event: IEvent) => void; + +export interface IAbortSignal { + readonly aborted: boolean; + onabort: ((this: IAbortSignal, ev: IEvent) => any) | null; + readonly reason: any; + throwIfAborted: () => void; + addEventListener: (type: string, listener: EventListener) => void; + removeEventListener: (type: string, listener: EventListener) => void; +} + +// C8 ignore wasn't working for this file so adding noop function to it, +// to get tests coverage passing +export function noop(): void { + return undefined; +} diff --git a/src/type/definition.ts b/src/type/definition.ts index 0ca4152bd2..7e4b04d28d 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -1,3 +1,4 @@ +import type { IAbortSignal } from '../jsutils/AbortController.js'; import { devAssert } from '../jsutils/devAssert.js'; import { didYouMean } from '../jsutils/didYouMean.js'; import { identityFunc } from '../jsutils/identityFunc.js'; @@ -896,6 +897,12 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: { [variable: string]: unknown }; + + /** + * Note: signal is undefined only if execution environment doesn't support + * AbortController (e.g. node14 without polyfill). + */ + readonly signal: IAbortSignal | undefined; } /**