UNPKG

@stewartmcgown/apollo-response-cache

Version:

Caching and invalidation mechanisms (plugins, directives) of Apollo GraphQL

228 lines (227 loc) 12.5 kB
"use strict"; // 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;