diff --git a/integrationTests/ts/package.json b/integrationTests/ts/package.json index e003b253fd..f2bea673d8 100644 --- a/integrationTests/ts/package.json +++ b/integrationTests/ts/package.json @@ -14,6 +14,6 @@ "typescript-4.7": "npm:typescript@4.7.x", "typescript-4.8": "npm:typescript@4.8.x", "typescript-4.9": "npm:typescript@4.9.x", - "typescript-4.9": "npm:typescript@5.0.x" + "typescript-5.0": "npm:typescript@5.0.x" } } diff --git a/integrationTests/ts/tsconfig.json b/integrationTests/ts/tsconfig.json index e8505c2bb9..2f3b87af16 100644 --- a/integrationTests/ts/tsconfig.json +++ b/integrationTests/ts/tsconfig.json @@ -1,7 +1,13 @@ { "compilerOptions": { "module": "commonjs", - "lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"], + "lib": [ + "es2019", + "es2020.promise", + "es2020.bigint", + "es2020.string", + "dom" // Workaround for missing web-compatible globals in `@types/node` + ], "noEmit": true, "types": [], "strict": true, diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index c29b4ae60d..1eba3a8669 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -635,6 +635,299 @@ describe('Execute: Handles basic execution tasks', () => { expect(isAsyncResolverFinished).to.equal(true); }); + it('exits early on early abort', () => { + let isExecuted = false; + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + field: { + type: GraphQLString, + /* c8 ignore next 3 */ + resolve() { + isExecuted = true; + }, + }, + }, + }), + }); + + const document = parse(` + { + field + } + `); + + const abortController = new AbortController(); + abortController.abort(); + + const result = execute({ + schema, + document, + abortSignal: abortController.signal, + }); + + expect(isExecuted).to.equal(false); + expectJSON(result).toDeepEqual({ + data: { field: null }, + errors: [ + { + message: 'This operation was aborted', + locations: [{ line: 3, column: 9 }], + path: ['field'], + }, + ], + }); + }); + + it('exits early on abort mid-execution', async () => { + let isExecuted = false; + + const asyncObjectType = new GraphQLObjectType({ + name: 'AsyncObject', + fields: { + field: { + type: GraphQLString, + /* c8 ignore next 3 */ + resolve() { + isExecuted = true; + }, + }, + }, + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + asyncObject: { + type: asyncObjectType, + async resolve() { + await resolveOnNextTick(); + return {}; + }, + }, + }, + }), + }); + + const document = parse(` + { + asyncObject { + field + } + } + `); + + const abortController = new AbortController(); + + const result = execute({ + schema, + document, + abortSignal: abortController.signal, + }); + + abortController.abort(); + + expect(isExecuted).to.equal(false); + expectJSON(await result).toDeepEqual({ + data: { asyncObject: { field: null } }, + errors: [ + { + message: 'This operation was aborted', + locations: [{ line: 4, column: 11 }], + path: ['asyncObject', 'field'], + }, + ], + }); + expect(isExecuted).to.equal(false); + }); + + it('exits early on abort mid-resolver', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + asyncField: { + type: GraphQLString, + async resolve(_parent, _args, _context, _info, abortSignal) { + await resolveOnNextTick(); + abortSignal?.throwIfAborted(); + }, + }, + }, + }), + }); + + const document = parse(` + { + asyncField + } + `); + + const abortController = new AbortController(); + + const result = execute({ + schema, + document, + abortSignal: abortController.signal, + }); + + abortController.abort(); + + expectJSON(await result).toDeepEqual({ + data: { asyncField: null }, + errors: [ + { + message: 'This operation was aborted', + locations: [{ line: 3, column: 9 }], + path: ['asyncField'], + }, + ], + }); + }); + + it('exits early on abort mid-nested resolver', async () => { + const syncObjectType = new GraphQLObjectType({ + name: 'SyncObject', + fields: { + asyncField: { + type: GraphQLString, + async resolve(_parent, _args, _context, _info, abortSignal) { + await resolveOnNextTick(); + abortSignal?.throwIfAborted(); + }, + }, + }, + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + syncObject: { + type: syncObjectType, + resolve() { + return {}; + }, + }, + }, + }), + }); + + const document = parse(` + { + syncObject { + asyncField + } + } + `); + + const abortController = new AbortController(); + + const result = execute({ + schema, + document, + abortSignal: abortController.signal, + }); + + abortController.abort(); + + expectJSON(await result).toDeepEqual({ + data: { syncObject: { asyncField: null } }, + errors: [ + { + message: 'This operation was aborted', + locations: [{ line: 4, column: 11 }], + path: ['syncObject', 'asyncField'], + }, + ], + }); + }); + + it('exits early on error', async () => { + const objectType = new GraphQLObjectType({ + name: 'Object', + fields: { + nonNullNestedAsyncField: { + type: new GraphQLNonNull(GraphQLString), + async resolve() { + await resolveOnNextTick(); + throw new Error('Oops'); + }, + }, + nestedAsyncField: { + type: GraphQLString, + async resolve(_parent, _args, _context, _info, abortSignal) { + await resolveOnNextTick(); + abortSignal?.throwIfAborted(); + }, + }, + }, + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + object: { + type: objectType, + resolve() { + return {}; + }, + }, + asyncField: { + type: GraphQLString, + async resolve() { + await resolveOnNextTick(); + return 'asyncValue'; + }, + }, + }, + }), + }); + + const document = parse(` + { + object { + nonNullNestedAsyncField + nestedAsyncField + } + asyncField + } + `); + + const abortController = new AbortController(); + + const result = execute({ + schema, + document, + abortSignal: abortController.signal, + }); + + abortController.abort(); + + expectJSON(await result).toDeepEqual({ + data: { + object: null, + asyncField: 'asyncValue', + }, + errors: [ + { + message: 'This operation was aborted', + locations: [{ line: 5, column: 11 }], + path: ['object', 'nestedAsyncField'], + }, + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['object', 'nonNullNestedAsyncField'], + }, + ], + }); + }); + it('Full response path is included for non-nullable fields', () => { const A: GraphQLObjectType = new GraphQLObjectType({ name: 'A', diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index ce3b920895..f4756af8bc 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -151,6 +151,35 @@ describe('Execute: stream directive', () => { }, ]); }); + it('Can stream a list field that returns an async iterable', async () => { + const document = parse('{ scalarList @stream(initialCount: 1) }'); + const result = await complete(document, { + async *scalarList() { + yield await Promise.resolve('apple'); + yield await Promise.resolve('banana'); + yield await Promise.resolve('coconut'); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['apple'], + }, + hasNext: true, + }, + { + incremental: [{ items: ['banana'], path: ['scalarList', 1] }], + hasNext: true, + }, + { + incremental: [{ items: ['coconut'], path: ['scalarList', 2] }], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); it('Can use default value of initialCount', async () => { const document = parse('{ scalarList @stream }'); const result = await complete(document, { @@ -536,7 +565,7 @@ describe('Execute: stream directive', () => { }, ]); }); - it('Can stream a field that returns an async iterable', async () => { + it('Can stream an object field that returns an async iterable', async () => { const document = parse(` query { friendList @stream { @@ -770,6 +799,114 @@ describe('Execute: stream directive', () => { }, ]); }); + it('Handles error returned in async iterable after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(new Error('bad')); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ name: 'Luke', id: '1' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + errors: [ + { + message: 'bad', + locations: [{ line: 3, column: 9 }], + path: ['friendList', 1], + }, + ], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Handles null returned in list items after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + name + } + } + `); + const result = await complete(document, { + friendList: () => [friends[0], null], + }); + + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ name: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles null returned in async iterable list items after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + name + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(null); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ name: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); it('Handles null returned in non-null list items after initialCount is reached', async () => { const document = parse(` query { @@ -855,7 +992,7 @@ describe('Execute: stream directive', () => { }, ]); }); - it('Handles errors thrown by completeValue after initialCount is reached', async () => { + it('Handles errors thrown by leaf value completion after initialCount is reached', async () => { const document = parse(` query { scalarList @stream(initialCount: 1) @@ -889,7 +1026,41 @@ describe('Execute: stream directive', () => { }, ]); }); - it('Handles async errors thrown by completeValue after initialCount is reached', async () => { + it('Handles errors returned by leaf value completion after initialCount is reached', async () => { + const document = parse(` + query { + scalarList @stream(initialCount: 1) + } + `); + const result = await complete(document, { + scalarList: () => [friends[0].name, new Error('Oops')], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['Luke'], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['scalarList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 3, column: 9 }], + path: ['scalarList', 1], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles async errors thrown by leaf value completion after initialCount is reached', async () => { const document = parse(` query { friendList @stream(initialCount: 1) { @@ -940,6 +1111,189 @@ describe('Execute: stream directive', () => { }, ]); }); + it('Handles nested errors thrown by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + friendList: () => [ + { nonNullName: friends[0].name }, + { nonNullName: new Error('Oops') }, + { nonNullName: friends[1].name }, + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['friendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles nested errors thrown by completeValue after initialCount is reached for a non-nullable list', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + nonNullFriendList: () => [ + { nonNullName: friends[0].name }, + { nonNullName: new Error('Oops') }, + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + path: ['nonNullFriendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles nested errors thrown by completeValue after initialCount is reached from async iterable', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => new Error('Oops'), + }); + yield await Promise.resolve({ nonNullName: friends[1].name }); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['friendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Handles nested errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + async *nonNullFriendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => new Error('Oops'), + }); /* c8 ignore start */ + } /* c8 ignore stop */, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + path: ['nonNullFriendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); it('Handles nested async errors thrown by completeValue after initialCount is reached', async () => { const document = parse(` query { diff --git a/src/execution/execute.ts b/src/execution/execute.ts index af68c286e1..ad500e110b 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -1,3 +1,4 @@ +import { addAbortListener } from '../jsutils/addAbortListener.js'; import { inspect } from '../jsutils/inspect.js'; import { invariant } from '../jsutils/invariant.js'; import { isAsyncIterable } from '../jsutils/isAsyncIterable.js'; @@ -31,6 +32,7 @@ import type { GraphQLFieldResolver, GraphQLLeafType, GraphQLList, + GraphQLNullableOutputType, GraphQLObjectType, GraphQLOutputType, GraphQLResolveInfo, @@ -131,6 +133,7 @@ export interface ExecutionContext { typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; incrementalPublisher: IncrementalPublisher; + abortSignal: AbortSignal | undefined; } /** @@ -200,6 +203,7 @@ export interface ExecutionArgs { fieldResolver?: Maybe>; typeResolver?: Maybe>; subscribeFieldResolver?: Maybe>; + abortSignal?: AbortSignal; } const UNEXPECTED_EXPERIMENTAL_DIRECTIVES = @@ -388,6 +392,7 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, + abortSignal, } = args; // If the schema used for execution is invalid, throw an error. @@ -452,6 +457,7 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, incrementalPublisher: new IncrementalPublisher(), + abortSignal, }; } @@ -472,8 +478,14 @@ function executeOperation( exeContext: ExecutionContext, initialResultRecord: InitialResultRecord, ): PromiseOrValue> { - const { operation, schema, fragments, variableValues, rootValue } = - exeContext; + const { + operation, + schema, + fragments, + variableValues, + rootValue, + abortSignal, + } = exeContext; const rootType = schema.getRootType(operation.operation); if (rootType == null) { throw new GraphQLError( @@ -501,6 +513,7 @@ function executeOperation( path, groupedFieldSet, initialResultRecord, + abortSignal, ); break; case OperationTypeNode.MUTATION: @@ -511,6 +524,7 @@ function executeOperation( path, groupedFieldSet, initialResultRecord, + abortSignal, ); break; case OperationTypeNode.SUBSCRIPTION: @@ -523,6 +537,7 @@ function executeOperation( path, groupedFieldSet, initialResultRecord, + abortSignal, ); } @@ -534,6 +549,7 @@ function executeOperation( rootValue, patchGroupedFieldSet, initialResultRecord, + abortSignal, label, path, ); @@ -553,6 +569,7 @@ function executeFieldsSerially( path: Path | undefined, groupedFieldSet: GroupedFieldSet, incrementalDataRecord: InitialResultRecord, + abortSignal: AbortSignal | undefined, ): PromiseOrValue> { return promiseReduce( groupedFieldSet, @@ -565,6 +582,7 @@ function executeFieldsSerially( fieldGroup, fieldPath, incrementalDataRecord, + abortSignal, ); if (result === undefined) { return results; @@ -593,6 +611,7 @@ function executeFields( path: Path | undefined, groupedFieldSet: GroupedFieldSet, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal | undefined, ): PromiseOrValue> { const results = Object.create(null); let containsPromise = false; @@ -607,6 +626,7 @@ function executeFields( fieldGroup, fieldPath, incrementalDataRecord, + abortSignal, ); if (result !== undefined) { @@ -640,8 +660,9 @@ function executeFields( /** * Implements the "Executing fields" section of the spec * In particular, this function figures out the value that the field returns by - * calling its resolve function, then calls completeValue to complete promises, - * serialize scalars, or execute the sub-selection-set for objects. + * calling its resolve function, checks for promises, and then serializes leaf + * values or calls completeNonLeafValue to execute the sub-selection-set for + * objects and/or complete lists as necessary. */ function executeField( exeContext: ExecutionContext, @@ -650,6 +671,7 @@ function executeField( fieldGroup: FieldGroup, path: Path, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal | undefined, ): PromiseOrValue { const fieldName = fieldGroup[0].name.value; const fieldDef = exeContext.schema.getField(parentType, fieldName); @@ -668,6 +690,8 @@ function executeField( path, ); + let result; + let nullableType: GraphQLNullableOutputType; // Get the resolve function, regardless of if its result is normal or abrupt (error). try { // Build a JS object of arguments from the field.arguments AST, using the @@ -684,7 +708,11 @@ function executeField( // used to represent an authenticated user, or request-specific caches. const contextValue = exeContext.contextValue; - const result = resolveFn(source, args, contextValue, info); + if (abortSignal?.aborted) { + abortSignal.throwIfAborted(); + } + + result = resolveFn(source, args, contextValue, info, abortSignal); if (isPromise(result)) { return completePromisedValue( @@ -695,23 +723,89 @@ function executeField( path, result, incrementalDataRecord, + abortSignal, ); } - const completed = completeValue( + if (result instanceof Error) { + throw result; + } + + if (isNonNullType(returnType)) { + if (result == null) { + throw new Error( + `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, + ); + } + nullableType = returnType.ofType; + } else { + if (result == null) { + return null; + } + nullableType = returnType; + } + + if (isLeafType(nullableType)) { + return completeLeafValue(nullableType, result); + } + } catch (rawError) { + handleFieldError( + rawError, exeContext, returnType, fieldGroup, + path, + incrementalDataRecord, + ); + exeContext.incrementalPublisher.filter(path, incrementalDataRecord); + return null; + } + + const abortController = new AbortController(); + let removeAbortListener: (() => void) | undefined; + if (abortSignal !== undefined) { + removeAbortListener = addAbortListener(abortSignal, () => + abortController.abort(), + ); + } + let completed; + try { + completed = completeNonLeafValue( + exeContext, + nullableType, + fieldGroup, info, path, result, incrementalDataRecord, + abortController.signal, + ); + } catch (rawError) { + removeAbortListener?.(); + abortController.abort(); + handleFieldError( + rawError, + exeContext, + returnType, + fieldGroup, + path, + incrementalDataRecord, ); + exeContext.incrementalPublisher.filter(path, incrementalDataRecord); + return null; + } - if (isPromise(completed)) { - // Note: we don't rely on a `catch` method, but we do expect "thenable" - // to take a second callback for the error case. - return completed.then(undefined, (rawError) => { + if (isPromise(completed)) { + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + return completed.then( + (resolved) => { + removeAbortListener?.(); + return resolved; + }, + (rawError) => { + removeAbortListener?.(); + abortController.abort(); handleFieldError( rawError, exeContext, @@ -722,21 +816,12 @@ function executeField( ); exeContext.incrementalPublisher.filter(path, incrementalDataRecord); return null; - }); - } - return completed; - } catch (rawError) { - handleFieldError( - rawError, - exeContext, - returnType, - fieldGroup, - path, - incrementalDataRecord, + }, ); - exeContext.incrementalPublisher.filter(path, incrementalDataRecord); - return null; } + + removeAbortListener?.(); + return completed; } /** @@ -788,115 +873,75 @@ function handleFieldError( } /** - * Implements the instructions for completeValue as defined in the + * Implements the instructions for completing non-leaf values as defined in the * "Value Completion" section of the spec. * - * If the field type is Non-Null, then this recursively completes the value - * for the inner type. It throws a field error if that completion returns null, - * as per the "Nullability" section of the spec. - * * If the field type is a List, then this recursively completes the value * for the inner type on each item in the list. * - * If the field type is a Scalar or Enum, ensures the completed value is a legal - * value of the type by calling the `serialize` method of GraphQL type - * definition. - * * If the field is an abstract type, determine the runtime type of the value * and then complete based on that type * * Otherwise, the field type expects a sub-selection set, and will complete the * value by executing all sub-selections. */ -function completeValue( +function completeNonLeafValue( exeContext: ExecutionContext, - returnType: GraphQLOutputType, + nullableType: GraphQLNullableOutputType, fieldGroup: FieldGroup, info: GraphQLResolveInfo, path: Path, result: unknown, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal, ): PromiseOrValue { - // If result is an Error, throw a located error. - if (result instanceof Error) { - throw result; - } - - // If field type is NonNull, complete for inner type, and throw field error - // if result is null. - if (isNonNullType(returnType)) { - const completed = completeValue( - exeContext, - returnType.ofType, - fieldGroup, - info, - path, - result, - incrementalDataRecord, - ); - if (completed === null) { - throw new Error( - `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, - ); - } - return completed; - } - - // If result value is null or undefined then return null. - if (result == null) { - return null; - } - // If field type is List, complete each item in the list with the inner type - if (isListType(returnType)) { + if (isListType(nullableType)) { return completeListValue( exeContext, - returnType, + nullableType, fieldGroup, info, path, result, incrementalDataRecord, + abortSignal, ); } - // If field type is a leaf type, Scalar or Enum, serialize to a valid value, - // returning null if serialization is not possible. - if (isLeafType(returnType)) { - return completeLeafValue(returnType, result); - } - // If field type is an abstract type, Interface or Union, determine the // runtime Object type and complete for that type. - if (isAbstractType(returnType)) { + if (isAbstractType(nullableType)) { return completeAbstractValue( exeContext, - returnType, + nullableType, fieldGroup, info, path, result, incrementalDataRecord, + abortSignal, ); } // If field type is Object, execute and complete all sub-selections. - if (isObjectType(returnType)) { + if (isObjectType(nullableType)) { return completeObjectValue( exeContext, - returnType, + nullableType, fieldGroup, info, path, result, incrementalDataRecord, + abortSignal, ); } /* c8 ignore next 6 */ // Not reachable, all possible output types have been considered. invariant( false, - 'Cannot complete value of unexpected output type: ' + inspect(returnType), + 'Cannot complete value of unexpected output type: ' + inspect(nullableType), ); } @@ -908,23 +953,73 @@ async function completePromisedValue( path: Path, result: Promise, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal | undefined, ): Promise { + let resolved; + let nullableType: GraphQLNullableOutputType; try { - const resolved = await result; - let completed = completeValue( + resolved = await result; + + if (resolved instanceof Error) { + throw resolved; + } + + if (isNonNullType(returnType)) { + if (resolved == null) { + throw new Error( + `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, + ); + } + nullableType = returnType.ofType; + } else { + if (resolved == null) { + return null; + } + nullableType = returnType; + } + + if (isLeafType(nullableType)) { + return completeLeafValue(nullableType, resolved); + } + } catch (rawError) { + handleFieldError( + rawError, exeContext, returnType, fieldGroup, + path, + incrementalDataRecord, + ); + exeContext.incrementalPublisher.filter(path, incrementalDataRecord); + return null; + } + + const abortController = new AbortController(); + let removeAbortListener: (() => void) | undefined; + if (abortSignal !== undefined) { + removeAbortListener = addAbortListener(abortSignal, () => + abortController.abort(), + ); + } + try { + let completed = completeNonLeafValue( + exeContext, + nullableType, + fieldGroup, info, path, resolved, incrementalDataRecord, + abortController.signal, ); if (isPromise(completed)) { completed = await completed; } + removeAbortListener?.(); return completed; } catch (rawError) { + removeAbortListener?.(); + abortController.abort(); handleFieldError( rawError, exeContext, @@ -1007,6 +1102,7 @@ async function completeAsyncIteratorValue( path: Path, asyncIterator: AsyncIterator, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal, ): Promise> { const stream = getStreamValues(exeContext, fieldGroup, path); let containsPromise = false; @@ -1029,6 +1125,7 @@ async function completeAsyncIteratorValue( itemType, path, incrementalDataRecord, + abortSignal, stream.label, ); break; @@ -1056,6 +1153,7 @@ async function completeAsyncIteratorValue( info, itemPath, incrementalDataRecord, + abortSignal, ) ) { containsPromise = true; @@ -1077,6 +1175,7 @@ function completeListValue( path: Path, result: unknown, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal, ): PromiseOrValue> { const itemType = returnType.ofType; @@ -1091,6 +1190,7 @@ function completeListValue( path, asyncIterator, incrementalDataRecord, + abortSignal, ); } @@ -1127,6 +1227,7 @@ function completeListValue( info, itemType, previousIncrementalDataRecord, + abortSignal, stream.label, ); index++; @@ -1143,6 +1244,7 @@ function completeListValue( info, itemPath, incrementalDataRecord, + abortSignal, ) ) { containsPromise = true; @@ -1168,6 +1270,7 @@ function completeListItemValue( info: GraphQLResolveInfo, itemPath: Path, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal, ): boolean { if (isPromise(item)) { completedResults.push( @@ -1179,28 +1282,96 @@ function completeListItemValue( itemPath, item, incrementalDataRecord, + abortSignal, ), ); return true; } + let nullableType: GraphQLNullableOutputType; try { - const completedItem = completeValue( + if (item instanceof Error) { + throw item; + } + + if (isNonNullType(itemType)) { + if (item == null) { + throw new Error( + `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, + ); + } + nullableType = itemType.ofType; + } else { + if (item == null) { + completedResults.push(null); + return false; + } + nullableType = itemType; + } + + if (isLeafType(nullableType)) { + completedResults.push(completeLeafValue(nullableType, item)); + return false; + } + } catch (rawError) { + handleFieldError( + rawError, exeContext, itemType, fieldGroup, + itemPath, + incrementalDataRecord, + ); + exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); + completedResults.push(null); + return false; + } + + const abortController = new AbortController(); + const removeAbortListener = addAbortListener(abortSignal, () => + abortController.abort(), + ); + let completedItem; + try { + completedItem = completeNonLeafValue( + exeContext, + nullableType, + fieldGroup, info, itemPath, item, incrementalDataRecord, + abortController.signal, + ); + } catch (rawError) { + removeAbortListener(); + abortController.abort(); + handleFieldError( + rawError, + exeContext, + itemType, + fieldGroup, + itemPath, + incrementalDataRecord, ); + exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); + completedResults.push(null); + return false; + } - if (isPromise(completedItem)) { - // Note: we don't rely on a `catch` method, but we do expect "thenable" - // to take a second callback for the error case. - completedResults.push( - completedItem.then(undefined, (rawError) => { + if (isPromise(completedItem)) { + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + completedResults.push( + completedItem.then( + (resolved) => { + removeAbortListener(); + return resolved; + }, + (rawError) => { + removeAbortListener(); + abortController.abort(); handleFieldError( rawError, exeContext, @@ -1214,26 +1385,16 @@ function completeListItemValue( incrementalDataRecord, ); return null; - }), - ); - - return true; - } - - completedResults.push(completedItem); - } catch (rawError) { - handleFieldError( - rawError, - exeContext, - itemType, - fieldGroup, - itemPath, - incrementalDataRecord, + }, + ), ); - exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); - completedResults.push(null); + + return true; } + removeAbortListener(); + completedResults.push(completedItem); + return false; } @@ -1267,6 +1428,7 @@ function completeAbstractValue( path: Path, result: unknown, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal, ): PromiseOrValue> { const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; const contextValue = exeContext.contextValue; @@ -1289,6 +1451,7 @@ function completeAbstractValue( path, result, incrementalDataRecord, + abortSignal, ), ); } @@ -1308,6 +1471,7 @@ function completeAbstractValue( path, result, incrementalDataRecord, + abortSignal, ); } @@ -1377,6 +1541,7 @@ function completeObjectValue( path: Path, result: unknown, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal, ): PromiseOrValue> { // If there is an isTypeOf predicate function, call it with the // current result. If isTypeOf returns false, then raise an error rather @@ -1396,6 +1561,7 @@ function completeObjectValue( path, result, incrementalDataRecord, + abortSignal, ); }); } @@ -1412,6 +1578,7 @@ function completeObjectValue( path, result, incrementalDataRecord, + abortSignal, ); } @@ -1433,6 +1600,7 @@ function collectAndExecuteSubfields( path: Path, result: unknown, incrementalDataRecord: IncrementalDataRecord, + abortSignal: AbortSignal, ): PromiseOrValue> { // Collect sub-fields to execute to complete this value. const { groupedFieldSet: subGroupedFieldSet, patches: subPatches } = @@ -1445,6 +1613,7 @@ function collectAndExecuteSubfields( path, subGroupedFieldSet, incrementalDataRecord, + abortSignal, ); for (const subPatch of subPatches) { @@ -1455,6 +1624,7 @@ function collectAndExecuteSubfields( result, subPatchGroupedFieldSet, incrementalDataRecord, + abortSignal, label, path, ); @@ -1665,8 +1835,14 @@ function createSourceEventStreamImpl( function executeSubscription( exeContext: ExecutionContext, ): PromiseOrValue> { - const { schema, fragments, operation, variableValues, rootValue } = - exeContext; + const { + schema, + fragments, + operation, + variableValues, + rootValue, + abortSignal, + } = exeContext; const rootType = schema.getSubscriptionType(); if (rootType == null) { @@ -1721,7 +1897,7 @@ function executeSubscription( // Call the `subscribe()` resolver or the default resolver to produce an // AsyncIterable yielding raw payloads. const resolveFn = fieldDef.subscribe ?? exeContext.subscribeFieldResolver; - const result = resolveFn(rootValue, args, contextValue, info); + const result = resolveFn(rootValue, args, contextValue, info, abortSignal); if (isPromise(result)) { return result.then(assertEventStream).then(undefined, (error) => { @@ -1757,6 +1933,7 @@ function executeDeferredFragment( sourceValue: unknown, fields: GroupedFieldSet, parentContext: IncrementalDataRecord, + abortSignal: AbortSignal | undefined, label?: string, path?: Path, ): void { @@ -1777,6 +1954,7 @@ function executeDeferredFragment( path, fields, incrementalDataRecord, + abortSignal, ); if (isPromise(promiseOrData)) { @@ -1818,6 +1996,7 @@ function executeStreamField( info: GraphQLResolveInfo, itemType: GraphQLOutputType, parentContext: IncrementalDataRecord, + abortSignal: AbortSignal, label?: string, ): SubsequentDataRecord { const incrementalPublisher = exeContext.incrementalPublisher; @@ -1837,6 +2016,7 @@ function executeStreamField( itemPath, item, incrementalDataRecord, + abortSignal, ).then( (value) => incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, [ @@ -1857,18 +2037,78 @@ function executeStreamField( } let completedItem: PromiseOrValue; + let nullableType: GraphQLNullableOutputType; try { try { - completedItem = completeValue( + if (item instanceof Error) { + throw item; + } + + if (isNonNullType(itemType)) { + if (item == null) { + throw new Error( + `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, + ); + } + nullableType = itemType.ofType; + } else { + if (item == null) { + incrementalPublisher.completeStreamItemsRecord( + incrementalDataRecord, + [null], + ); + return incrementalDataRecord; + } + nullableType = itemType; + } + + if (isLeafType(nullableType)) { + incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, [ + completeLeafValue(nullableType, item), + ]); + return incrementalDataRecord; + } + } catch (rawError) { + handleFieldError( + rawError, exeContext, itemType, fieldGroup, + itemPath, + incrementalDataRecord, + ); + exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); + incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, [ + null, + ]); + return incrementalDataRecord; + } + } catch (error) { + incrementalPublisher.addFieldError(incrementalDataRecord, error); + incrementalPublisher.filter(path, incrementalDataRecord); + incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, null); + return incrementalDataRecord; + } + + const abortController = new AbortController(); + const removeAbortListener = addAbortListener(abortSignal, () => + abortController.abort(), + ); + try { + try { + completedItem = completeNonLeafValue( + exeContext, + nullableType, + fieldGroup, info, itemPath, item, incrementalDataRecord, + abortController.signal, ); } catch (rawError) { + removeAbortListener(); + abortController.abort(); handleFieldError( rawError, exeContext, @@ -1877,8 +2117,11 @@ function executeStreamField( itemPath, incrementalDataRecord, ); - completedItem = null; exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); + incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, [ + null, + ]); + return incrementalDataRecord; } } catch (error) { incrementalPublisher.addFieldError(incrementalDataRecord, error); @@ -1889,18 +2132,29 @@ function executeStreamField( if (isPromise(completedItem)) { completedItem - .then(undefined, (rawError) => { - handleFieldError( - rawError, - exeContext, - itemType, - fieldGroup, - itemPath, - incrementalDataRecord, - ); - exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); - return null; - }) + .then( + (resolvedItem) => { + removeAbortListener(); + return resolvedItem; + }, + (rawError) => { + removeAbortListener(); + abortController.abort(); + handleFieldError( + rawError, + exeContext, + itemType, + fieldGroup, + itemPath, + incrementalDataRecord, + ); + exeContext.incrementalPublisher.filter( + itemPath, + incrementalDataRecord, + ); + return null; + }, + ) .then( (value) => incrementalPublisher.completeStreamItemsRecord( @@ -1920,6 +2174,7 @@ function executeStreamField( return incrementalDataRecord; } + removeAbortListener(); incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, [ completedItem, ]); @@ -1935,6 +2190,7 @@ async function executeStreamAsyncIteratorItem( incrementalDataRecord: StreamItemsRecord, path: Path, itemPath: Path, + abortSignal: AbortSignal, ): Promise> { let item; try { @@ -1950,20 +2206,83 @@ async function executeStreamAsyncIteratorItem( } catch (rawError) { throw locatedError(rawError, fieldGroup, pathToArray(path)); } - let completedItem; + + let nullableType: GraphQLNullableOutputType; try { - completedItem = completeValue( + if (item instanceof Error) { + throw item; + } + + if (isNonNullType(itemType)) { + if (item == null) { + throw new Error( + `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, + ); + } + nullableType = itemType.ofType; + } else { + if (item == null) { + return { done: false, value: null }; + } + nullableType = itemType; + } + + if (isLeafType(nullableType)) { + return { done: false, value: completeLeafValue(nullableType, item) }; + } + } catch (rawError) { + handleFieldError( + rawError, exeContext, itemType, fieldGroup, + itemPath, + incrementalDataRecord, + ); + exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); + return { done: false, value: null }; + } + + const abortController = new AbortController(); + const removeAbortListener = addAbortListener(abortSignal, () => + abortController.abort(), + ); + let completedItem; + try { + completedItem = completeNonLeafValue( + exeContext, + nullableType, + fieldGroup, info, itemPath, item, incrementalDataRecord, + abortController.signal, ); + } catch (rawError) { + removeAbortListener(); + abortController.abort(); + handleFieldError( + rawError, + exeContext, + itemType, + fieldGroup, + itemPath, + incrementalDataRecord, + ); + exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); + return { done: false, value: null }; + } - if (isPromise(completedItem)) { - completedItem = completedItem.then(undefined, (rawError) => { + if (isPromise(completedItem)) { + completedItem = completedItem.then( + (resolvedItem) => { + removeAbortListener(); + return resolvedItem; + }, + (rawError) => { + removeAbortListener(); + abortController.abort(); handleFieldError( rawError, exeContext, @@ -1974,21 +2293,11 @@ async function executeStreamAsyncIteratorItem( ); exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); return null; - }); - } - return { done: false, value: completedItem }; - } catch (rawError) { - handleFieldError( - rawError, - exeContext, - itemType, - fieldGroup, - itemPath, - incrementalDataRecord, + }, ); - exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); - return { done: false, value: null }; } + removeAbortListener(); + return { done: false, value: completedItem }; } async function executeStreamAsyncIterator( @@ -2000,6 +2309,7 @@ async function executeStreamAsyncIterator( itemType: GraphQLOutputType, path: Path, parentContext: IncrementalDataRecord, + abortSignal: AbortSignal, label?: string, ): Promise { const incrementalPublisher = exeContext.incrementalPublisher; @@ -2028,6 +2338,7 @@ async function executeStreamAsyncIterator( incrementalDataRecord, path, itemPath, + abortSignal, ); } catch (error) { incrementalPublisher.addFieldError(incrementalDataRecord, error); diff --git a/src/jsutils/addAbortListener.ts b/src/jsutils/addAbortListener.ts new file mode 100644 index 0000000000..cdada90531 --- /dev/null +++ b/src/jsutils/addAbortListener.ts @@ -0,0 +1,64 @@ +type Callback = () => void; +interface AbortInfo { + listeners: Set; + dispose: Callback; +} +type Cache = WeakMap; + +let maybeCache: Cache | undefined; + +/** + * Helper function to add a callback to be triggered when the abort signal fires. + * Returns a function that will remove the callback when called. + * + * This helper function also avoids hitting the max listener limit on AbortSignals, + * which could be a common occurrence when setting up multiple contingent + * abort signals. + */ +export function addAbortListener( + abortSignal: AbortSignal, + callback: Callback, +): Callback { + if (abortSignal.aborted) { + callback(); + return () => { + /* noop */ + }; + } + + const cache = (maybeCache ??= new WeakMap()); + + const abortInfo = cache.get(abortSignal); + + if (abortInfo !== undefined) { + abortInfo.listeners.add(callback); + return () => removeAbortListener(abortInfo, callback); + } + + const listeners = new Set([callback]); + const onAbort = () => triggerCallbacks(listeners); + const dispose = () => { + abortSignal.removeEventListener('abort', onAbort); + }; + const newAbortInfo = { listeners, dispose }; + cache.set(abortSignal, newAbortInfo); + abortSignal.addEventListener('abort', onAbort); + + return () => removeAbortListener(newAbortInfo, callback); +} + +function triggerCallbacks(listeners: Set): void { + for (const listener of listeners) { + listener(); + } +} + +function removeAbortListener(abortInfo: AbortInfo, callback: Callback): void { + const listeners = abortInfo.listeners; + + listeners.delete(callback); + + if (listeners.size === 0) { + abortInfo.dispose(); + } +} diff --git a/src/type/definition.ts b/src/type/definition.ts index 0ca4152bd2..140d891bf3 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -883,6 +883,7 @@ export type GraphQLFieldResolver< args: TArgs, context: TContext, info: GraphQLResolveInfo, + abortSignal: AbortSignal | undefined, ) => TResult; export interface GraphQLResolveInfo {