-
Notifications
You must be signed in to change notification settings - Fork 2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
CDN cache-control headers #1138
Changes from 22 commits
d85275a
b805493
e5986a2
156981b
dc8dba8
d0368cb
6890156
534de91
92fba23
d0e2625
cdffba8
025a271
7c35dc2
fab0c2f
3333695
631a0d4
e1750d0
fc7de7f
bd547f7
44a6e4b
c90bfea
39d3872
d5c0de9
e7190fa
7300cac
b421821
55835e2
ccc4357
a5e6c41
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
--- | ||
title: CDN Integration | ||
description: Getting content delivery networks to cache GraphQL responses | ||
--- | ||
|
||
Content-delivery networks such as [fly.io](https://fly.io), [Cloudflare](https://www.cloudflare.com/), [Akamai](https://www.akamai.com/) or [Fastly](https://www.fastly.com/) allow content caching close to clients, delivering data with low latency from a nearby server. Apollo Server makes it straightforward to use CDNs with GraphQL queries to cache full responses while still executing more dynamic queries. | ||
|
||
To use Apollo Server behind a CDN, we define which GraphQL responses the CDN is allowed to cache. On the client, we set up [automatic persisted queries](./apq.html) to ensure that GraphQL requests are in a format that a CDN can understand. | ||
|
||
<h2 id="cache-hints" title="1. Add cache hints">Step 1: Add cache hints to the GraphQL schema</h2> | ||
|
||
Add cache hints as [directives](./directives.html) to GraphQL schema so that Apollo Server knows which fields and types are cacheable and for how long. For example, this schema indicates that all fields that return an `Author` should be cached for 60 seconds, and that the `posts` field should itself be cached for 180 seconds: | ||
|
||
```graphql | ||
type Author @cacheControl(maxAge: 60) { | ||
id: Int | ||
firstName: String | ||
lastName: String | ||
posts: [Post] @cacheControl(maxAge: 180) | ||
} | ||
``` | ||
|
||
See [the cache control documentation](https://github.com/apollographql/apollo-cache-control-js#add-cache-hints-to-your-schema) for more details, including how to specify hints dynamically inside resolvers, how to set a default `maxAge` for all fields, and how to specify that a field should be cached for specific users only (in which case CDNs should ignore it). For example, to set a default max age other than 0 modify the Apollo Server constructor to include `cacheControl`: | ||
|
||
```js | ||
const server = new ApolloServer({ | ||
typeDefs, | ||
resolvers, | ||
// The max age is calculated in seconds | ||
cacheControl: { defaultMaxAge: 5 }, | ||
}); | ||
``` | ||
|
||
After this step, Apollo Server will serve the HTTP `Cache-Control` header on fully cacheable responses, so that any CDN in front of Apollo Server will know which responses can be cached and for how long! A "fully cacheable" response contains only data with non-zero `maxAge`; the header will refer to the minimum `maxAge` value across the whole response, and it will be `public` unless some of the data is tagged `scope: PRIVATE`. To observe this header, use any browser's network tab in its dev tools. | ||
|
||
<h2 id="enable-apq" title="2. Enable persisted queries">Step 2: Enable automatic persisted queries</h2> | ||
|
||
Often, GraphQL requests are big POST requests and most CDNs will only cache GET requests. Additionally, GET requests generally work best when the URL has a bounded size. Enabling automatic persisted queries means that short hashes are sent over the wire instead of full queries, and Apollo Client can be configured to use GET requests for those hashed queries. | ||
|
||
To do this, update the **client** code. First, add the package: | ||
|
||
``` | ||
npm install apollo-link-persisted-queries | ||
``` | ||
|
||
Then, add the persisted queries link to the Apollo Client constructor before the HTTP link: | ||
|
||
```js | ||
import { createPersistedQueryLink } from "apollo-link-persisted-queries"; | ||
import { createHttpLink } from "apollo-link-http"; | ||
import { InMemoryCache } from "apollo-cache-inmemory"; | ||
import { ApolloLink } from "apollo-link"; | ||
import ApolloClient from "apollo-client"; | ||
|
||
ApolloLink.from([ | ||
createPersistedQueryLink({ useGETForHashedQueries: true }), | ||
createHttpLink({ uri: "/graphql" }) | ||
]); | ||
|
||
const client = new ApolloClient({ | ||
cache: new InMemoryCache(), | ||
link: link | ||
}); | ||
``` | ||
|
||
Make sure to include `useGETForHashedQueries: true`. Note that the client will still use POSTs for mutations, because it's generally best to avoid GETs for non-idempotent requests. | ||
|
||
If configured correctly, browser's dev tools should verify that queries are now sent as GET requests, and receive appropriate `Cache-Control` response headers. | ||
|
||
<h2 id="setup-cdn" title="3. Set up a CDN">Step 3: Set up a CDN!</h2> | ||
|
||
How exactly this works depends on exactly which CDN you chose. Configure your CDN to send requests to Apollo Server. Some CDNs may need to be specially configured to honor origin Cache-Control headers; for example, here is [Akamai's documentation on that setting](https://learn.akamai.com/en-us/webhelp/ion/oca/GUID-57C31126-F745-4FFB-AA92-6A5AAC36A8DA.html). If all is well, cacheable queries should now be saved by the CDN! | ||
|
||
> Note that requests served directly by a CDN will not show up in the Engine dashboard. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,9 +7,7 @@ Apollo Server provides reporting and persisted queries in native javascript by d | |
|
||
## Stand-alone Apollo Server | ||
|
||
With ENGINE_API_KEY set as an environment variable, Apollo Server creates a reporting agent that sends execution traces to the Engine UI. In addition by default, Apollo Server supports [persisted queries](./features/apq.html). | ||
|
||
<!-- FIXME add something about CDN headers--> | ||
Apollo Server 2 is able to completely replace the Engine Proxy. To enable metrics reporting, add `ENGINE_API_KEY` as an environment variable. Apollo Server will then create a reporting agent that sends execution traces to the Engine UI. In addition by default, Apollo Server supports [persisted queries](./features/apq.html) without needing the proxy's cache. Apollo Server also provides cache-control headers for consumption by a [CDN](./features/cdn.html). Integration with a CDN provides a replacement for the full response caching in Engine Proxy. | ||
|
||
```js | ||
const { ApolloServer } = require('apollo-server'); | ||
|
@@ -24,9 +22,9 @@ server.listen().then(({ url }) => { | |
}); | ||
``` | ||
|
||
## Starting Engine Proxy | ||
## Starting Engine Proxy as a Sidecar | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we moved away from the "sidecar" terminology because it never made sense to anybody |
||
|
||
The `apollo-engine` package provides integrations with many [node frameworks](/docs/engine/setup-node.html#not-express), including [express](/docs/engine/setup-node.html#setup-guide), that starts the Engine Proxy alongside the framework. The following code demonstrates how to start the proxy with Apollo Server 2, assuming that the `ENGINE_API_KEY` environment variable is set to the api key of the service. | ||
Some applications require the Engine Proxy for full response caching, so it is necessary to run the proxy as a process alongside Apollo Server. The `apollo-engine` package provides integrations with many [node frameworks](/docs/engine/setup-node.html#not-express), including [express](/docs/engine/setup-node.html#setup-guide), and starts the Engine Proxy alongside Apollo Server. The following code demonstrates how to start the proxy with Apollo Server 2. It assumes that the `ENGINE_API_KEY` environment variable is set to the api key of the service. | ||
|
||
```js | ||
const { ApolloEngine } = require('apollo-engine'); | ||
|
@@ -38,7 +36,9 @@ const server = new ApolloServer({ | |
typeDefs, | ||
resolvers, | ||
tracing: true, | ||
cacheControl: true | ||
cacheControl: true, | ||
// We set `engine` to false, so that the new agent is not used. | ||
engine: false, | ||
}); | ||
|
||
server.applyMiddlware({ app }); | ||
|
@@ -59,9 +59,26 @@ engine.listen({ | |
}); | ||
``` | ||
|
||
To set the default max age inside of cacheControl, some additional options must be specified: | ||
|
||
```js | ||
const server = new ApolloServer({ | ||
typeDefs, | ||
resolvers, | ||
tracing: true, | ||
cacheControl: { | ||
defaultMaxAge: 5, | ||
stripFormattedExtensions: false, | ||
calculateCacheControlHeaders: false, | ||
}, | ||
// We set `engine` to false, so that the new agent is not used. | ||
engine: false, | ||
}); | ||
``` | ||
|
||
## With a Running Engine Proxy | ||
|
||
If the engine proxy is already running in a container in front of Apollo Server, then set `tracing` and `cacheControl` to true. These options will provide the extensions information to the proxy to create traces and ensure caching. | ||
If the engine proxy is already running in a container in front of Apollo Server, then set `tracing` and `cacheControl` to true. These options will provide the extensions information to the proxy to create traces and ensure caching. We set `engine` to false, so that the new metrics reporting pipeline is not activated. | ||
|
||
```js | ||
const { ApolloServer } = require('apollo-server'); | ||
|
@@ -70,7 +87,9 @@ const server = new ApolloServer({ | |
typeDefs, | ||
resolvers, | ||
tracing: true, | ||
cacheControl: true | ||
cacheControl: true, | ||
// We set `engine` to false, so that the new agent is not used. | ||
engine: false, | ||
}); | ||
|
||
server.listen().then(({ url }) => { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,77 @@ | ||
import { ExecutionResult } from 'graphql'; | ||
import { CacheControlFormat } from 'apollo-cache-control'; | ||
|
||
export interface PersistedQueryCache { | ||
set(key: string, data: string): Promise<any>; | ||
get(key: string): Promise<string | null>; | ||
} | ||
|
||
export type HttpHeaderCalculation = ( | ||
responses: Array<ExecutionResult & { extensions?: Record<string, any> }>, | ||
) => Record<string, string>; | ||
|
||
export function calculateCacheControlHeaders( | ||
responses: Array<ExecutionResult & { extensions?: Record<string, any> }>, | ||
): Record<string, string> { | ||
let lowestMaxAge = Number.MAX_VALUE; | ||
let publicOrPrivate = 'public'; | ||
|
||
// Because of the early exit, we are unable to use forEach. While a reduce | ||
// loop might be possible, a for loop is more readable | ||
for (let i = 0; i < responses.length; i++) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So the key thing in the engineproxy calculation of cacheControl is you need to put a hint on every top-level data piece in order for it to count. See the rootDataKey loop in parseCacheControl. |
||
const response = responses[i]; | ||
|
||
const cacheControl: CacheControlFormat = | ||
response.extensions && response.extensions.cacheControl; | ||
|
||
// If there are no extensions or hints, then the headers should not be present | ||
if ( | ||
!cacheControl || | ||
!cacheControl.hints || | ||
cacheControl.hints.length === 0 || | ||
cacheControl.version !== 1 | ||
) { | ||
if (cacheControl && cacheControl.version !== 1) { | ||
console.warn('Invalid cacheControl version.'); | ||
} | ||
return {}; | ||
} | ||
|
||
const rootHints = new Set<string>(); | ||
for (let j = 0; j < cacheControl.hints.length; j++) { | ||
const hint = cacheControl.hints[j]; | ||
if (hint.scope && hint.scope.toLowerCase() === 'private') { | ||
publicOrPrivate = 'private'; | ||
} | ||
|
||
// If no maxAge is present, then we ignore the hint | ||
if (hint.maxAge === undefined) { | ||
continue; | ||
} | ||
|
||
// if there is a hint with max age of 0, we don't need to process more | ||
if (hint.maxAge === 0) { | ||
return {}; | ||
} | ||
|
||
if (hint.maxAge < lowestMaxAge) { | ||
lowestMaxAge = hint.maxAge; | ||
} | ||
|
||
// If this is a root path, store that the root is cacheable: | ||
if (hint.path.length === 1) { | ||
rootHints.add(hint.path[0] as string); | ||
} | ||
} | ||
|
||
// If a root field inside of data does not have a cache hint, then we do not | ||
// cache the response | ||
if (Object.keys(response.data).find(rootKey => !rootHints.has(rootKey))) { | ||
return {}; | ||
} | ||
} | ||
|
||
return { | ||
'Cache-Control': `max-age=${lowestMaxAge}, ${publicOrPrivate}`, | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that last sentence really is true. After all that was already a feature of the Proxy.