|
import { |
|
DirectiveNode, |
|
getNamedType, |
|
GraphQLInterfaceType, |
|
GraphQLObjectType, |
|
ResponsePath, |
|
responsePathAsArray, |
|
} from 'graphql'; |
|
import { ApolloServerPlugin } from "apollo-server-plugin-base"; |
|
|
|
export interface CacheControlFormat { |
|
version: 1; |
|
hints: ({ path: (string | number)[] } & CacheHint)[]; |
|
} |
|
|
|
export interface CacheHint { |
|
maxAge?: number; |
|
scope?: CacheScope; |
|
// FORK: Added this directive field! |
|
staleWhileRevalidate?: number; |
|
} |
|
|
|
export enum CacheScope { |
|
Public = 'PUBLIC', |
|
Private = 'PRIVATE', |
|
} |
|
|
|
export interface CacheControlExtensionOptions { |
|
defaultMaxAge?: number; |
|
// FIXME: We should replace these with |
|
// more appropriately named options. |
|
calculateHttpHeaders?: boolean; |
|
stripFormattedExtensions?: boolean; |
|
} |
|
|
|
type MapResponsePathHints = Map<ResponsePath, CacheHint>; |
|
|
|
export const plugin = ( |
|
options: CacheControlExtensionOptions = Object.create(null), |
|
): ApolloServerPlugin => ({ |
|
requestDidStart(requestContext) { |
|
const defaultMaxAge: number = options.defaultMaxAge || 0; |
|
const hints: MapResponsePathHints = new Map(); |
|
|
|
|
|
function setOverallCachePolicyWhenUnset() { |
|
// @ts-ignore: FORK. Don't know enough TypeScript to resolve this! |
|
if (!requestContext.overallCachePolicy) { |
|
// @ts-ignore: FORK. Don't know enough TypeScript to resolve this! |
|
requestContext.overallCachePolicy = computeOverallCachePolicy(hints); |
|
} |
|
} |
|
|
|
return { |
|
executionDidStart: () => ({ |
|
executionDidEnd: () => setOverallCachePolicyWhenUnset(), |
|
willResolveField({ info }) { |
|
let hint: CacheHint = {}; |
|
|
|
// If this field's resolver returns an object or interface, look for |
|
// hints on that return type. |
|
const targetType = getNamedType(info.returnType); |
|
if ( |
|
targetType instanceof GraphQLObjectType || |
|
targetType instanceof 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 GraphQLObjectType || |
|
targetType instanceof GraphQLInterfaceType || |
|
!info.path.prev) && |
|
hint.maxAge === undefined |
|
) { |
|
hint.maxAge = defaultMaxAge; |
|
} |
|
|
|
if (hint.maxAge !== undefined || hint.scope !== undefined) { |
|
addHint(hints, info.path, hint); |
|
} |
|
|
|
info.cacheControl = { |
|
setCacheHint: (hint: CacheHint) => { |
|
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, |
|
// @ts-ignore: FORK. Don't know enough TypeScript to resolve this! |
|
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 = |
|
overallCachePolicyOverride || |
|
// @ts-ignore: FORK. Don't know enough TypeScript to resolve this! |
|
(requestContext.overallCachePolicy = |
|
computeOverallCachePolicy(hints)); |
|
|
|
if ( |
|
overallCachePolicy && |
|
options.calculateHttpHeaders && |
|
response.http |
|
) { |
|
if (overallCachePolicy.staleWhileRevalidate) { // FORK |
|
response.http.headers.set( |
|
'Cache-Control', |
|
`max-age=${ |
|
overallCachePolicy.maxAge |
|
}, stale-while-revalidate=${ |
|
overallCachePolicy.staleWhileRevalidate |
|
}, ${overallCachePolicy.scope.toLowerCase()}`, |
|
); |
|
} else { |
|
response.http.headers.set( |
|
'Cache-Control', |
|
`max-age=${ |
|
overallCachePolicy.maxAge |
|
}, ${overallCachePolicy.scope.toLowerCase()}`, |
|
); |
|
} |
|
} |
|
|
|
// 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: [...responsePathAsArray(path)], |
|
...hint, |
|
})), |
|
}; |
|
} |
|
} |
|
} |
|
}); |
|
|
|
function cacheHintFromDirectives( |
|
directives: ReadonlyArray<DirectiveNode> | undefined, |
|
): CacheHint | undefined { |
|
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 as CacheScope) |
|
: undefined, |
|
}; |
|
} |
|
|
|
function mergeHints( |
|
hint: CacheHint, |
|
otherHint: CacheHint | undefined, |
|
): CacheHint { |
|
if (!otherHint) return hint; |
|
|
|
return { |
|
maxAge: otherHint.maxAge !== undefined ? otherHint.maxAge : hint.maxAge, |
|
staleWhileRevalidate: otherHint.staleWhileRevalidate !== undefined ? otherHint.staleWhileRevalidate : hint.staleWhileRevalidate, // FORK |
|
scope: otherHint.scope || hint.scope, |
|
}; |
|
} |
|
|
|
function computeOverallCachePolicy( |
|
hints: MapResponsePathHints, |
|
): Required<CacheHint> | undefined { |
|
let lowestMaxAge: number | undefined = undefined; |
|
let lowestMaxAgePlusSWR: number | undefined = undefined; // FORK |
|
let scope: CacheScope = 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 lowestMaxAge && lowestMaxAgePlusSWR // FORK |
|
? { |
|
maxAge: lowestMaxAge, |
|
staleWhileRevalidate: lowestMaxAgePlusSWR - lowestMaxAge, // FORK |
|
scope, |
|
} |
|
: undefined; |
|
} |
|
|
|
function addHint(hints: MapResponsePathHints, path: ResponsePath, hint: CacheHint) { |
|
const existingCacheHint = hints.get(path); |
|
if (existingCacheHint) { |
|
hints.set(path, mergeHints(existingCacheHint, hint)); |
|
} else { |
|
hints.set(path, hint); |
|
} |
|
} |
|
|
|
export const __testing__ = { |
|
addHint, |
|
computeOverallCachePolicy, |
|
}; |