UNPKG

@envelop/response-cache

Version:

- Skip the execution phase and reduce server load by caching execution results in-memory. - Customize cache entry time to live based on fields and types within the execution result. - Automatically invalidate the cache based on mutation selection sets. -

390 lines (388 loc) • 17.9 kB
import jsonStableStringify from 'fast-json-stable-stringify'; import { getOperationAST, Kind, print, TypeInfo, visit, visitWithTypeInfo, } from 'graphql'; import { getDocumentString, isAsyncIterable, } from '@envelop/core'; import { getDirective, MapperKind, mapSchema, memoize1, memoize4, mergeIncrementalResult, } from '@graphql-tools/utils'; import { hashSHA256 } from './hash-sha256.js'; import { createInMemoryCache } from './in-memory-cache.js'; /** * Default function used for building the response cache key. * It is exported here for advanced use-cases. E.g. if you want to short circuit and serve responses from the cache on a global level in order to completely by-pass the GraphQL flow. */ export const defaultBuildResponseCacheKey = (params) => hashSHA256([ params.documentString, params.operationName ?? '', jsonStableStringify(params.variableValues ?? {}), params.sessionId ?? '', ].join('|')); /** * Default function used to check if the result should be cached. * * It is exported here for advanced use-cases. E.g. if you want to choose if * results with certain error types should be cached. * * By default, results with errors (unexpected, EnvelopError, or GraphQLError) are not cached. */ export const defaultShouldCacheResult = (params) => { if (params.result.errors) { // eslint-disable-next-line no-console console.warn('[useResponseCache] Failed to cache due to errors'); return false; } return true; }; export function defaultGetDocumentString(executionArgs) { return getDocumentString(executionArgs.document, print); } const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(document, { invalidateViaMutation, ttlPerSchemaCoordinate, }, schema, idFieldByTypeName) { const typeInfo = new TypeInfo(schema); let ttl; const visitor = { OperationDefinition: { enter(node) { if (!invalidateViaMutation && node.operation === 'mutation') { return false; } if (node.operation === 'subscription') { return false; } }, }, ...(ttlPerSchemaCoordinate != null && { Field(fieldNode) { const parentType = typeInfo.getParentType(); if (parentType) { const schemaCoordinate = `${parentType.name}.${fieldNode.name.value}`; const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate]; ttl = calculateTtl(maybeTtl, ttl); } }, }), SelectionSet(node, _key) { const parentType = typeInfo.getParentType(); const idField = parentType && idFieldByTypeName.get(parentType.name); return { ...node, selections: [ { kind: Kind.FIELD, name: { kind: Kind.NAME, value: '__typename', }, alias: { kind: Kind.NAME, value: '__responseCacheTypeName', }, }, ...(idField ? [ { kind: Kind.FIELD, name: { kind: Kind.NAME, value: idField }, alias: { kind: Kind.NAME, value: '__responseCacheId' }, }, ] : []), ...node.selections, ], }; }, }; return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl]; }); export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl = Infinity, session, enabled, ignoredTypes = [], ttlPerType, ttlPerSchemaCoordinate = {}, scopePerSchemaCoordinate = {}, idFields = ['id'], invalidateViaMutation = true, buildResponseCacheKey = defaultBuildResponseCacheKey, getDocumentString = defaultGetDocumentString, shouldCacheResult = defaultShouldCacheResult, includeExtensionMetadata = typeof process !== 'undefined' ? // eslint-disable-next-line dot-notation process.env['NODE_ENV'] === 'development' || !!process.env['DEBUG'] : false, }) { const cacheFactory = typeof cache === 'function' ? memoize1(cache) : () => cache; const ignoredTypesMap = new Set(ignoredTypes); const typePerSchemaCoordinateMap = new Map(); enabled = enabled ? memoize1(enabled) : enabled; // never cache Introspections ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...ttlPerSchemaCoordinate }; if (ttlPerType) { for (const [typeName, ttl] of Object.entries(ttlPerType)) { ttlPerSchemaCoordinate[typeName] = ttl; } } const documentMetadataOptions = { queries: { invalidateViaMutation, ttlPerSchemaCoordinate }, mutations: { invalidateViaMutation }, // remove ttlPerSchemaCoordinate for mutations to skip TTL calculation }; const idFieldByTypeName = new Map(); let schema; function isPrivate(typeName, data) { if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') { return true; } return Object.keys(data).some(fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE'); } return { onSchemaChange({ schema: newSchema }) { if (schema === newSchema) { return; } schema = newSchema; const directive = schema.getDirective('cacheControl'); mapSchema(schema, { ...(directive && { [MapperKind.COMPOSITE_TYPE]: type => { const cacheControlAnnotations = getDirective(schema, type, 'cacheControl'); cacheControlAnnotations?.forEach(cacheControl => { if (cacheControl.maxAge != null) { ttlPerSchemaCoordinate[type.name] = cacheControl.maxAge * 1000; } if (cacheControl.scope) { scopePerSchemaCoordinate[type.name] = cacheControl.scope; } }); return type; }, }), [MapperKind.FIELD]: (fieldConfig, fieldName, typeName) => { const schemaCoordinates = `${typeName}.${fieldName}`; const resultTypeNames = unwrapTypenames(fieldConfig.type); typePerSchemaCoordinateMap.set(schemaCoordinates, resultTypeNames); if (idFields.includes(fieldName) && !idFieldByTypeName.has(typeName)) { idFieldByTypeName.set(typeName, fieldName); } if (directive) { const cacheControlAnnotations = getDirective(schema, fieldConfig, 'cacheControl'); cacheControlAnnotations?.forEach(cacheControl => { if (cacheControl.maxAge != null) { ttlPerSchemaCoordinate[schemaCoordinates] = cacheControl.maxAge * 1000; } if (cacheControl.scope) { scopePerSchemaCoordinate[schemaCoordinates] = cacheControl.scope; } }); } return fieldConfig; }, }); }, async onExecute(onExecuteParams) { if (enabled && !enabled(onExecuteParams.args.contextValue)) { return; } const identifier = new Map(); const types = new Set(); let currentTtl; let skip = false; const sessionId = session(onExecuteParams.args.contextValue); function setExecutor({ execute, onExecuteDone, }) { let executed = false; onExecuteParams.setExecuteFn(args => { executed = true; return execute(args); }); return { onExecuteDone(params) { if (!executed) { // eslint-disable-next-line no-console console.warn('[useResponseCache] The cached execute function was not called, another plugin might have overwritten it. Please check your plugin order.'); } return onExecuteDone?.(params); }, }; } function processResult(data) { if (data == null || typeof data !== 'object') { return; } if (Array.isArray(data)) { for (const item of data) { processResult(item); } return; } const typename = data.__responseCacheTypeName; delete data.__responseCacheTypeName; const entityId = data.__responseCacheId; delete data.__responseCacheId; // Always process nested objects, even if we are skipping cache, to ensure the result is cleaned up // of metadata fields added to the query document. for (const fieldName in data) { processResult(data[fieldName]); } if (!skip) { if (ignoredTypesMap.has(typename) || (!sessionId && isPrivate(typename, data))) { skip = true; return; } types.add(typename); if (typename in ttlPerSchemaCoordinate) { const maybeTtl = ttlPerSchemaCoordinate[typename]; currentTtl = calculateTtl(maybeTtl, currentTtl); } if (entityId != null) { identifier.set(`${typename}:${entityId}`, { typename, id: entityId }); } for (const fieldName in data) { const fieldData = data[fieldName]; if (fieldData == null || (Array.isArray(fieldData) && fieldData.length === 0)) { const inferredTypes = typePerSchemaCoordinateMap.get(`${typename}.${fieldName}`); inferredTypes?.forEach(inferredType => { if (inferredType in ttlPerSchemaCoordinate) { const maybeTtl = ttlPerSchemaCoordinate[inferredType]; currentTtl = calculateTtl(maybeTtl, currentTtl); } identifier.set(inferredType, { typename: inferredType }); }); } } } } function invalidateCache(result, setResult) { processResult(result.data); const cacheInstance = cacheFactory(onExecuteParams.args.contextValue); if (cacheInstance == null) { // eslint-disable-next-line no-console console.warn('[useResponseCache] Cache instance is not available for the context. Skipping invalidation.'); return; } cacheInstance.invalidate(identifier.values()); if (includeExtensionMetadata) { setResult(resultWithMetadata(result, { invalidatedEntities: Array.from(identifier.values()), })); } } if (invalidateViaMutation !== false) { const operationAST = getOperationAST(onExecuteParams.args.document, onExecuteParams.args.operationName); if (operationAST?.operation === 'mutation') { return setExecutor({ execute(args) { const [document] = getDocumentWithMetadataAndTTL(args.document, documentMetadataOptions.mutations, args.schema, idFieldByTypeName); return onExecuteParams.executeFn({ ...args, document }); }, onExecuteDone({ result, setResult }) { if (isAsyncIterable(result)) { return handleAsyncIterableResult(invalidateCache); } return invalidateCache(result, setResult); }, }); } } const cacheKey = await buildResponseCacheKey({ documentString: getDocumentString(onExecuteParams.args), variableValues: onExecuteParams.args.variableValues, operationName: onExecuteParams.args.operationName, sessionId, context: onExecuteParams.args.contextValue, }); const cacheInstance = cacheFactory(onExecuteParams.args.contextValue); if (cacheInstance == null) { // eslint-disable-next-line no-console console.warn('[useResponseCache] Cache instance is not available for the context. Skipping cache lookup.'); } const cachedResponse = (await cacheInstance.get(cacheKey)); if (cachedResponse != null) { return setExecutor({ execute: () => includeExtensionMetadata ? resultWithMetadata(cachedResponse, { hit: true }) : cachedResponse, }); } function maybeCacheResult(result, setResult) { processResult(result.data); // we only use the global ttl if no currentTtl has been determined. const finalTtl = currentTtl ?? globalTtl; if (skip || !shouldCacheResult({ cacheKey, result }) || finalTtl === 0) { if (includeExtensionMetadata) { setResult(resultWithMetadata(result, { hit: false, didCache: false })); } return; } cacheInstance.set(cacheKey, result, identifier.values(), finalTtl); if (includeExtensionMetadata) { setResult(resultWithMetadata(result, { hit: false, didCache: true, ttl: finalTtl })); } } return setExecutor({ execute(args) { const [document, ttl] = getDocumentWithMetadataAndTTL(args.document, documentMetadataOptions.queries, schema, idFieldByTypeName); currentTtl = ttl; return onExecuteParams.executeFn({ ...args, document }); }, onExecuteDone({ result, setResult }) { if (isAsyncIterable(result)) { return handleAsyncIterableResult(maybeCacheResult); } return maybeCacheResult(result, setResult); }, }); }, }; } function handleAsyncIterableResult(handler) { // When the result is an AsyncIterable, it means the query is using @defer or @stream. // This means we have to build the final result by merging the incremental results. // The merged result is then used to know if we should cache it and to calculate the ttl. const result = {}; return { onNext(payload) { const { data, errors, extensions } = payload.result; // This is the first result with the initial data payload sent to the client. We use it as the base result if (data) { result.data = data; } if (errors) { result.errors = errors; } if (extensions) { result.extensions = extensions; } if ('hasNext' in payload.result) { const { incremental, hasNext } = payload.result; if (incremental) { for (const patch of incremental) { mergeIncrementalResult({ executionResult: result, incrementalResult: patch }); } } if (!hasNext) { // The query is complete, we can process the final result handler(result, payload.setResult); } } }, }; } export function resultWithMetadata(result, metadata) { return { ...result, extensions: { ...result.extensions, responseCache: { ...result.extensions?.responseCache, ...metadata, }, }, }; } function calculateTtl(typeTtl, currentTtl) { if (typeof typeTtl === 'number' && !Number.isNaN(typeTtl)) { if (typeof currentTtl === 'number') { return Math.min(currentTtl, typeTtl); } return typeTtl; } return currentTtl; } function unwrapTypenames(type) { if (type.ofType) { return unwrapTypenames(type.ofType); } if (type._types) { return type._types.map((t) => unwrapTypenames(t)).flat(); } return [type.name]; } export const cacheControlDirective = /* GraphQL */ ` enum CacheControlScope { PUBLIC PRIVATE } directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT `;