UNPKG

@soundxyz/response-cache

Version:

Heavily inspired by @envelop/response-cache

319 lines (312 loc) 11.1 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); const core = require('@envelop/core'); const utils = require('@graphql-tools/utils'); const graphql = require('graphql'); const jsonStableStringify = require('fast-json-stable-stringify'); const hashSHA256 = require('./hashSHA256.js'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } const jsonStableStringify__default = /*#__PURE__*/_interopDefaultLegacy(jsonStableStringify); const contextSymbol = Symbol("responseCache"); const rawDocumentStringSymbol = Symbol("rawDocumentString"); const defaultBuildResponseCacheKey = (params) => hashSHA256.hashSHA256( [ params.documentString, params.operationName ?? "", jsonStableStringify__default["default"](params.variableValues ?? {}), params.sessionId ?? "" ].join("|") ); const defaultShouldCacheResult = (params) => { if (params.result.errors) { console.warn("[useResponseCache] Failed to cache due to errors", params.result.errors); return false; } return true; }; const defaultGetDocumentStringFromContext = (context) => context[rawDocumentStringSymbol]; function useResponseCache({ cache, ttl: globalTtl = Infinity, session = () => null, enabled, ignoredTypes = [], ttlPerType = {}, ttlPerSchemaCoordinate = {}, idFields = ["id"], invalidateViaMutation = true, buildResponseCacheKey = defaultBuildResponseCacheKey, getDocumentStringFromContext = defaultGetDocumentStringFromContext, shouldCacheResult = defaultShouldCacheResult, // eslint-disable-next-line dot-notation includeExtensionMetadata = typeof process !== "undefined" ? process.env["NODE_ENV"] === "development" : false }) { const ignoredTypesMap = new Set(ignoredTypes); const schemaCache = /* @__PURE__ */ new WeakMap(); ttlPerSchemaCoordinate["Query.__schema"] ??= 0; const enabledCachingDocuments = /* @__PURE__ */ new WeakMap(); const idempotentPromises = /* @__PURE__ */ new Map(); function idempotentCall(key, cb) { const existingPromise = idempotentPromises.get(key); if (existingPromise) return existingPromise; function cleanupPromise() { idempotentPromises.delete(key); } const newPromise = cb().finally(cleanupPromise); idempotentPromises.set(key, newPromise); setTimeout(cleanupPromise); return newPromise; } return { onSchemaChange(ctx) { let schema = schemaCache.get(ctx.schema); if (schema == null) { schema = applyResponseCacheLogic(ctx.schema, idFields); schemaCache.set(ctx.schema, schema); } ctx.replaceSchema(schema); }, onParse(parseCtx) { return function onParseEnd(ctx) { if (ctx.result && "kind" in ctx.result) { const source = parseCtx.params.source; const rawDocumentString = typeof source === "string" ? source : source.body; ctx.extendContext({ [rawDocumentStringSymbol]: rawDocumentString }); } }; }, async onExecute(ctx) { const identifier = /* @__PURE__ */ new Map(); const types = /* @__PURE__ */ new Set(); const context = { identifier, types, currentTtl: void 0, ttlPerType, ignoredTypesMap, skip: false }; const publicContext = { $responseCache: { setExpiry({ ttl }) { context.currentTtl = ttl; }, getExpiry() { return context.currentTtl ?? globalTtl; } } }; const partialCtx = { [contextSymbol]: context, ...publicContext }; ctx.extendContext(partialCtx); if (isMutation(ctx.args.document)) { if (invalidateViaMutation === false) { return; } return { onExecuteDone({ result, setResult }) { if (core.isAsyncIterable(result)) { console.warn( "[useResponseCache] AsyncIterable returned from execute is currently unsupported." ); return; } cache.invalidate(context.identifier.values()); if (includeExtensionMetadata) { setResult({ ...result, extensions: { ...result.extensions, responseCache: { invalidatedEntities: Array.from(context.identifier.values()) } } }); } } }; } else { let enabledCaching = true; if (enabled != null) { enabledCaching = enabled(ctx.args.contextValue); } if (enabledCaching === true && ttlPerSchemaCoordinate) { const enabledCachingDocumentCache = enabledCachingDocuments.get(ctx.args.document); if (enabledCachingDocumentCache != null) { enabledCaching = enabledCachingDocumentCache; } else { const typeInfo = new graphql.TypeInfo(ctx.args.schema); graphql.visit( ctx.args.document, graphql.visitWithTypeInfo(typeInfo, { Field(fieldNode) { const parentType = typeInfo.getParentType(); if (parentType) { const schemaCoordinate = `${parentType.name}.${fieldNode.name.value}`; const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate]; if (maybeTtl === 0) { enabledCaching = false; return graphql.BREAK; } else if (maybeTtl !== void 0) { context.currentTtl = calculateTtl(maybeTtl, context.currentTtl); } } } }) ); enabledCachingDocuments.set(ctx.args.document, enabledCaching); } } if (enabledCaching === false) return; const documentString = getDocumentStringFromContext(ctx.args.contextValue); if (documentString != null) { const operationId = buildResponseCacheKey({ documentString, variableValues: ctx.args.variableValues, operationName: ctx.args.operationName, sessionId: session(ctx.args.contextValue) }); const [cachedResponse, extra] = await cache.get(operationId); if (cachedResponse != null) { if (includeExtensionMetadata) { return ctx.setResultAndStopExecution({ ...cachedResponse, extensions: { ...cachedResponse.extensions, responseCache: { hit: true, ttl: extra?.ttl } } }); } return ctx.setResultAndStopExecution(cachedResponse); } const result = await idempotentCall(operationId, async () => { return ctx.executeFn({ ...ctx.args, contextValue: { ...ctx.args.contextValue, ...partialCtx } }); }); if (core.isAsyncIterable(result)) { console.warn( "[useResponseCache] AsyncIterable returned from execute is currently unsupported." ); return; } if (!shouldCacheResult({ result })) { cache.onSkipCache(operationId); return ctx.setResultAndStopExecution(result); } const finalTtl = context.currentTtl ?? globalTtl; if (finalTtl <= 0) { cache.onSkipCache(operationId); if (includeExtensionMetadata) { return ctx.setResultAndStopExecution({ ...result, extensions: { ...result.extensions, responseCache: { hit: false, didCache: false } } }); } return ctx.setResultAndStopExecution(result); } cache.set(operationId, result, identifier.values(), finalTtl); if (includeExtensionMetadata) { return ctx.setResultAndStopExecution({ ...result, extensions: { ...result.extensions, responseCache: { hit: false, didCache: true, ttl: finalTtl } } }); } return ctx.setResultAndStopExecution(result); } else { console.warn( `[useResponseCache] Failed extracting document string from the context. The response will not be cached or served from the cache. If you are overriding the 'parse' behavior make sure to pass a custom 'getDocumentStringFromContext' function for getting the document string, which is required for building the response cache key.` ); return; } } } }; } function isOperationDefinition(node) { return node?.kind === "OperationDefinition"; } function calculateTtl(typeTtl, currentTtl) { if (typeof currentTtl === "number") { return Math.min(currentTtl, typeTtl); } return typeTtl; } function isMutation(doc) { return doc.definitions.find(isOperationDefinition).operation === "mutation"; } function runWith(input, callback) { if (input instanceof Promise) { input.then(callback, () => void 0); } else { callback(input); } } function applyResponseCacheLogic(schema, idFieldNames) { return utils.mapSchema(schema, { [utils.MapperKind.OBJECT_FIELD](fieldConfig, fieldName, typename) { if (idFieldNames.includes(fieldName)) { return { ...fieldConfig, resolve(src, args, context, info) { const result = (fieldConfig.resolve ?? graphql.defaultFieldResolver)( src, args, context, info ); runWith(result, (id) => { const ctx = context[contextSymbol]; if (ctx !== void 0) { if (ctx.ignoredTypesMap.has(typename)) { ctx.skip = true; } if (ctx.skip === true) { ctx.skip = true; return; } ctx.identifier.set(`${typename}:${id}`, { typename, id }); ctx.types.add(typename); if (typename in ctx.ttlPerType) { ctx.currentTtl = calculateTtl(ctx.ttlPerType[typename], ctx.currentTtl); } } }); return result; } }; } return fieldConfig; } }); } exports.defaultBuildResponseCacheKey = defaultBuildResponseCacheKey; exports.defaultGetDocumentStringFromContext = defaultGetDocumentStringFromContext; exports.defaultShouldCacheResult = defaultShouldCacheResult; exports.useResponseCache = useResponseCache;