@stewartmcgown/apollo-response-cache
Version:
Caching and invalidation mechanisms (plugins, directives) of Apollo GraphQL
228 lines (227 loc) • 12.5 kB
JavaScript
;
// forked from https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-plugin-response-cache
Object.defineProperty(exports, "__esModule", { value: true });
const apollo_cache_control_1 = require("apollo-cache-control");
const apollo_server_caching_1 = require("apollo-server-caching");
// XXX This should use createSHA from apollo-server-core in order to work on
// non-Node environments. I'm not sure where that should end up ---
// apollo-server-sha as its own tiny module? apollo-server-env seems bad because
// that would add sha.js to unnecessary places, I think?
const crypto_1 = require("crypto");
const enums_1 = require("../enums");
const utils_1 = require("../utils");
const cacheControlPlugin_1 = require("./cacheControlPlugin");
var SessionMode;
(function (SessionMode) {
SessionMode[SessionMode["NoSession"] = 0] = "NoSession";
SessionMode[SessionMode["Private"] = 1] = "Private";
SessionMode[SessionMode["AuthenticatedPublic"] = 2] = "AuthenticatedPublic";
})(SessionMode || (SessionMode = {}));
function sha(s) {
return crypto_1.createHash('sha256').update(s).digest('hex');
}
function cacheKeyString(key) {
return sha(JSON.stringify(key));
}
function isGraphQLQuery(requestContext) {
return (requestContext.operation && requestContext.operation.operation === 'query');
}
function plugin(options = Object.create(null)) {
return {
requestDidStart(outerRequestContext) {
const cache = new apollo_server_caching_1.PrefixingKeyValueCache(options.cache || outerRequestContext.cache, enums_1.CACHE_KEY_PREFIX_FQC);
let sessionId = null;
let baseCacheKey = null;
let age = null;
return {
async responseForOperation(requestContext) {
requestContext.metrics.responseCacheHit = false;
/**
* Inject redis instance `__redis` and `__nodeFQCKeySet` to context,
* used by `@logCache`, `@purgeCache`,
* and `willSendResponse` below.
*/
requestContext.context.__redis =
options.cache || outerRequestContext.cache;
requestContext.context.__nodeFQCKeySet = new Set();
if (!isGraphQLQuery(requestContext)) {
return null;
}
async function cacheGet(contextualCacheKeyFields) {
const key = cacheKeyString({
...baseCacheKey,
...contextualCacheKeyFields,
});
const serializedValue = await cache.get(key);
if (serializedValue === undefined) {
return null;
}
const value = JSON.parse(serializedValue);
// Use cache policy from the cache (eg, to calculate HTTP response
// headers).
if (value.cachePolicy.maxAge)
requestContext.overallCachePolicy = value.cachePolicy;
requestContext.metrics.responseCacheHit = true;
age = Math.round((+new Date() - value.cacheTime) / 1000);
return { data: value.data };
}
// Call hooks. Save values which will be used in willSendResponse as well.
let extraCacheKeyData = null;
if (options.sessionId) {
sessionId = await options.sessionId(requestContext);
}
if (options.extraCacheKeyData) {
extraCacheKeyData = await options.extraCacheKeyData(requestContext);
}
baseCacheKey = {
source: requestContext.source,
operationName: requestContext.operationName,
// Defensive copy just in case it somehow gets mutated.
variables: { ...(requestContext.request.variables || {}) },
extra: extraCacheKeyData,
};
// Note that we set up sessionId and baseCacheKey before doing this
// check, so that we can still write the result to the cache even if
// we are told not to read from the cache.
if (options.shouldReadFromCache &&
!options.shouldReadFromCache(requestContext)) {
return null;
}
if (sessionId === null) {
return cacheGet({ sessionMode: SessionMode.NoSession });
}
else {
const privateResponse = await cacheGet({
sessionId,
sessionMode: SessionMode.Private,
});
if (privateResponse !== null) {
return privateResponse;
}
return cacheGet({ sessionMode: SessionMode.AuthenticatedPublic });
}
},
async willSendResponse(requestContext) {
var _a;
if (!isGraphQLQuery(requestContext)) {
return;
}
const http = requestContext.response.http;
if (requestContext.metrics.responseCacheHit) {
// Never write back to the cache what we just read from it. But do set the Age header!
if (http && age !== null) {
http.headers.set('age', age.toString());
http.headers.set('apollo-cache-status', 'HIT');
if (requestContext.overallCachePolicy)
http.headers.set('Cache-Control', cacheControlPlugin_1.makeCacheControlHeader(requestContext.overallCachePolicy));
}
return;
}
else if (http) {
http.headers.set('apollo-cache-status', !((_a = requestContext.overallCachePolicy) === null || _a === void 0 ? void 0 : _a.maxAge) ? 'DYNAMIC' : 'MISS');
}
if (options.shouldWriteToCache &&
!options.shouldWriteToCache(requestContext)) {
return;
}
const { response, overallCachePolicy } = requestContext;
if (response.errors ||
!response.data ||
!overallCachePolicy ||
overallCachePolicy.maxAge <= 0) {
// This plugin never caches errors or anything without a cache policy.
//
// There are two reasons we don't cache errors. The user-level
// reason is that we think that in general errors are less cacheable
// than real results, since they might indicate something transient
// like a failure to talk to a backend. (If you need errors to be
// cacheable, represent the erroneous condition explicitly in data
// instead of out-of-band as an error.) The implementation reason is
// that this lets us avoid complexities around serialization and
// deserialization of GraphQL errors, and the distinction between
// formatted and unformatted errors, etc.
if (http) {
/**
* If we have a cache miss and no maxage, this is a dynamic read.
* If we have a cache DYNAMIC and no maxage, then this must be a cache bypass.
*/
if (http.headers.get('apollo-cache-status') === 'MISS') {
http.headers.set('apollo-cache-status', 'DYNAMIC');
}
}
return;
}
const data = response.data;
// We're pretty sure that any path that calls willSendResponse with a
// non-error response will have already called our execute hook above,
// but let's just double-check that, since accidentally ignoring
// sessionId could be a big security hole.
if (!baseCacheKey) {
throw new Error('willSendResponse called without error, but execute not called?');
}
function cacheSetInBackground(contextualCacheKeyFields) {
const key = cacheKeyString({
...baseCacheKey,
...contextualCacheKeyFields,
});
const nowMillis = +new Date();
const value = {
data,
cachePolicy: overallCachePolicy,
cacheTime: nowMillis,
staleAt: nowMillis + ((overallCachePolicy === null || overallCachePolicy === void 0 ? void 0 : overallCachePolicy.staleWhileRevalidate) || 0),
};
const serializedValue = JSON.stringify(value);
// Note that this function converts key and response to strings before
// doing anything asynchronous, so it can run in parallel with user code
// without worrying about anything being mutated out from under it.
//
// Also note that the test suite assumes that this asynchronous function
// still calls `cache.set` synchronously (ie, that it writes to
// InMemoryLRUCache synchronously).
cache
.set(key, serializedValue, {
ttl: overallCachePolicy.maxAge +
(overallCachePolicy.staleWhileRevalidate || 0),
})
.catch(console.warn);
const { __nodeFQCKeySet, __redis } = requestContext.context;
if (__nodeFQCKeySet && __redis) {
utils_1.recordNodeFQCMapping({
nodeFQCKeys: __nodeFQCKeySet,
fqcKey: key,
ttl: options.nodeFQCTTL,
redis: __redis,
});
}
}
const isPrivate = overallCachePolicy.scope === apollo_cache_control_1.CacheScope.Private;
if (isPrivate) {
if (!options.sessionId) {
console.warn('A GraphQL response used @cacheControl or setCacheHint to set cache hints with scope ' +
"Private, but you didn't define the sessionId hook for " +
'apollo-server-plugin-response-cache. Not caching.');
return;
}
if (sessionId === null) {
// Private data shouldn't be cached for logged-out users.
return;
}
cacheSetInBackground({
sessionId,
sessionMode: SessionMode.Private,
});
}
else {
cacheSetInBackground({
sessionMode: sessionId === null
? SessionMode.NoSession
: SessionMode.AuthenticatedPublic,
});
}
},
};
},
};
}
exports.default = plugin;