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. -

399 lines (397 loc) • 18.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.cacheControlDirective = exports.resultWithMetadata = exports.useResponseCache = exports.defaultGetDocumentString = exports.defaultShouldCacheResult = exports.defaultBuildResponseCacheKey = void 0; const tslib_1 = require("tslib"); const fast_json_stable_stringify_1 = tslib_1.__importDefault(require("fast-json-stable-stringify")); const graphql_1 = require("graphql"); const core_1 = require("@envelop/core"); const utils_1 = require("@graphql-tools/utils"); const hash_sha256_js_1 = require("./hash-sha256.js"); const in_memory_cache_js_1 = require("./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. */ const defaultBuildResponseCacheKey = (params) => (0, hash_sha256_js_1.hashSHA256)([ params.documentString, params.operationName ?? '', (0, fast_json_stable_stringify_1.default)(params.variableValues ?? {}), params.sessionId ?? '', ].join('|')); exports.defaultBuildResponseCacheKey = defaultBuildResponseCacheKey; /** * 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. */ 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; }; exports.defaultShouldCacheResult = defaultShouldCacheResult; function defaultGetDocumentString(executionArgs) { return (0, core_1.getDocumentString)(executionArgs.document, graphql_1.print); } exports.defaultGetDocumentString = defaultGetDocumentString; const getDocumentWithMetadataAndTTL = (0, utils_1.memoize4)(function addTypeNameToDocument(document, { invalidateViaMutation, ttlPerSchemaCoordinate, }, schema, idFieldByTypeName) { const typeInfo = new graphql_1.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: graphql_1.Kind.FIELD, name: { kind: graphql_1.Kind.NAME, value: '__typename', }, alias: { kind: graphql_1.Kind.NAME, value: '__responseCacheTypeName', }, }, ...(idField ? [ { kind: graphql_1.Kind.FIELD, name: { kind: graphql_1.Kind.NAME, value: idField }, alias: { kind: graphql_1.Kind.NAME, value: '__responseCacheId' }, }, ] : []), ...node.selections, ], }; }, }; return [(0, graphql_1.visit)(document, (0, graphql_1.visitWithTypeInfo)(typeInfo, visitor)), ttl]; }); function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache)(), ttl: globalTtl = Infinity, session, enabled, ignoredTypes = [], ttlPerType, ttlPerSchemaCoordinate = {}, scopePerSchemaCoordinate = {}, idFields = ['id'], invalidateViaMutation = true, buildResponseCacheKey = exports.defaultBuildResponseCacheKey, getDocumentString = defaultGetDocumentString, shouldCacheResult = exports.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' ? (0, utils_1.memoize1)(cache) : () => cache; const ignoredTypesMap = new Set(ignoredTypes); const typePerSchemaCoordinateMap = new Map(); enabled = enabled ? (0, utils_1.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'); (0, utils_1.mapSchema)(schema, { ...(directive && { [utils_1.MapperKind.COMPOSITE_TYPE]: type => { const cacheControlAnnotations = (0, utils_1.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; }, }), [utils_1.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 = (0, utils_1.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 = (0, graphql_1.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 ((0, core_1.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 ((0, core_1.isAsyncIterable)(result)) { return handleAsyncIterableResult(maybeCacheResult); } return maybeCacheResult(result, setResult); }, }); }, }; } exports.useResponseCache = useResponseCache; 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) { (0, utils_1.mergeIncrementalResult)({ executionResult: result, incrementalResult: patch }); } } if (!hasNext) { // The query is complete, we can process the final result handler(result, payload.setResult); } } }, }; } function resultWithMetadata(result, metadata) { return { ...result, extensions: { ...result.extensions, responseCache: { ...result.extensions?.responseCache, ...metadata, }, }, }; } exports.resultWithMetadata = resultWithMetadata; 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]; } exports.cacheControlDirective = ` enum CacheControlScope { PUBLIC PRIVATE } directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT `;