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..1dd363cb54 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -30,10 +30,14 @@ import { Plugin, RequestParser, ResultProcessor, + ResultProcessorInput, } 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' @@ -143,6 +147,7 @@ export type YogaServerOptions< * Whether the landing page should be shown. */ landingPage?: boolean + /** * GraphiQL options * @@ -383,15 +388,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 +496,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/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/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..14a7a628ee --- /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 new file mode 100644 index 0000000000..e85673755a --- /dev/null +++ b/packages/plugins/response-cache/package.json @@ -0,0 +1,59 @@ +{ + "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": "^3.0.0" + }, + "peerDependencies": { + "graphql": "^15.2.0 || ^16.0.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 new file mode 100644 index 0000000000..40e3d1261c --- /dev/null +++ b/packages/plugins/response-cache/src/index.ts @@ -0,0 +1,74 @@ +import { + BuildResponseCacheKeyFunction, + createInMemoryCache, + defaultBuildResponseCacheKey, + useResponseCache as useEnvelopResponseCache, + UseResponseCacheParameter as UseEnvelopResponseCacheParameter, +} from '@envelop/response-cache' +import { Maybe, Plugin, PromiseOrValue, YogaInitialContext } from 'graphql-yoga' + +export type UseResponseCacheParameter = Omit< + UseEnvelopResponseCacheParameter, + 'getDocumentString' | 'session' +> & { + session: (request: Request) => PromiseOrValue> + enabled?: (request: Request) => boolean +} + +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) +} + +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, + session: sessionFactoryForEnvelop, + }), + ) + }, + onRequestParse({ request }) { + return { + async onRequestParseDone({ params, setResult }) { + if (enabled(request)) { + const operationId = await buildResponseCacheKey({ + documentString: params.query!, + variableValues: params.variables, + operationName: params.operationName, + sessionId: await options.session(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) + } + }, + } + }, + } +} + +export { createInMemoryCache } 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, +}) +``` diff --git a/yarn.lock b/yarn.lock index beac8ccdb8..08725edd71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2918,6 +2918,15 @@ dependencies: tiny-lru "7.0.6" +"@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" + 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"