@soundxyz/response-cache
Version:
Heavily inspired by @envelop/response-cache
319 lines (312 loc) • 11.1 kB
JavaScript
;
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;