From 9a075064e5d9c4b364714861e8153fe72cf6e32d Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 28 Mar 2024 22:13:15 +0100 Subject: [PATCH] docs --- .changeset/green-badgers-work.md | 6 +- examples/graphql-ws/package.json | 3 + examples/request-cancelation/package.json | 27 ++ examples/request-cancelation/src/main.ts | 61 ++++ examples/request-cancelation/tsconfig.json | 13 + pnpm-lock.yaml | 66 ++-- website/src/pages/docs/features/_meta.ts | 1 + .../docs/features/execution-cancelation.mdx | 290 ++++++++++++++++++ 8 files changed, 436 insertions(+), 31 deletions(-) create mode 100644 examples/request-cancelation/package.json create mode 100644 examples/request-cancelation/src/main.ts create mode 100644 examples/request-cancelation/tsconfig.json create mode 100644 website/src/pages/docs/features/execution-cancelation.mdx diff --git a/.changeset/green-badgers-work.md b/.changeset/green-badgers-work.md index 639aa923ad..2520973851 100644 --- a/.changeset/green-badgers-work.md +++ b/.changeset/green-badgers-work.md @@ -8,13 +8,15 @@ The execution of subsequent GraphQL resolvers is now aborted if the incoming HTT This reduces the load of your API in case incoming requests with deep GraphQL operation selection sets are canceled. ```ts -import { createYoga, useExecutionCancellation } from 'graphql-yoga' +import { createYoga, useExecutionCancelation } from 'graphql-yoga' const yoga = createYoga({ - plugins: [useExecutionCancellation()] + plugins: [useExecutionCancelation()] }) ``` +[Learn more in our docs](https://graphql-yoga.com/docs/features/execution-cancelation) + **Action Required** In order to benefit from this new feature, you need to update your integration setup for Fastify, Koa and Hapi. ```diff diff --git a/examples/graphql-ws/package.json b/examples/graphql-ws/package.json index 6e333a578c..cbbb871d59 100644 --- a/examples/graphql-ws/package.json +++ b/examples/graphql-ws/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "check": "tsc --pretty --noEmit", + "dev": "cross-env NODE_ENV=development ts-node-dev --exit-child --respawn src/index.ts", "start": "ts-node src/index.ts" }, "dependencies": { @@ -13,7 +14,9 @@ "ws": "8.13.0" }, "devDependencies": { + "cross-env": "7.0.3", "ts-node": "10.9.1", + "ts-node-dev": "2.0.0", "typescript": "5.1.6" } } diff --git a/examples/request-cancelation/package.json b/examples/request-cancelation/package.json new file mode 100644 index 0000000000..ec724619dd --- /dev/null +++ b/examples/request-cancelation/package.json @@ -0,0 +1,27 @@ +{ + "name": "example-request-cancelation", + "version": "0.0.0", + "description": "", + "author": "Laurin Quast ", + "license": "MIT", + "private": true, + "module": "commonjs", + "keywords": [], + "scripts": { + "check": "tsc --pretty --noEmit", + "dev": "cross-env NODE_ENV=development ts-node-dev --exit-child --respawn src/main.ts", + "start": "ts-node src/main.ts" + }, + "dependencies": { + "graphql": "16.6.0", + "graphql-yoga": "5.2.0" + }, + "devDependencies": { + "@types/node": "18.16.16", + "@whatwg-node/fetch": "^0.9.17", + "cross-env": "7.0.3", + "ts-node": "10.9.1", + "ts-node-dev": "2.0.0", + "typescript": "5.1.6" + } +} diff --git a/examples/request-cancelation/src/main.ts b/examples/request-cancelation/src/main.ts new file mode 100644 index 0000000000..3582bde958 --- /dev/null +++ b/examples/request-cancelation/src/main.ts @@ -0,0 +1,61 @@ +import { createServer } from 'node:http'; +import { createLogger, createSchema, createYoga, useExecutionCancelation } from 'graphql-yoga'; + +const logger = createLogger('debug'); + +const schema = createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + user: User + } + + type User { + id: ID! + name: String! + bestFriend: User + } + `, + resolvers: { + Query: { + async user(_, __, { request }) { + logger.info('resolving user'); + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, 5000); + request.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(request.signal.reason); + }); + }); + logger.info('resolved user'); + + return { + id: '1', + name: 'Chewie', + }; + }, + }, + User: { + bestFriend() { + logger.info('resolving user best friend'); + + return { + id: '2', + name: 'Han Solo', + }; + }, + }, + }, +}); + +// Provide your schema +const yoga = createYoga({ + plugins: [useExecutionCancelation()], + schema, + logging: logger, +}); + +// Start the server and explore http://localhost:4000/graphql +const server = createServer(yoga); +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql'); +}); diff --git a/examples/request-cancelation/tsconfig.json b/examples/request-cancelation/tsconfig.json new file mode 100644 index 0000000000..7c1c2e7795 --- /dev/null +++ b/examples/request-cancelation/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "module": "commonjs", + "target": "esnext", + "lib": ["esnext"], + "moduleResolution": "node", + "sourceMap": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 241d46b090..2c673cac71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -765,9 +765,15 @@ importers: specifier: 8.13.0 version: 8.13.0 devDependencies: + cross-env: + specifier: 7.0.3 + version: 7.0.3 ts-node: specifier: 10.9.1 version: 10.9.1(@types/node@18.16.16)(typescript@5.1.6) + ts-node-dev: + specifier: 2.0.0 + version: 2.0.0(@types/node@18.16.16)(typescript@5.1.6) typescript: specifier: 5.1.6 version: 5.1.6 @@ -1259,6 +1265,34 @@ importers: specifier: 5.1.6 version: 5.1.6 + examples/request-cancelation: + dependencies: + graphql: + specifier: 16.6.0 + version: 16.6.0 + graphql-yoga: + specifier: 5.2.0 + version: link:../../packages/graphql-yoga/dist + devDependencies: + '@types/node': + specifier: 18.16.16 + version: 18.16.16 + '@whatwg-node/fetch': + specifier: ^0.9.17 + version: 0.9.17 + cross-env: + specifier: 7.0.3 + version: 7.0.3 + ts-node: + specifier: 10.9.1 + version: 10.9.1(@types/node@18.16.16)(typescript@5.1.6) + ts-node-dev: + specifier: 2.0.0 + version: 2.0.0(@types/node@18.16.16)(typescript@5.1.6) + typescript: + specifier: 5.1.6 + version: 5.1.6 + examples/response-cache: dependencies: '@graphql-yoga/plugin-response-cache': @@ -1545,7 +1579,7 @@ importers: version: 0.8.4(graphql@16.6.0) '@graphql-tools/url-loader': specifier: 8.0.1 - version: 8.0.1(graphql@16.6.0) + version: 8.0.1(@types/node@18.16.16)(graphql@16.6.0) graphiql: specifier: 2.0.7 version: 2.0.7(@codemirror/language@6.0.0)(@types/react@18.2.8)(graphql@16.6.0)(react-dom@18.2.0)(react-is@17.0.2)(react@18.2.0) @@ -8229,33 +8263,6 @@ packages: dev: true /@graphql-tools/url-loader@8.0.1(@types/node@18.16.16)(graphql@16.6.0): - resolution: {integrity: sha512-B2k8KQEkEQmfV1zhurT5GLoXo8jbXP+YQHUayhCSxKYlRV7j/1Fhp1b21PDM8LXIDGlDRXaZ0FbWKOs7eYXDuQ==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/delegate': 10.0.0(graphql@16.6.0) - '@graphql-tools/executor-graphql-ws': 1.0.0(graphql@16.6.0) - '@graphql-tools/executor-http': 1.0.5(@types/node@18.16.16)(graphql@16.6.0) - '@graphql-tools/executor-legacy-ws': 1.0.0(graphql@16.6.0) - '@graphql-tools/utils': 10.1.1(graphql@16.6.0) - '@graphql-tools/wrap': 10.0.0(graphql@16.6.0) - '@types/ws': 8.5.4 - '@whatwg-node/fetch': 0.9.17 - graphql: 16.6.0 - isomorphic-ws: 5.0.0(ws@8.13.0) - tslib: 2.6.2 - value-or-promise: 1.0.12 - ws: 8.13.0 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - encoding - - utf-8-validate - dev: true - - /@graphql-tools/url-loader@8.0.1(graphql@16.6.0): resolution: {integrity: sha512-B2k8KQEkEQmfV1zhurT5GLoXo8jbXP+YQHUayhCSxKYlRV7j/1Fhp1b21PDM8LXIDGlDRXaZ0FbWKOs7eYXDuQ==} engines: {node: '>=16.0.0'} peerDependencies: @@ -8280,7 +8287,6 @@ packages: - bufferutil - encoding - utf-8-validate - dev: false /@graphql-tools/utils@10.0.0(graphql@16.6.0): resolution: {integrity: sha512-ndBPc6zgR+eGU/jHLpuojrs61kYN3Z89JyMLwK3GCRkPv4EQn9EOr1UWqF1JO0iM+/jAVHY0mvfUxyrFFN9DUQ==} @@ -17839,6 +17845,7 @@ packages: /cross-env@7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true dependencies: cross-spawn: 7.0.3 dev: true @@ -31337,6 +31344,7 @@ packages: /rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true dependencies: glob: 7.2.3 diff --git a/website/src/pages/docs/features/_meta.ts b/website/src/pages/docs/features/_meta.ts index d7b687b8e4..b6f8acc51d 100644 --- a/website/src/pages/docs/features/_meta.ts +++ b/website/src/pages/docs/features/_meta.ts @@ -3,6 +3,7 @@ export default { graphiql: 'GraphiQL', context: 'GraphQL Context', 'error-masking': 'Error Masking', + 'execution-cancelation': 'Execution Cancelation', introspection: 'Introspection', subscriptions: 'Subscriptions', 'file-uploads': 'File Uploads', diff --git a/website/src/pages/docs/features/execution-cancelation.mdx b/website/src/pages/docs/features/execution-cancelation.mdx new file mode 100644 index 0000000000..c2c0395a1e --- /dev/null +++ b/website/src/pages/docs/features/execution-cancelation.mdx @@ -0,0 +1,290 @@ +--- +description: Yoga has experimental support for canceling the GraphQL execution. +--- + +# Execution Cancelation + +In the real world a lot of HTTP requests are dropped or canceled. This can happen due to flakey +internet connection, navigation to a new view or page within a web or native app or the user simply +closing the app. In this case, the server can simply stop processing the request and save resources. + +That is why Yoga comes with experimental support for canceling the GraphQL execution upon request +cancelation. + +## Getting started + +To enable execution cancelation, you need to add the `useExecutionCancelation` plugin to your Yoga +instance. + +```ts filename="Execution Cancelation configuration" {1,6} +import { createYoga, useExecutionCancelation } from 'graphql-yoga' +import { schema } from './schema' + +// Provide your schema +const yoga = createYoga({ + plugins: [useExecutionCancelation()], + schema +}) + +// Start the server and explore http://localhost:4000/graphql +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql') +}) +``` + +That is all you need to do to enable execution cancelation in your Yoga server. Theoretically, you +can enable this and immediatly benefit from it without making any other adjustments within your +GraphQL schema implementation. + +If you want to understand how it works and how you can adjust your resolvers to properly cancel +pending promises (else.gc. database reads or HTTP requests), you can continue with the next section. + +## How it works + +The following example demonstrates how the execution cancelation works. + +```graphql filename="Simple GraphQL schema" +type Query { + user: User +} + +type User { + id: ID! + name: String! + bestFriend: User +} +``` + +The `Query.user` resolver has a artifical delay of 10 seconds to simulate a long running e.g. a slow +read from a database. + +```ts filename="Full Yoga Example" +import { createServer } from 'node:http' +import { createLogger, createSchema, createYoga, useExecutionCancelation } from 'graphql-yoga' + +const logger = createLogger('debug') + +const schema = createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + user: User + } + + type User { + id: ID! + name: String! + bestFriend: User + } + `, + resolvers: { + Query: { + async user(_, __, { request }) { + logger.info('resolving user') + + await new Promise(resolve => { + setTimeout(resolve, 5000) + }) + + logger.info('resolved user') + + return { + id: '1', + name: 'Chewie' + } + } + }, + User: { + bestFriend() { + logger.info('resolving user best friend') + + return { + id: '2', + name: 'Han Solo' + } + } + } + } +}) + +const yoga = createYoga({ + plugins: [useExecutionCancelation()], + schema, + logging: logger +}) + +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql') +}) +``` + +With this server setup we are going to execute the following GraphQL query. + +```graphql filename="GraphQL Query for fetching user with best friend" +query UserWithBestFriend { + user { + id + name + bestFriend { + id + name + } + } +} +``` + +For your convencience, you can copy the following `curl` command to execute the query. + +```bash filename="Curl Request for executing the operation" +curl -g \ + -X POST \ + -H "content-type: application/json" \ + -d '{"query":"{ user { id name bestFriend { id name } } }"}' \ + "http://localhost:4000/graphql" +``` + +Execute this will give us the following server log output: + +```bash filename="Server logs" +DEBUG Parsing request to extract GraphQL parameters +DEBUG Processing GraphQL Parameters +INFO resolving user +INFO resolved user +INFO resolving user best friend +DEBUG Processing GraphQL Parameters done. +``` + +Now, let's cancel the request by closing the terminal or pressing `Ctrl + C` after the server shows +the `INFO resolving user` log. + +This will now log the following. + +```bash filename="Server logs for canceled request" +DEBUG Parsing request to extract GraphQL parameters +DEBUG Processing GraphQL Parameters +INFO resolving user +DEBUG Request aborted +INFO resolved user +``` + +The interesting part is that now `DEBUG Request aborted` is shown. At this point any further GraphQL +execution will be stopped. However, the `INFO resolved user` log is still showing up. + +We can further adjust our server to cancel the artifical delay promise when the request is aborted +to avoid any further processing. + +```ts filename="Altered server example with timer cleanup" {22-28} +import { createServer } from 'node:http' +import { createLogger, createSchema, createYoga, useExecutionCancelation } from 'graphql-yoga' + +const logger = createLogger('debug') + +const schema = createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + user: User + } + + type User { + id: ID! + name: String! + bestFriend: User + } + `, + resolvers: { + Query: { + async user(_, __, { request }) { + logger.info('resolving user') + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, 5000) + request.signal.addEventListener('abort', () => { + clearTimeout(timeout) + reject(request.signal.reason) + }) + }) + logger.info('resolved user') + + return { + id: '1', + name: 'Chewie' + } + } + }, + User: { + bestFriend() { + logger.info('resolving user best friend') + + return { + id: '2', + name: 'Han Solo' + } + } + } + } +}) + +// Provide your schema +const yoga = createYoga({ + plugins: [useExecutionCancelation()], + schema, + logging: logger +}) + +// Start the server and explore http://localhost:4000/graphql +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql') +}) +``` + +After these adjustments, timer will be cleaned up properly, and `INFO resolved user`, will no longer +show up in the logs. + +```bash filename="Server Logs after timer cleanup" +DEBUG Parsing request to extract GraphQL parameters +DEBUG Processing GraphQL Parameters +INFO resolving user +DEBUG Request aborted +``` + +## Propagate cancelation in resolvers + +As shown in the previous Selection, you can utilize the Yoga context object `request.signal` to +cancel pending asynchronous operations in your resolvers. + +### `fetch` example + +In this example we just pass `context.request.signal` to the fetch call. This will cancel the fetch +request when the GraphQL request is canceled. + +```ts filename="Resolver fetch cancelation example" {15} +import { createSchema, createYoga } from 'graphql-yoga' + +const yoga = createYoga({ + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + greeting: String! + } + `, + resolvers: { + Query: { + greeting: async (_, args, context) => { + // This service does not exist + const greeting = await fetch('http://localhost:9876/greeting', { + signal: context.request.signal + }).then(res => res.text()) + + return greeting + } + } + } + }) +}) + +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql') +}) +```