index.js 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. import { getNamedType, isCompositeType, isInterfaceType, isObjectType, responsePathAsArray, } from 'graphql';
  2. import { newCachePolicy } from '../../cachePolicy.js';
  3. import { internalPlugin } from '../../internalPlugin.js';
  4. import LRUCache from 'lru-cache';
  5. export function ApolloServerPluginCacheControl(options = Object.create(null)) {
  6. let typeAnnotationCache;
  7. let fieldAnnotationCache;
  8. return internalPlugin({
  9. __internal_plugin_id__: 'CacheControl',
  10. __is_disabled_plugin__: false,
  11. async serverWillStart({ schema }) {
  12. typeAnnotationCache = new LRUCache({
  13. max: Object.values(schema.getTypeMap()).filter(isCompositeType)
  14. .length,
  15. });
  16. fieldAnnotationCache = new LRUCache({
  17. max: Object.values(schema.getTypeMap())
  18. .filter(isObjectType)
  19. .flatMap((t) => Object.values(t.getFields())).length +
  20. Object.values(schema.getTypeMap())
  21. .filter(isInterfaceType)
  22. .flatMap((t) => Object.values(t.getFields())).length,
  23. });
  24. return undefined;
  25. },
  26. async requestDidStart(requestContext) {
  27. function memoizedCacheAnnotationFromType(t) {
  28. const existing = typeAnnotationCache.get(t);
  29. if (existing) {
  30. return existing;
  31. }
  32. const annotation = cacheAnnotationFromType(t);
  33. typeAnnotationCache.set(t, annotation);
  34. return annotation;
  35. }
  36. function memoizedCacheAnnotationFromField(field) {
  37. const existing = fieldAnnotationCache.get(field);
  38. if (existing) {
  39. return existing;
  40. }
  41. const annotation = cacheAnnotationFromField(field);
  42. fieldAnnotationCache.set(field, annotation);
  43. return annotation;
  44. }
  45. const defaultMaxAge = options.defaultMaxAge ?? 0;
  46. const calculateHttpHeaders = options.calculateHttpHeaders ?? true;
  47. const { __testing__cacheHints } = options;
  48. return {
  49. async executionDidStart() {
  50. if (isRestricted(requestContext.overallCachePolicy)) {
  51. const fakeFieldPolicy = newCachePolicy();
  52. return {
  53. willResolveField({ info }) {
  54. info.cacheControl = {
  55. setCacheHint: (dynamicHint) => {
  56. fakeFieldPolicy.replace(dynamicHint);
  57. },
  58. cacheHint: fakeFieldPolicy,
  59. cacheHintFromType: memoizedCacheAnnotationFromType,
  60. };
  61. },
  62. };
  63. }
  64. return {
  65. willResolveField({ info }) {
  66. const fieldPolicy = newCachePolicy();
  67. let inheritMaxAge = false;
  68. const targetType = getNamedType(info.returnType);
  69. if (isCompositeType(targetType)) {
  70. const typeAnnotation = memoizedCacheAnnotationFromType(targetType);
  71. fieldPolicy.replace(typeAnnotation);
  72. inheritMaxAge = !!typeAnnotation.inheritMaxAge;
  73. }
  74. const fieldAnnotation = memoizedCacheAnnotationFromField(info.parentType.getFields()[info.fieldName]);
  75. if (fieldAnnotation.inheritMaxAge &&
  76. fieldPolicy.maxAge === undefined) {
  77. inheritMaxAge = true;
  78. if (fieldAnnotation.scope) {
  79. fieldPolicy.replace({ scope: fieldAnnotation.scope });
  80. }
  81. }
  82. else {
  83. fieldPolicy.replace(fieldAnnotation);
  84. }
  85. info.cacheControl = {
  86. setCacheHint: (dynamicHint) => {
  87. fieldPolicy.replace(dynamicHint);
  88. },
  89. cacheHint: fieldPolicy,
  90. cacheHintFromType: memoizedCacheAnnotationFromType,
  91. };
  92. return () => {
  93. if (fieldPolicy.maxAge === undefined &&
  94. ((isCompositeType(targetType) && !inheritMaxAge) ||
  95. !info.path.prev)) {
  96. fieldPolicy.restrict({ maxAge: defaultMaxAge });
  97. }
  98. if (__testing__cacheHints && isRestricted(fieldPolicy)) {
  99. const path = responsePathAsArray(info.path).join('.');
  100. if (__testing__cacheHints.has(path)) {
  101. throw Error("shouldn't happen: addHint should only be called once per path");
  102. }
  103. __testing__cacheHints.set(path, {
  104. maxAge: fieldPolicy.maxAge,
  105. scope: fieldPolicy.scope,
  106. });
  107. }
  108. requestContext.overallCachePolicy.restrict(fieldPolicy);
  109. };
  110. },
  111. };
  112. },
  113. async willSendResponse(requestContext) {
  114. if (!calculateHttpHeaders) {
  115. return;
  116. }
  117. const { response, overallCachePolicy } = requestContext;
  118. const existingCacheControlHeader = parseExistingCacheControlHeader(response.http.headers.get('cache-control'));
  119. if (existingCacheControlHeader.kind === 'unparsable') {
  120. return;
  121. }
  122. const cachePolicy = newCachePolicy();
  123. cachePolicy.replace(overallCachePolicy);
  124. if (existingCacheControlHeader.kind === 'parsable-and-cacheable') {
  125. cachePolicy.restrict(existingCacheControlHeader.hint);
  126. }
  127. const policyIfCacheable = cachePolicy.policyIfCacheable();
  128. if (policyIfCacheable &&
  129. existingCacheControlHeader.kind !== 'uncacheable' &&
  130. response.body.kind === 'single' &&
  131. !response.body.singleResult.errors) {
  132. response.http.headers.set('cache-control', `max-age=${policyIfCacheable.maxAge}, ${policyIfCacheable.scope.toLowerCase()}`);
  133. }
  134. else if (calculateHttpHeaders !== 'if-cacheable') {
  135. response.http.headers.set('cache-control', CACHE_CONTROL_HEADER_UNCACHEABLE);
  136. }
  137. },
  138. };
  139. },
  140. });
  141. }
  142. const CACHE_CONTROL_HEADER_CACHEABLE_REGEXP = /^max-age=(\d+), (public|private)$/;
  143. const CACHE_CONTROL_HEADER_UNCACHEABLE = 'no-store';
  144. function parseExistingCacheControlHeader(header) {
  145. if (!header) {
  146. return { kind: 'no-header' };
  147. }
  148. if (header === CACHE_CONTROL_HEADER_UNCACHEABLE) {
  149. return { kind: 'uncacheable' };
  150. }
  151. const match = CACHE_CONTROL_HEADER_CACHEABLE_REGEXP.exec(header);
  152. if (!match) {
  153. return { kind: 'unparsable' };
  154. }
  155. return {
  156. kind: 'parsable-and-cacheable',
  157. hint: {
  158. maxAge: +match[1],
  159. scope: match[2] === 'public' ? 'PUBLIC' : 'PRIVATE',
  160. },
  161. };
  162. }
  163. function cacheAnnotationFromDirectives(directives) {
  164. if (!directives)
  165. return undefined;
  166. const cacheControlDirective = directives.find((directive) => directive.name.value === 'cacheControl');
  167. if (!cacheControlDirective)
  168. return undefined;
  169. if (!cacheControlDirective.arguments)
  170. return undefined;
  171. const maxAgeArgument = cacheControlDirective.arguments.find((argument) => argument.name.value === 'maxAge');
  172. const scopeArgument = cacheControlDirective.arguments.find((argument) => argument.name.value === 'scope');
  173. const inheritMaxAgeArgument = cacheControlDirective.arguments.find((argument) => argument.name.value === 'inheritMaxAge');
  174. const scopeString = scopeArgument?.value?.kind === 'EnumValue'
  175. ? scopeArgument.value.value
  176. : undefined;
  177. const scope = scopeString === 'PUBLIC' || scopeString === 'PRIVATE'
  178. ? scopeString
  179. : undefined;
  180. if (inheritMaxAgeArgument?.value?.kind === 'BooleanValue' &&
  181. inheritMaxAgeArgument.value.value) {
  182. return { inheritMaxAge: true, scope };
  183. }
  184. return {
  185. maxAge: maxAgeArgument?.value?.kind === 'IntValue'
  186. ? parseInt(maxAgeArgument.value.value)
  187. : undefined,
  188. scope,
  189. };
  190. }
  191. function cacheAnnotationFromType(t) {
  192. if (t.astNode) {
  193. const hint = cacheAnnotationFromDirectives(t.astNode.directives);
  194. if (hint) {
  195. return hint;
  196. }
  197. }
  198. if (t.extensionASTNodes) {
  199. for (const node of t.extensionASTNodes) {
  200. const hint = cacheAnnotationFromDirectives(node.directives);
  201. if (hint) {
  202. return hint;
  203. }
  204. }
  205. }
  206. return {};
  207. }
  208. function cacheAnnotationFromField(field) {
  209. if (field.astNode) {
  210. const hint = cacheAnnotationFromDirectives(field.astNode.directives);
  211. if (hint) {
  212. return hint;
  213. }
  214. }
  215. return {};
  216. }
  217. function isRestricted(hint) {
  218. return hint.maxAge !== undefined || hint.scope !== undefined;
  219. }
  220. //# sourceMappingURL=index.js.map