@stewartmcgown/apollo-response-cache
Version:
Caching and invalidation mechanisms (plugins, directives) of Apollo GraphQL
259 lines (258 loc) • 12.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.__testing__ = exports.plugin = exports.makeCacheControlHeader = exports.makeCacheHint = exports.CacheScope = void 0;
const graphql_1 = require("graphql");
var CacheScope;
(function (CacheScope) {
CacheScope["Public"] = "PUBLIC";
CacheScope["Private"] = "PRIVATE";
})(CacheScope = exports.CacheScope || (exports.CacheScope = {}));
function makeCacheHint(hint) {
const fields = [];
if (typeof hint.maxAge === 'number') {
fields.push(`maxAge: ${hint.maxAge}`);
}
if (hint.scope) {
fields.push(`scope: ${hint.scope}`);
}
if (typeof hint.staleWhileRevalidate === 'number') {
fields.push(`staleWhileRevalidate: ${hint.staleWhileRevalidate}`);
}
return `@cacheControl(${fields.join(', ')})`;
}
exports.makeCacheHint = makeCacheHint;
function makeCacheControlHeader(overallCachePolicy) {
const swr = overallCachePolicy.staleWhileRevalidate
? `stale-while-revalidate=${overallCachePolicy.staleWhileRevalidate}, `
: '';
return `max-age=${overallCachePolicy.maxAge}, ${swr}${overallCachePolicy.scope.toLowerCase()}`;
}
exports.makeCacheControlHeader = makeCacheControlHeader;
const DEFAULT_OPTIONS = {
calculateHttpHeaders: true,
};
exports.plugin = (options = {}) => {
options = {
...DEFAULT_OPTIONS,
...options,
};
return {
requestDidStart(requestContext) {
const defaultMaxAge = options.defaultMaxAge || 0;
const defaultStaleWhileRevalidate = options.defaultStaleWhileRevalidate || 0;
const hints = new Map();
function setOverallCachePolicyWhenUnset() {
if (!requestContext.overallCachePolicy) {
requestContext.overallCachePolicy = computeOverallCachePolicy(hints);
}
}
return {
executionDidStart: () => ({
executionDidEnd: () => setOverallCachePolicyWhenUnset(),
willResolveField({ info }) {
var _a;
/**
* Mutations are not eligible for any sort of caching, as they are analogous to a POST request
* in traditional HTTP verbage.
*/
if (((_a = requestContext.operation) === null || _a === void 0 ? void 0 : _a.operation) === 'mutation' &&
!options.cacheMutations)
return;
let hint = {};
// If this field's resolver returns an object or interface, look for
// hints on that return type.
const targetType = graphql_1.getNamedType(info.returnType);
if (targetType instanceof graphql_1.GraphQLObjectType ||
targetType instanceof graphql_1.GraphQLInterfaceType) {
if (targetType.astNode) {
hint = mergeHints(hint, cacheHintFromDirectives(targetType.astNode.directives));
}
}
// Look for hints on the field itself (on its parent type), taking
// precedence over previously calculated hints.
const fieldDef = info.parentType.getFields()[info.fieldName];
if (fieldDef.astNode) {
hint = mergeHints(hint, cacheHintFromDirectives(fieldDef.astNode.directives));
}
// If this resolver returns an object or is a root field and we haven't
// seen an explicit maxAge hint, set the maxAge to 0 (uncached) or the
// default if specified in the constructor. (Non-object fields by
// default are assumed to inherit their cacheability from their parents.
// But on the other hand, while root non-object fields can get explicit
// hints from their definition on the Query/Mutation object, if that
// doesn't exist then there's no parent field that would assign the
// default maxAge, so we do it here.)
if (targetType instanceof graphql_1.GraphQLObjectType ||
targetType instanceof graphql_1.GraphQLInterfaceType ||
!info.path.prev) {
if (hint.maxAge === undefined) {
hint.maxAge = defaultMaxAge;
}
if (hint.staleWhileRevalidate === undefined) {
hint.staleWhileRevalidate = defaultStaleWhileRevalidate;
}
}
if (hint.maxAge !== undefined || hint.scope !== undefined) {
addHint(hints, info.path, hint);
}
info.cacheControl = {
setCacheHint: (hint) => {
addHint(hints, info.path, hint);
},
cacheHint: hint,
};
},
}),
responseForOperation() {
// We are not supplying an answer, we are only setting the cache
// policy if it's not set! Therefore, we return null.
setOverallCachePolicyWhenUnset();
return null;
},
willSendResponse(requestContext) {
const { response, overallCachePolicy: overallCachePolicyOverride, } = requestContext;
// If there are any errors, we don't consider this cacheable.
if (response.errors) {
return;
}
// Use the override by default, but if it's not overridden, set our
// own computation onto the `requestContext` for other plugins to read.
const overallCachePolicy = (requestContext.overallCachePolicy = computeOverallCachePolicy(hints));
if (overallCachePolicy &&
options.calculateHttpHeaders &&
response.http) {
/**
* We want to set a cache header even if the response is technically
* uncachable.
*/
response.http.headers.set('Cache-Control', makeCacheControlHeader(overallCachePolicy));
}
// We should have to explicitly ask to leave the formatted extension in,
// or pass the old-school `cacheControl: true` (as interpreted by
// apollo-server-core/ApolloServer), in order to include the
// old engineproxy-aimed extensions. Specifically, we want users of
// apollo-server-plugin-response-cache to be able to specify
// `cacheControl: {defaultMaxAge: 600}` without accidentally turning on
// the extension formatting.
if (options.stripFormattedExtensions !== false)
return;
const extensions = response.extensions || (response.extensions = Object.create(null));
if (typeof extensions.cacheControl !== 'undefined') {
throw new Error('The cacheControl information already existed.');
}
extensions.cacheControl = {
version: 1,
hints: Array.from(hints).map(([path, hint]) => ({
path: [...graphql_1.responsePathAsArray(path)],
...hint,
})),
};
},
};
},
};
};
function cacheHintFromDirectives(directives) {
if (!directives)
return undefined;
const cacheControlDirective = directives.find((directive) => directive.name.value === 'cacheControl');
if (!cacheControlDirective)
return undefined;
if (!cacheControlDirective.arguments)
return undefined;
const maxAgeArgument = cacheControlDirective.arguments.find((argument) => argument.name.value === 'maxAge');
const staleWhileRevalidateArgument = cacheControlDirective.arguments.find(
// FORK
(argument) => argument.name.value === 'staleWhileRevalidate');
const scopeArgument = cacheControlDirective.arguments.find((argument) => argument.name.value === 'scope');
// TODO: Add proper typechecking of arguments
return {
maxAge: maxAgeArgument &&
maxAgeArgument.value &&
maxAgeArgument.value.kind === 'IntValue'
? parseInt(maxAgeArgument.value.value)
: undefined,
staleWhileRevalidate: staleWhileRevalidateArgument &&
staleWhileRevalidateArgument.value &&
staleWhileRevalidateArgument.value.kind === 'IntValue'
? parseInt(staleWhileRevalidateArgument.value.value)
: undefined,
scope: scopeArgument &&
scopeArgument.value &&
scopeArgument.value.kind === 'EnumValue'
? scopeArgument.value.value
: undefined,
};
}
function mergeHints(hint, otherHint) {
if (!otherHint)
return hint;
return {
maxAge: otherHint.maxAge !== undefined ? otherHint.maxAge : hint.maxAge,
staleWhileRevalidate: otherHint.staleWhileRevalidate !== undefined
? otherHint.staleWhileRevalidate
: hint.staleWhileRevalidate,
scope: otherHint.scope || hint.scope,
};
}
function computeOverallCachePolicy(hints) {
let lowestMaxAge = undefined;
let lowestMaxAgePlusSWR = undefined; // FORK
let scope = CacheScope.Public;
for (const hint of hints.values()) {
if (hint.maxAge !== undefined) {
lowestMaxAge =
lowestMaxAge !== undefined
? Math.min(lowestMaxAge, hint.maxAge)
: hint.maxAge;
// FORK
//
// SWR is defined as the amount of time _after_ max-age when we should
// treat a resource as no longer fresh, but not _entirely_ stale.
//
// So, to merge, we want to know the time when our first resource becomes
// non-fresh, and then time when our first resource becomes too stale to
// serve at all. The first value is the min max-age across our hints, and
// the second value is the min max-age-plus-SWR across our hints. So,
// that sum is what we actually compute and compare when looping here -
// and then we subtract the final max-age off of the final
// max-age-plus-SWR to get the SWR for our HTTP header!
//
// If SWR is not specified, we treat it as 0: the resource can be served
// for zero additional seconds!
//
// Note that we skip processing staleWhileRevalidate if maxAge is not
// also specified. TODO: Type check this, and/or louder error?
const maxAgePlusSWR = hint.maxAge + (hint.staleWhileRevalidate || 0);
lowestMaxAgePlusSWR =
lowestMaxAgePlusSWR !== undefined
? Math.min(lowestMaxAgePlusSWR, maxAgePlusSWR)
: maxAgePlusSWR;
}
if (hint.scope === CacheScope.Private) {
scope = CacheScope.Private;
}
}
// If maxAge is 0, then we consider it uncacheable so it doesn't matter what
// the scope was.
return {
maxAge: lowestMaxAge || 0,
staleWhileRevalidate: lowestMaxAgePlusSWR && lowestMaxAge
? lowestMaxAgePlusSWR - lowestMaxAge
: 0,
scope,
};
}
function addHint(hints, path, hint) {
const existingCacheHint = hints.get(path);
if (existingCacheHint) {
hints.set(path, mergeHints(existingCacheHint, hint));
}
else {
hints.set(path, hint);
}
}
exports.__testing__ = {
addHint,
computeOverallCachePolicy,
};