From 39fbb60de0de4881e8df720bab651d0c7ae9e142 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 24 Jul 2022 09:52:38 -0400 Subject: [PATCH 1/7] chore(deps): update dependency vite to v3 (master) (#1444) * chore(deps): update actions/checkout action to v3 (#1431) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update dependency ioredis to v5.2.2 (#1450) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Replace cross-undici-fetch with @whatwg-node/fetch * chore(deps): update dependency vite to v3 * Fix GraphiQL build * Go Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Arda TANRIKULU --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index beac8ccdb8..3435c43c40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10163,7 +10163,7 @@ esbuild-windows-arm64@0.14.50: resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.50.tgz#e7ddde6a97194051a5a4ac05f4f5900e922a7ea5" integrity sha512-EMS4lQnsIe12ZyAinOINx7eq2mjpDdhGZZWDwPZE/yUTN9cnc2Ze/xUTYIAyaJqrqQda3LnDpADKpvLvol6ENQ== -esbuild@0.14.47, esbuild@0.14.50, esbuild@^0.14.25, esbuild@^0.14.47, esbuild@^0.14.48: +esbuild@0.14.47, esbuild@0.14.49, esbuild@0.14.50, esbuild@^0.14.25, esbuild@^0.14.47, esbuild@^0.14.48: version "0.14.50" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.50.tgz#7a665392c8df94bf6e1ae1e999966a5ee62c6cbc" integrity sha512-SbC3k35Ih2IC6trhbMYW7hYeGdjPKf9atTKwBUHqMCYFZZ9z8zhuvfnZihsnJypl74FjiAKjBRqFkBkAd0rS/w== From 218df27470bc082d23c2e9a3e02a3957a4e15859 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 6 Jul 2022 18:32:11 +0300 Subject: [PATCH 2/7] Response Cache plugin --- .changeset/chilly-rats-marry.md | 9 ++ .changeset/twenty-poets-prove.md | 18 ++++ packages/graphql-yoga/src/plugins/types.ts | 12 +-- .../src/plugins/useResultProcessor.ts | 18 ++-- packages/graphql-yoga/src/processRequest.ts | 78 ++++++++++------ packages/graphql-yoga/src/server.ts | 44 +++++++--- packages/graphql-yoga/src/types.ts | 71 +++++---------- packages/plugins/response-cache/package.json | 60 +++++++++++++ packages/plugins/response-cache/src/index.ts | 88 +++++++++++++++++++ yarn.lock | 9 ++ 10 files changed, 304 insertions(+), 103 deletions(-) create mode 100644 .changeset/chilly-rats-marry.md create mode 100644 .changeset/twenty-poets-prove.md create mode 100644 packages/plugins/response-cache/package.json create mode 100644 packages/plugins/response-cache/src/index.ts diff --git a/.changeset/chilly-rats-marry.md b/.changeset/chilly-rats-marry.md new file mode 100644 index 0000000000..f7b7f923ca --- /dev/null +++ b/.changeset/chilly-rats-marry.md @@ -0,0 +1,9 @@ +--- +'@graphql-yoga/common': minor +--- + +New `setResult` helper is available in `onRequestParseDone` hook to set `ExecutionResult` before any GraphQL specific process. + +You can check `@graphql-yoga/plugin-response-cache`'s implementation to see how it can be useful. + +Also now `onResultProcess` and `useResultProcessor` hooks use generics to get more type-safety. diff --git a/.changeset/twenty-poets-prove.md b/.changeset/twenty-poets-prove.md new file mode 100644 index 0000000000..622161dff6 --- /dev/null +++ b/.changeset/twenty-poets-prove.md @@ -0,0 +1,18 @@ +--- +'@graphql-yoga/plugin-response-cache': major +--- + +New Response Cache Plugin!!! + +On top of [`@envelop/response-cache`](https://www.envelop.dev/plugins/use-response-cache), this new plugin allows you to skip execution phase even before all the GraphQL execution phases immediately after the GraphQL request parameters is parsed by Yoga. + +Also it doesn't need to have `documentString` stored in somewhere in order to get it back during the execution to generate the cache key. + +All the features of the same except for the following: + +- `session` factory function takes `GraphQLParams` and `Request` objects instead of GraphQL context as arguments. + + - `type SessionIdFactory = (params: GraphQLParams, request: Request) => Maybe` + +- `enabled` function takes `GraphQLParams` and `Request` objects instead of GraphQL context as arguments. + - `type EnabledFn = (params: GraphQLParams, request: Request) => boolean` diff --git a/packages/graphql-yoga/src/plugins/types.ts b/packages/graphql-yoga/src/plugins/types.ts index f2b29c182b..22e9c2206f 100644 --- a/packages/graphql-yoga/src/plugins/types.ts +++ b/packages/graphql-yoga/src/plugins/types.ts @@ -63,6 +63,7 @@ export type OnRequestParseDoneHook = ( export interface OnRequestParseDoneEventPayload { params: GraphQLParams setParams: (params: GraphQLParams) => void + setResult: (result: ResultProcessorInput) => void } export type OnResultProcess = ( @@ -73,16 +74,17 @@ export type ResultProcessorInput = PromiseOrValue< ExecutionResult | AsyncIterable > -export type ResultProcessor = ( - result: ResultProcessorInput, - fetchAPI: FetchAPI, -) => PromiseOrValue +export type ResultProcessor< + TResult extends ResultProcessorInput = ResultProcessorInput, +> = (result: TResult, fetchAPI: FetchAPI) => PromiseOrValue export interface OnResultProcessEventPayload { request: Request result: ResultProcessorInput resultProcessor?: ResultProcessor - setResultProcessor(resultProcessor: ResultProcessor): void + setResultProcessor( + resultProcessor: ResultProcessor, + ): void } export type OnResponseHook = ( diff --git a/packages/graphql-yoga/src/plugins/useResultProcessor.ts b/packages/graphql-yoga/src/plugins/useResultProcessor.ts index 27f772c6ce..b55ea20c6c 100644 --- a/packages/graphql-yoga/src/plugins/useResultProcessor.ts +++ b/packages/graphql-yoga/src/plugins/useResultProcessor.ts @@ -1,17 +1,19 @@ import { Plugin, ResultProcessor, ResultProcessorInput } from './types.js' -export interface ResultProcessorPluginOptions { - processResult: ResultProcessor - match?(request: Request, result: ResultProcessorInput): boolean +export interface ResultProcessorPluginOptions< + TResult extends ResultProcessorInput, +> { + processResult: ResultProcessor + match?(request: Request, result: ResultProcessorInput): result is TResult } -export function useResultProcessor( - options: ResultProcessorPluginOptions, -): Plugin { - const isMatch = options.match || (() => true) +export function useResultProcessor< + TResult extends ResultProcessorInput = ResultProcessorInput, +>(options: ResultProcessorPluginOptions): Plugin { + const matchFn = options.match || (() => true) return { onResultProcess({ request, result, setResultProcessor }) { - if (isMatch(request, result)) { + if (matchFn(request, result)) { setResultProcessor(options.processResult) } }, diff --git a/packages/graphql-yoga/src/processRequest.ts b/packages/graphql-yoga/src/processRequest.ts index 15017ae3aa..2876ffe17e 100644 --- a/packages/graphql-yoga/src/processRequest.ts +++ b/packages/graphql-yoga/src/processRequest.ts @@ -1,14 +1,57 @@ import { getOperationAST, ExecutionArgs } from 'graphql' -import { RequestProcessContext } from './types.js' -import { ResultProcessor } from './plugins/types.js' +import { FetchAPI, GraphQLParams } from './types.js' +import { + OnResultProcess, + ResultProcessor, + ResultProcessorInput, +} from './plugins/types.js' +import { GetEnvelopedFn } from '@envelop/core' -export async function processRequest({ +export async function processResult({ request, - params, - enveloped, + result, fetchAPI, onResultProcessHooks, -}: RequestProcessContext): Promise { +}: { + request: Request + result: ResultProcessorInput + fetchAPI: FetchAPI + /** + * Response Hooks + */ + onResultProcessHooks: OnResultProcess[] +}) { + let resultProcessor: ResultProcessor | undefined + + for (const onResultProcessHook of onResultProcessHooks) { + await onResultProcessHook({ + request, + result, + resultProcessor, + setResultProcessor(newResultProcessor) { + resultProcessor = newResultProcessor + }, + }) + } + + // If no result processor found for this result, return an error + if (!resultProcessor) { + return new fetchAPI.Response(null, { + status: 406, + statusText: 'Not Acceptable', + }) + } + + return resultProcessor(result, fetchAPI) +} + +export async function processRequest({ + params, + enveloped, +}: { + params: GraphQLParams + enveloped: ReturnType> +}): Promise { // Parse GraphQLParams const document = enveloped.parse(params.query!) @@ -38,26 +81,5 @@ export async function processRequest({ // Get the result to be processed const result = await executeFn(executionArgs) - let resultProcessor: ResultProcessor | undefined - - for (const onResultProcessHook of onResultProcessHooks) { - await onResultProcessHook({ - request, - result, - resultProcessor, - setResultProcessor(newResultProcessor) { - resultProcessor = newResultProcessor - }, - }) - } - - // If no result processor found for this result, return an error - if (!resultProcessor) { - return new fetchAPI.Response(null, { - status: 406, - statusText: 'Not Acceptable', - }) - } - - return resultProcessor(result, fetchAPI) + return result } diff --git a/packages/graphql-yoga/src/server.ts b/packages/graphql-yoga/src/server.ts index 276a5eb5ff..504e7b481b 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -30,6 +30,7 @@ import { Plugin, RequestParser, ResultProcessor, + ResultProcessorInput, } from './plugins/types.js' import { createFetch } from '@whatwg-node/fetch' import { ServerAdapter, createServerAdapter } from '@whatwg-node/server' @@ -142,6 +143,7 @@ export type YogaServerOptions< /** * Whether the landing page should be shown. */ + landingPage?: boolean /** * GraphiQL options @@ -383,15 +385,15 @@ export class YogaServer< // Middlewares after the GraphQL execution useResultProcessor({ match: isRegularResult, - processResult: processRegularResult as ResultProcessor, + processResult: processRegularResult, }), useResultProcessor({ match: isPushResult, - processResult: processPushResult as ResultProcessor, + processResult: processPushResult, }), useResultProcessor({ match: isMultipartResult, - processResult: processMultipartResult as ResultProcessor, + processResult: processMultipartResult, }), ...(options?.plugins ?? []), @@ -491,34 +493,48 @@ export class YogaServer< let params = await requestParser(request) + let result: ResultProcessorInput | undefined + for (const onRequestParseDone of onRequestParseDoneList) { await onRequestParseDone({ params, setParams(newParams: GraphQLParams) { params = newParams }, + setResult(earlyResult: ResultProcessorInput) { + result = earlyResult + }, }) + if (result) { + break + } } - const initialContext = { - request, - ...params, - ...serverContext, - } + if (result == null) { + const initialContext = { + request, + ...params, + ...serverContext, + } + + const enveloped = this.getEnveloped(initialContext) - const enveloped = this.getEnveloped(initialContext) + this.logger.debug(`Processing GraphQL Parameters`) - this.logger.debug(`Processing GraphQL Parameters`) + result = await processGraphQLParams({ + params, + enveloped, + }) + } - const result = await processGraphQLParams({ + const response = await processResult({ request, - params, - enveloped, + result, fetchAPI: this.fetchAPI, onResultProcessHooks: this.onResultProcessHooks, }) - return result + return response } catch (error: unknown) { const finalResponseInit = { status: 200, diff --git a/packages/graphql-yoga/src/types.ts b/packages/graphql-yoga/src/types.ts index 94eeb3d0d1..d2104fd5d9 100644 --- a/packages/graphql-yoga/src/types.ts +++ b/packages/graphql-yoga/src/types.ts @@ -1,18 +1,12 @@ -import type { - DocumentNode, - ExecutionResult, - GraphQLError, - OperationDefinitionNode, -} from 'graphql' +import type { DocumentNode, GraphQLError } from 'graphql' import type { TypedDocumentNode } from '@graphql-typed-document-node/core' -import { GetEnvelopedFn, PromiseOrValue } from '@envelop/core' -import { OnResultProcess } from './plugins/types.js' +import { PromiseOrValue } from '@envelop/core' import { createFetch } from '@whatwg-node/fetch' export interface ExecutionPatchResult< TData = { [key: string]: any }, TExtensions = { [key: string]: any }, -> { + > { errors?: ReadonlyArray data?: TData | null path?: ReadonlyArray @@ -24,21 +18,13 @@ export interface ExecutionPatchResult< export interface GraphQLParams< TVariables = Record, TExtensions = Record, -> { + > { operationName?: string query?: string variables?: TVariables extensions?: TExtensions } -export interface FormatPayloadParams { - payload: ExecutionResult | ExecutionPatchResult - context?: TContext - document?: DocumentNode - operation?: OperationDefinitionNode - rootValue?: TRootValue -} - export interface YogaInitialContext { /** * A Document containing GraphQL Operations and Fragments to execute. @@ -62,44 +48,33 @@ export interface YogaInitialContext { extensions?: Record } -export interface RequestProcessContext { - request: Request - enveloped: ReturnType> - params: GraphQLParams - fetchAPI: FetchAPI - /** - * Response Hooks - */ - onResultProcessHooks: OnResultProcess[] -} - export type CORSOptions = | { - origin?: string[] | string - methods?: string[] - allowedHeaders?: string[] - exposedHeaders?: string[] - credentials?: boolean - maxAge?: number - } + origin?: string[] | string + methods?: string[] + allowedHeaders?: string[] + exposedHeaders?: string[] + credentials?: boolean + maxAge?: number + } | false export type GraphQLServerInject< TData = any, TVariables = Record, TServerContext extends Record = Record, -> = { - /** GraphQL Operation to execute */ - document: string | TypedDocumentNode - /** Variables for GraphQL Operation */ - variables?: TVariables - /** Name for GraphQL Operation */ - operationName?: string - /** Set any headers for the GraphQL request */ - headers?: HeadersInit -} & ({} extends TServerContext - ? { serverContext?: TServerContext } - : { serverContext: TServerContext }) + > = { + /** GraphQL Operation to execute */ + document: string | TypedDocumentNode + /** Variables for GraphQL Operation */ + variables?: TVariables + /** Name for GraphQL Operation */ + operationName?: string + /** Set any headers for the GraphQL request */ + headers?: HeadersInit + } & ({} extends TServerContext + ? { serverContext?: TServerContext } + : { serverContext: TServerContext }) export { EnvelopError as GraphQLYogaError } from '@envelop/core' diff --git a/packages/plugins/response-cache/package.json b/packages/plugins/response-cache/package.json new file mode 100644 index 0000000000..ec48cfc6df --- /dev/null +++ b/packages/plugins/response-cache/package.json @@ -0,0 +1,60 @@ +{ + "name": "@graphql-yoga/plugin-response-cache", + "version": "0.0.0", + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/dotansimha/graphql-yoga.git", + "directory": "packages/plugins/response-cache" + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "scripts": { + "check": "tsc --pretty --noEmit" + }, + "keywords": [ + "graphql", + "server", + "api", + "graphql-server" + ], + "author": "Arda TANRIKULU ", + "license": "MIT", + "buildOptions": { + "input": "./src/index.ts" + }, + "exports": { + ".": { + "require": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + }, + "default": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./package.json": "./package.json" + }, + "typings": "dist/typings/index.d.ts", + "typescript": { + "definition": "dist/typings/index.d.ts" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "dependencies": { + "@envelop/response-cache": "2.4.0", + "tslib": "^2.3.1" + }, + "peerDependencies": { + "graphql": "^15.2.0 || ^16.0.0", + "@graphql-yoga/common": "^2.10.0" + }, + "type": "module" +} diff --git a/packages/plugins/response-cache/src/index.ts b/packages/plugins/response-cache/src/index.ts new file mode 100644 index 0000000000..fb9570c3b9 --- /dev/null +++ b/packages/plugins/response-cache/src/index.ts @@ -0,0 +1,88 @@ +import { + BuildResponseCacheKeyFunction, + createInMemoryCache, + defaultBuildResponseCacheKey, + GetDocumentStringFromContextFunction, + useResponseCache as useEnvelopResponseCache, + UseResponseCacheParameter as UseEnvelopResponseCacheParameter, +} from '@envelop/response-cache' +import { + GraphQLParams, + Maybe, + Plugin, + YogaInitialContext, +} from '@graphql-yoga/common' + +export type UseResponseCacheParameter = Omit< + UseEnvelopResponseCacheParameter, + 'getDocumentStringFromContext' | 'session' +> & { + session: (params: GraphQLParams, request: Request) => Maybe + enabled?: (params: GraphQLParams, request: Request) => boolean +} + +// Probably this is not used but somehow if Envelop plugin needs that +const getDocumentStringFromContext: GetDocumentStringFromContextFunction = ( + context, +) => context.query as string + +const operationIdByRequest = new WeakMap() + +// We trick Envelop plugin by passing operationId as sessionId so we can take it from cache key builder we pass to Envelop +function sessionFactoryForEnvelop({ request }: YogaInitialContext) { + return operationIdByRequest.get(request) +} +const buildResponseCacheKeyForEnvelop: BuildResponseCacheKeyFunction = async ({ + sessionId, +}) => sessionId! + +export function useResponseCache(options: UseResponseCacheParameter): Plugin { + const buildResponseCacheKey: BuildResponseCacheKeyFunction = + options?.buildResponseCacheKey || defaultBuildResponseCacheKey + const cache = options.cache ?? createInMemoryCache() + const enabled = options.enabled ?? (() => true) + return { + onPluginInit({ addPlugin }) { + addPlugin( + useEnvelopResponseCache({ + ...options, + cache, + getDocumentStringFromContext, + session: sessionFactoryForEnvelop, + buildResponseCacheKey: buildResponseCacheKeyForEnvelop, + }), + ) + }, + onRequestParse({ request }) { + return { + async onRequestParseDone({ params, setResult }) { + if (enabled(params, request)) { + const operationId = await buildResponseCacheKey({ + documentString: params.query!, + variableValues: params.variables, + operationName: params.operationName, + sessionId: options.session(params, request), + }) + const cachedResponse = await cache.get(operationId) + if (cachedResponse) { + if (options.includeExtensionMetadata) { + setResult({ + ...cachedResponse, + extensions: { + responseCache: { + hit: true, + }, + }, + }) + } else { + setResult(cachedResponse) + } + return + } + operationIdByRequest.set(request, operationId) + } + }, + } + }, + } +} diff --git a/yarn.lock b/yarn.lock index 3435c43c40..e8c9ae3608 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2918,6 +2918,15 @@ dependencies: tiny-lru "7.0.6" +"@envelop/response-cache@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@envelop/response-cache/-/response-cache-2.4.0.tgz#0420afe2bea6bf636e607343e4b517b03ee3fa9c" + integrity sha512-flJYReUL+wyZ3x3TYGd6XgO+Em/b9BO+N3Ii915slSsvU7N60Xczo9hG+ewrne4dC4EDJ6NAWcybGvwe6qKd4Q== + dependencies: + "@graphql-tools/utils" "^8.8.0" + fast-json-stable-stringify "^2.1.0" + lru-cache "^6.0.0" + "@envelop/types@2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@envelop/types/-/types-2.3.0.tgz#d633052eb3c7e7913380165ce041e2c0e358d5b6" From 7b18c9bf0999ff5545f8ca48b41a1162e414aaa4 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Tue, 26 Jul 2022 13:58:26 +0300 Subject: [PATCH 3/7] Fixes --- packages/graphql-yoga/src/server.ts | 5 ++++- packages/plugins/response-cache/package.json | 2 +- packages/plugins/response-cache/src/index.ts | 7 +------ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/graphql-yoga/src/server.ts b/packages/graphql-yoga/src/server.ts index 504e7b481b..aa4657434e 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -34,7 +34,10 @@ import { } from './plugins/types.js' import { createFetch } from '@whatwg-node/fetch' import { ServerAdapter, createServerAdapter } from '@whatwg-node/server' -import { processRequest as processGraphQLParams } from './processRequest.js' +import { + processRequest as processGraphQLParams, + processResult, +} from './processRequest.js' import { defaultYogaLogger, titleBold, YogaLogger } from './logger.js' import { CORSPluginOptions, useCORS } from './plugins/useCORS.js' import { useHealthCheck } from './plugins/useHealthCheck.js' diff --git a/packages/plugins/response-cache/package.json b/packages/plugins/response-cache/package.json index ec48cfc6df..e25df5e78d 100644 --- a/packages/plugins/response-cache/package.json +++ b/packages/plugins/response-cache/package.json @@ -54,7 +54,7 @@ }, "peerDependencies": { "graphql": "^15.2.0 || ^16.0.0", - "@graphql-yoga/common": "^2.10.0" + "graphql-yoga": "^2.13.4" }, "type": "module" } diff --git a/packages/plugins/response-cache/src/index.ts b/packages/plugins/response-cache/src/index.ts index fb9570c3b9..6300ea4632 100644 --- a/packages/plugins/response-cache/src/index.ts +++ b/packages/plugins/response-cache/src/index.ts @@ -6,12 +6,7 @@ import { useResponseCache as useEnvelopResponseCache, UseResponseCacheParameter as UseEnvelopResponseCacheParameter, } from '@envelop/response-cache' -import { - GraphQLParams, - Maybe, - Plugin, - YogaInitialContext, -} from '@graphql-yoga/common' +import { GraphQLParams, Maybe, Plugin, YogaInitialContext } from 'graphql-yoga' export type UseResponseCacheParameter = Omit< UseEnvelopResponseCacheParameter, From d57ce9b509dc84a643ff8ac48fc1ff0aa508749c Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 28 Jul 2022 10:37:15 +0200 Subject: [PATCH 4/7] chore: upgrade to latest envelop/response-cache --- .../__tests__/response-cache.spec.ts | 122 ++++++++++++++++++ packages/plugins/response-cache/package.json | 3 +- packages/plugins/response-cache/src/index.ts | 28 ++-- yarn.lock | 10 +- 4 files changed, 141 insertions(+), 22 deletions(-) create mode 100644 packages/plugins/response-cache/__tests__/response-cache.spec.ts diff --git a/packages/plugins/response-cache/__tests__/response-cache.spec.ts b/packages/plugins/response-cache/__tests__/response-cache.spec.ts new file mode 100644 index 0000000000..72094950ce --- /dev/null +++ b/packages/plugins/response-cache/__tests__/response-cache.spec.ts @@ -0,0 +1,122 @@ +import { createYoga } from 'graphql-yoga' +import { useResponseCache } from '@graphql-yoga/plugin-response-cache' + +it('cache a query operation', async () => { + const yoga = createYoga({ + plugins: [ + useResponseCache({ + session: () => null, + includeExtensionMetadata: true, + }), + ], + }) + function fetch() { + return yoga.fetch('http://localhost:3000/graphql', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ query: '{__typename}' }), + }) + } + + let response = await fetch() + + expect(response.status).toEqual(200) + let body = await response.json() + expect(body).toEqual({ + data: { + __typename: 'Query', + }, + extensions: { + responseCache: { + didCache: true, + hit: false, + ttl: null, + }, + }, + }) + + response = await fetch() + expect(response.status).toEqual(200) + body = await response.json() + expect(body).toEqual({ + data: { + __typename: 'Query', + }, + extensions: { + responseCache: { + hit: true, + }, + }, + }) +}) + +it('cache a query operation per session', async () => { + const yoga = createYoga({ + plugins: [ + useResponseCache({ + session: (_, request) => request.headers.get('x-session-id') ?? null, + includeExtensionMetadata: true, + }), + ], + }) + function fetch(sessionId: string) { + return yoga.fetch('http://localhost:3000/graphql', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify({ query: '{__typename}' }), + }) + } + + let response = await fetch('1') + + expect(response.status).toEqual(200) + let body = await response.json() + expect(body).toEqual({ + data: { + __typename: 'Query', + }, + extensions: { + responseCache: { + didCache: true, + hit: false, + ttl: null, + }, + }, + }) + + response = await fetch('1') + expect(response.status).toEqual(200) + body = await response.json() + expect(body).toEqual({ + data: { + __typename: 'Query', + }, + extensions: { + responseCache: { + hit: true, + }, + }, + }) + + response = await fetch('2') + + expect(response.status).toEqual(200) + body = await response.json() + expect(body).toEqual({ + data: { + __typename: 'Query', + }, + extensions: { + responseCache: { + didCache: true, + hit: false, + ttl: null, + }, + }, + }) +}) diff --git a/packages/plugins/response-cache/package.json b/packages/plugins/response-cache/package.json index e25df5e78d..e85673755a 100644 --- a/packages/plugins/response-cache/package.json +++ b/packages/plugins/response-cache/package.json @@ -49,8 +49,7 @@ "access": "public" }, "dependencies": { - "@envelop/response-cache": "2.4.0", - "tslib": "^2.3.1" + "@envelop/response-cache": "^3.0.0" }, "peerDependencies": { "graphql": "^15.2.0 || ^16.0.0", diff --git a/packages/plugins/response-cache/src/index.ts b/packages/plugins/response-cache/src/index.ts index 6300ea4632..6901a358ba 100644 --- a/packages/plugins/response-cache/src/index.ts +++ b/packages/plugins/response-cache/src/index.ts @@ -2,34 +2,34 @@ import { BuildResponseCacheKeyFunction, createInMemoryCache, defaultBuildResponseCacheKey, - GetDocumentStringFromContextFunction, useResponseCache as useEnvelopResponseCache, UseResponseCacheParameter as UseEnvelopResponseCacheParameter, } from '@envelop/response-cache' -import { GraphQLParams, Maybe, Plugin, YogaInitialContext } from 'graphql-yoga' +import { + GraphQLParams, + Maybe, + Plugin, + PromiseOrValue, + YogaInitialContext, +} from 'graphql-yoga' export type UseResponseCacheParameter = Omit< UseEnvelopResponseCacheParameter, - 'getDocumentStringFromContext' | 'session' + 'getDocumentString' | 'session' > & { - session: (params: GraphQLParams, request: Request) => Maybe + session: ( + params: GraphQLParams, + request: Request, + ) => PromiseOrValue> enabled?: (params: GraphQLParams, request: Request) => boolean } -// Probably this is not used but somehow if Envelop plugin needs that -const getDocumentStringFromContext: GetDocumentStringFromContextFunction = ( - context, -) => context.query as string - const operationIdByRequest = new WeakMap() // We trick Envelop plugin by passing operationId as sessionId so we can take it from cache key builder we pass to Envelop function sessionFactoryForEnvelop({ request }: YogaInitialContext) { return operationIdByRequest.get(request) } -const buildResponseCacheKeyForEnvelop: BuildResponseCacheKeyFunction = async ({ - sessionId, -}) => sessionId! export function useResponseCache(options: UseResponseCacheParameter): Plugin { const buildResponseCacheKey: BuildResponseCacheKeyFunction = @@ -42,9 +42,7 @@ export function useResponseCache(options: UseResponseCacheParameter): Plugin { useEnvelopResponseCache({ ...options, cache, - getDocumentStringFromContext, session: sessionFactoryForEnvelop, - buildResponseCacheKey: buildResponseCacheKeyForEnvelop, }), ) }, @@ -56,7 +54,7 @@ export function useResponseCache(options: UseResponseCacheParameter): Plugin { documentString: params.query!, variableValues: params.variables, operationName: params.operationName, - sessionId: options.session(params, request), + sessionId: await options.session(params, request), }) const cachedResponse = await cache.get(operationId) if (cachedResponse) { diff --git a/yarn.lock b/yarn.lock index e8c9ae3608..08725edd71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2918,10 +2918,10 @@ dependencies: tiny-lru "7.0.6" -"@envelop/response-cache@2.4.0": - version "2.4.0" - resolved "https://registry.yarnpkg.com/@envelop/response-cache/-/response-cache-2.4.0.tgz#0420afe2bea6bf636e607343e4b517b03ee3fa9c" - integrity sha512-flJYReUL+wyZ3x3TYGd6XgO+Em/b9BO+N3Ii915slSsvU7N60Xczo9hG+ewrne4dC4EDJ6NAWcybGvwe6qKd4Q== +"@envelop/response-cache@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@envelop/response-cache/-/response-cache-3.0.0.tgz#fd53523e00c66bd221c2e1200f6eb00b7529c206" + integrity sha512-sGlX5noloUqUG5oVfckbuXTuOaFsYLEqz5zTgrTE47eTE+j+s1mooKecr0fyr3M83DHf/plF0/xilOtHsrJxyQ== dependencies: "@graphql-tools/utils" "^8.8.0" fast-json-stable-stringify "^2.1.0" @@ -10172,7 +10172,7 @@ esbuild-windows-arm64@0.14.50: resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.50.tgz#e7ddde6a97194051a5a4ac05f4f5900e922a7ea5" integrity sha512-EMS4lQnsIe12ZyAinOINx7eq2mjpDdhGZZWDwPZE/yUTN9cnc2Ze/xUTYIAyaJqrqQda3LnDpADKpvLvol6ENQ== -esbuild@0.14.47, esbuild@0.14.49, esbuild@0.14.50, esbuild@^0.14.25, esbuild@^0.14.47, esbuild@^0.14.48: +esbuild@0.14.47, esbuild@0.14.50, esbuild@^0.14.25, esbuild@^0.14.47, esbuild@^0.14.48: version "0.14.50" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.50.tgz#7a665392c8df94bf6e1ae1e999966a5ee62c6cbc" integrity sha512-SbC3k35Ih2IC6trhbMYW7hYeGdjPKf9atTKwBUHqMCYFZZ9z8zhuvfnZihsnJypl74FjiAKjBRqFkBkAd0rS/w== From 5269fa62854fa598fbfda3155e4c4a8edffc636c Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 28 Jul 2022 12:36:36 +0200 Subject: [PATCH 5/7] no need to graphql parameters --- .../__tests__/response-cache.spec.ts | 2 +- packages/plugins/response-cache/src/index.ts | 21 +++++++------------ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/plugins/response-cache/__tests__/response-cache.spec.ts b/packages/plugins/response-cache/__tests__/response-cache.spec.ts index 72094950ce..14a7a628ee 100644 --- a/packages/plugins/response-cache/__tests__/response-cache.spec.ts +++ b/packages/plugins/response-cache/__tests__/response-cache.spec.ts @@ -56,7 +56,7 @@ it('cache a query operation per session', async () => { const yoga = createYoga({ plugins: [ useResponseCache({ - session: (_, request) => request.headers.get('x-session-id') ?? null, + session: (request) => request.headers.get('x-session-id') ?? null, includeExtensionMetadata: true, }), ], diff --git a/packages/plugins/response-cache/src/index.ts b/packages/plugins/response-cache/src/index.ts index 6901a358ba..40e3d1261c 100644 --- a/packages/plugins/response-cache/src/index.ts +++ b/packages/plugins/response-cache/src/index.ts @@ -5,23 +5,14 @@ import { useResponseCache as useEnvelopResponseCache, UseResponseCacheParameter as UseEnvelopResponseCacheParameter, } from '@envelop/response-cache' -import { - GraphQLParams, - Maybe, - Plugin, - PromiseOrValue, - YogaInitialContext, -} from 'graphql-yoga' +import { Maybe, Plugin, PromiseOrValue, YogaInitialContext } from 'graphql-yoga' export type UseResponseCacheParameter = Omit< UseEnvelopResponseCacheParameter, 'getDocumentString' | 'session' > & { - session: ( - params: GraphQLParams, - request: Request, - ) => PromiseOrValue> - enabled?: (params: GraphQLParams, request: Request) => boolean + session: (request: Request) => PromiseOrValue> + enabled?: (request: Request) => boolean } const operationIdByRequest = new WeakMap() @@ -49,12 +40,12 @@ export function useResponseCache(options: UseResponseCacheParameter): Plugin { onRequestParse({ request }) { return { async onRequestParseDone({ params, setResult }) { - if (enabled(params, request)) { + if (enabled(request)) { const operationId = await buildResponseCacheKey({ documentString: params.query!, variableValues: params.variables, operationName: params.operationName, - sessionId: await options.session(params, request), + sessionId: await options.session(request), }) const cachedResponse = await cache.get(operationId) if (cachedResponse) { @@ -79,3 +70,5 @@ export function useResponseCache(options: UseResponseCacheParameter): Plugin { }, } } + +export { createInMemoryCache } From 83093b274d8a16d0929fdba57b0f58d993be3901 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 28 Jul 2022 12:55:59 +0200 Subject: [PATCH 6/7] add docs --- packages/plugins/response-cache/README.md | 3 + website/routes.ts | 1 + website/v3/docs/features/response-caching.mdx | 201 ++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 packages/plugins/response-cache/README.md create mode 100644 website/v3/docs/features/response-caching.mdx diff --git a/packages/plugins/response-cache/README.md b/packages/plugins/response-cache/README.md new file mode 100644 index 0000000000..02da9b6e91 --- /dev/null +++ b/packages/plugins/response-cache/README.md @@ -0,0 +1,3 @@ +# @graphql-yoga/plugin-response-cache + +For the documentation check `http://graphql-yoga.com/docs/response-cache` diff --git a/website/routes.ts b/website/routes.ts index 55f5551004..854c6420b3 100644 --- a/website/routes.ts +++ b/website/routes.ts @@ -103,6 +103,7 @@ export function getDocsV3Routes(): IRoutes { ['testing', 'Testing'], ['persisted-operations', 'Persisted Operations'], ['automatic-persisted-queries', 'Automatic Persisted Queries'], + ['response-caching', 'Response Caching'], ], }, integrations: { diff --git a/website/v3/docs/features/response-caching.mdx b/website/v3/docs/features/response-caching.mdx new file mode 100644 index 0000000000..272465c705 --- /dev/null +++ b/website/v3/docs/features/response-caching.mdx @@ -0,0 +1,201 @@ +--- +id: response-caching +title: Response Caching +sidebar_label: Resposne Caching +--- + +Response caching is a technique for reducing server load by caching GraphQL query operation results. + +## Installation + + + +## Quick Start + +```ts +import { createYoga } from 'graphql-yoga' +import { createServer } from 'node:http' +import { useResponseCache } from '@graphql-yoga/plugin-response-cache' + +const yoga = createYoga({ + schema: { + typeDefs: `type Query { slow: String}`, + resolvers: { + Query: { + slow: async () => { + await new Promise((resolve) => setTimeout(resolve, 5000)) + return 'I am slow.' + }, + }, + }, + }, + plugins: [ + useResponseCache({ + // global cache + session: () => null, + }), + ], +}) + +const server = createServer(yoga) +server.listen(4000) +``` + +``` +curl -X POST -H 'Content-Type: application/json' http://localhost:4000/graphql \ +-d '{"query":"{__typename}"}' -w '\nTotal time : %{time_total}' +``` + +This will output something like the following: + +``` +{"data":{"slow":"I am slow."}} +Total time:5.026632 +``` + +After executing the same curl statement a second time, the duration is significantly lower. + +``` +{"data":{"slow":"I am slow."}} +Total time:0.007571% +``` + +## Session based caching + +If your GraphQL API returns specific data depending on the viewer's session, you can use the `session` option to cache the response per session. + +```ts +useResponseCache({ + // cache based on the authentication header + session: (request) => request.headers.get('authentication'), +}) +``` + +## TTL + +It is possible to give cached operations a time to live. Either globally, based on [schema coordinates](https://github.com/graphql/graphql-wg/blob/main/rfcs/SchemaCoordinates.md) or object types. +If a query operation result contains multiple objects of the same type, the lowest TTL is picked. + +```ts +useResponseCache({ + session: () => null, + // by default cache all operations for 2 seconds + ttl: 2_000, + ttlPerType: { + // only cache query operations containing User for 500ms + User: 500, + }, + ttlPerSchemaCoordinate: { + // cache operations selecting Query.lazy for 10 seconds + 'Query.lazy': 10_000, + }, +}) +``` + +## Invalidatios via Mutation + +When executing a mutation operation the cached query results that contain type entities within the Mutation result will be automatically be invalidated. + +```graphql +mutation { + updateUser(id: 1, newName: "John") { + __typename + id + name + } +} +``` + +```json +{ + "data": { + "updateLaunch": { + "__typename": "User", + "id": "1", + "name": "John" + } + } +} +``` + +All cached query results that contain the type `User` with the id `1` will be invalidated. + +This behavior can be disabled by setting the `invalidateViaMutation` option to `false`. + +```ts +useResponseCache({ + session: (request) => null, + invalidateViaMutation: false, +}) +``` + +## Manual Invalidation + +You can invalidate a type or specific instances of a type using the cache invalidation API. + +In order to use the API, you need to manually instantiate the cache an pass it to the `useResponseCache` plugin. + +```ts +import { + useResponseCache, + createInMemoryCache, +} from '@graphql-yoga/plugin-response-cache' + +const cache = createInMemoryCache() + +useResponseCache({ + session: () => null, + cache, +}) +``` + +Then in your business logic you can call the `invalidate` method on the cache instance. + +Invalidate all GraphQL query results that referance a specific type: + +```ts +cache.invalidate([{ type: 'User' }]) +``` + +Invalidate all GraphQL query results that reference a specific entity of a type: + +```ts +cache.invalidate([{ type: 'User', id: '1' }]) +``` + +Invalidate all GraphQL query results multiple entities in a single call. + +```ts +cache.invalidate([ + { type: 'Post', id: '1' }, + { type: 'User', id: '2' }, +]) +``` + +## External Cache + +By default the response cache stores all the cached query results in memory. + +If you want a cache that is shared between multiple server instances you can use the Redis cache implementation. + + + +```ts +import { useResponseCache } from '@graphql-yoga/plugin-response-cache' +import Redis from 'ioredis' + +const redis = new Redis({ + host: 'my-redis-db.example.com', + port: '30652', + password: '1234567890', +}) + +const redis = new Redis('rediss://:1234567890@my-redis-db.example.com:30652') + +const cache = createRedisCache({ redis }) + +useResponseCache({ + session: () => null, + cache, +}) +``` From a0026b72dd5261094cb242c74a96e13c671b48c3 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 28 Jul 2022 14:27:54 +0200 Subject: [PATCH 7/7] white space ok --- packages/graphql-yoga/src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graphql-yoga/src/server.ts b/packages/graphql-yoga/src/server.ts index aa4657434e..1dd363cb54 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -146,8 +146,8 @@ export type YogaServerOptions< /** * Whether the landing page should be shown. */ - landingPage?: boolean + /** * GraphiQL options *