diff --git a/integrationTests/ts/tsconfig.json b/integrationTests/ts/tsconfig.json index e8505c2bb9..3a9475ed29 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" + ], "noEmit": true, "types": [], "strict": true, diff --git a/package-lock.json b/package-lock.json index 47e148e810..0862f52b50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@svgr/webpack": "8.1.0", "@types/chai": "4.3.19", "@types/mocha": "10.0.7", - "@types/node": "22.5.4", + "@types/node": "22.7.7", "@typescript-eslint/eslint-plugin": "8.4.0", "@typescript-eslint/parser": "8.4.0", "c8": "10.1.2", @@ -4946,11 +4946,10 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.5.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", - "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "version": "22.7.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.7.tgz", + "integrity": "sha512-SRxCrrg9CL/y54aiMCG3edPKdprgMVGDXjA3gB8UmmBW5TcXzRUYAh8EWzTnSJFAd1rgImPELza+A3bJ+qxz8Q==", "dev": true, - "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } diff --git a/package.json b/package.json index 7327e21c17..7399802238 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@svgr/webpack": "8.1.0", "@types/chai": "4.3.19", "@types/mocha": "10.0.7", - "@types/node": "22.5.4", + "@types/node": "22.7.7", "@typescript-eslint/eslint-plugin": "8.4.0", "@typescript-eslint/parser": "8.4.0", "c8": "10.1.2", diff --git a/src/execution/__tests__/abort-signal-test.ts b/src/execution/__tests__/abort-signal-test.ts new file mode 100644 index 0000000000..7e34c3dc91 --- /dev/null +++ b/src/execution/__tests__/abort-signal-test.ts @@ -0,0 +1,352 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import type { DocumentNode } from '../../language/ast.js'; +import { parse } from '../../language/parser.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { execute, experimentalExecuteIncrementally } from '../execute.js'; +import type { + InitialIncrementalExecutionResult, + SubsequentIncrementalExecutionResult, +} from '../types.js'; + +async function complete( + document: DocumentNode, + rootValue: unknown, + abortSignal: AbortSignal, +) { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + abortSignal, + }); + + if ('initialResult' in result) { + const results: Array< + InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult + > = [result.initialResult]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; + } +} + +const schema = buildSchema(` + type Todo { + id: ID + text: String + author: User + } + + type User { + id: ID + name: String + } + + type Query { + todo: Todo + } + + type Mutation { + foo: String + bar: String + } +`); + +describe('Execute: Cancellation', () => { + it('should stop the execution when aborted during object field completion', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: async () => + Promise.resolve({ + id: '1', + text: 'Hello, World!', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + }); + + abortController.abort('Aborted'); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual({ + data: { + todo: null, + }, + errors: [ + { + message: 'Aborted', + path: ['todo', 'id'], + locations: [{ line: 4, column: 11 }], + }, + ], + }); + }); + + it('should stop the execution when aborted during nested object field completion', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: { + id: '1', + text: 'Hello, World!', + /* c8 ignore next 3 */ + author: async () => + Promise.resolve(() => expect.fail('Should not be called')), + }, + }, + }); + + abortController.abort('Aborted'); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual({ + data: { + todo: { + id: '1', + author: null, + }, + }, + errors: [ + { + message: 'Aborted', + path: ['todo', 'author', 'id'], + locations: [{ line: 6, column: 13 }], + }, + ], + }); + }); + + it('should stop deferred execution when aborted', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + ... on Todo @defer { + text + author { + id + } + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + rootValue: { + todo: async () => + Promise.resolve({ + id: '1', + text: 'hello world', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + abortSignal: abortController.signal, + }); + + abortController.abort('Aborted'); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual({ + data: { + todo: null, + }, + errors: [ + { + message: 'Aborted', + path: ['todo', 'id'], + locations: [{ line: 4, column: 11 }], + }, + ], + }); + }); + + it('should stop deferred execution when aborted mid-execution', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + ... on Todo @defer { + text + author { + ... on Author @defer { + id + } + } + } + } + } + `); + + const resultPromise = complete( + document, + { + todo: async () => + Promise.resolve({ + id: '1', + text: 'hello world', + /* c8 ignore next 2 */ + author: async () => + Promise.resolve(() => expect.fail('Should not be called')), + }), + }, + abortController.signal, + ); + + await resolveOnNextTick(); + await resolveOnNextTick(); + await resolveOnNextTick(); + + abortController.abort('Aborted'); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual([ + { + data: { + todo: { + id: '1', + }, + }, + pending: [{ id: '0', path: ['todo'] }], + hasNext: true, + }, + { + completed: [ + { + errors: [ + { + locations: [ + { + column: 13, + line: 6, + }, + ], + message: 'Aborted', + path: ['todo', 'text'], + }, + ], + id: '0', + }, + ], + hasNext: false, + }, + ]); + }); + + it('should stop the execution when aborted mid-mutation', async () => { + const abortController = new AbortController(); + const document = parse(` + mutation { + foo + bar + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + foo: async () => Promise.resolve('baz'), + /* c8 ignore next */ + bar: () => expect.fail('Should not be called'), + }, + }); + + abortController.abort('Aborted'); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual({ + data: { + foo: 'baz', + bar: null, + }, + errors: [ + { + message: 'Aborted', + path: ['bar'], + locations: [{ line: 4, column: 9 }], + }, + ], + }); + }); + + it('should stop the execution when aborted pre-execute', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + abortController.abort('Aborted'); + const result = await execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + /* c8 ignore next */ + todo: () => expect.fail('Should not be called'), + }, + }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'Aborted', + }, + ], + }); + }); +}); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index db9e4fe33b..5cfb8c1924 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -157,6 +157,7 @@ export interface ValidatedExecutionArgs { ) => PromiseOrValue; enableEarlyExecution: boolean; hideSuggestions: boolean; + abortSignal: AbortSignal | undefined; } export interface ExecutionContext { @@ -187,6 +188,7 @@ export interface ExecutionArgs { >; enableEarlyExecution?: Maybe; hideSuggestions?: Maybe; + abortSignal?: Maybe; } export interface StreamUsage { @@ -319,6 +321,7 @@ export function experimentalExecuteQueryOrMutationOrSubscriptionEvent( variableValues, hideSuggestions, } = validatedExecutionArgs; + const rootType = schema.getRootType(operation.operation); if (rootType == null) { throw new GraphQLError( @@ -511,8 +514,13 @@ export function validateExecutionArgs( subscribeFieldResolver, perEventExecutor, enableEarlyExecution, + abortSignal, } = args; + if (abortSignal?.aborted) { + return [locatedError(new Error(abortSignal.reason), undefined)]; + } + // If the schema used for execution is invalid, throw an error. assertValidSchema(schema); @@ -592,6 +600,7 @@ export function validateExecutionArgs( perEventExecutor: perEventExecutor ?? executeSubscriptionEvent, enableEarlyExecution: enableEarlyExecution === true, hideSuggestions, + abortSignal: args.abortSignal ?? undefined, }; } @@ -656,6 +665,20 @@ function executeFieldsSerially( groupedFieldSet, (graphqlWrappedResult, [responseName, fieldDetailsList]) => { const fieldPath = addPath(path, responseName, parentType.name); + const abortSignal = exeContext.validatedExecutionArgs.abortSignal; + if (abortSignal?.aborted) { + handleFieldError( + new Error(abortSignal.reason), + exeContext, + parentType, + fieldDetailsList, + fieldPath, + incrementalContext, + ); + graphqlWrappedResult[0][responseName] = null; + return graphqlWrappedResult; + } + const result = executeField( exeContext, parentType, @@ -706,6 +729,15 @@ function executeFields( try { for (const [responseName, fieldDetailsList] of groupedFieldSet) { const fieldPath = addPath(path, responseName, parentType.name); + const abortSignal = exeContext.validatedExecutionArgs.abortSignal; + if (abortSignal?.aborted) { + throw locatedError( + new Error(abortSignal.reason), + toNodes(fieldDetailsList), + pathToArray(fieldPath), + ); + } + const result = executeField( exeContext, parentType, @@ -1069,6 +1101,7 @@ async function completePromisedValue( if (isPromise(completed)) { completed = await completed; } + return completed; } catch (rawError) { handleFieldError( diff --git a/src/graphql.ts b/src/graphql.ts index 899edb9977..ff11025968 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -68,6 +68,7 @@ export interface GraphQLArgs { operationName?: Maybe; fieldResolver?: Maybe>; typeResolver?: Maybe>; + abortSignal?: Maybe; } export function graphql(args: GraphQLArgs): Promise {