Skip to content

Commit a03cede

Browse files
heiskrCopilot
andauthored
Add a week-long stale window to the default CDN cache policy (#62451)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80d768a6-3069-432e-aae6-4d9cbd1e38be
1 parent 538822c commit a03cede

3 files changed

Lines changed: 107 additions & 83 deletions

File tree

src/frame/middleware/cache-control.ts

Lines changed: 69 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,43 @@ import type { Response } from 'express'
33
import { createLogger } from '@/observability/logger'
44
const logger = createLogger(import.meta.url)
55

6+
type CacheControlKey = 'cache-control' | 'surrogate-control'
7+
68
interface CacheControlOptions {
7-
key?: string
8-
public_?: boolean
9+
key?: CacheControlKey
910
immutable?: boolean
10-
maxAgeZero?: boolean
11+
staleWhileRevalidate?: number
12+
staleIfError?: number
1113
}
1214

13-
// Return a function you can pass a Response object to and it will
14-
// set the `Cache-Control` header.
15-
//
16-
// For example:
17-
//
18-
// const cacheControlYear = getCacheControl(60 * 60 * 24 * 365)
19-
// ...
20-
// cacheControlYear(res)
21-
// res.send(body)
22-
//
23-
// Max age is in seconds
24-
// Max age should not be greater than 31536000 https://www.ietf.org/rfc/rfc2616.txt
15+
const ONE_MINUTE = 60
16+
const TEN_MINUTES = 10 * ONE_MINUTE
17+
const ONE_HOUR = 60 * ONE_MINUTE
18+
const ONE_DAY = 24 * ONE_HOUR
19+
const ONE_WEEK = 7 * ONE_DAY
20+
const ONE_YEAR = 365 * ONE_DAY
21+
22+
// Return a function you can pass a Response object to and it will set the `Cache-Control` header.
23+
// Max age is in seconds.
24+
// Max age should not be greater than 31536000, per <https://www.ietf.org/rfc/rfc2616.txt>.
2525
function cacheControlFactory(
26-
maxAge: number = 60 * 60,
26+
maxAge: number = 0,
2727
{
2828
key = 'cache-control',
29-
public_ = true,
3029
immutable = false,
31-
maxAgeZero = false,
30+
staleWhileRevalidate = 0,
31+
staleIfError = 0,
3232
}: CacheControlOptions = {},
3333
): (res: Response) => void {
3434
const directives = [
35-
maxAge && public_ && 'public',
36-
maxAge && `max-age=${maxAge}`,
37-
maxAge && immutable && 'immutable',
38-
!maxAge && 'private',
39-
!maxAge && 'no-store',
40-
maxAge >= 60 * 60 && `stale-while-revalidate=${60 * 60}`,
41-
maxAge >= 60 * 60 && `stale-if-error=${24 * 60 * 60}`,
42-
(maxAgeZero || maxAge === 0) && 'max-age=0',
35+
maxAge > 0 && 'public',
36+
maxAge > 0 && `max-age=${maxAge}`,
37+
maxAge <= 0 && 'max-age=0',
38+
maxAge > 0 && immutable && 'immutable',
39+
maxAge <= 0 && 'private',
40+
maxAge <= 0 && 'no-store',
41+
maxAge > 0 && staleWhileRevalidate > 0 && `stale-while-revalidate=${staleWhileRevalidate}`,
42+
maxAge > 0 && staleIfError > 0 && `stale-if-error=${staleIfError}`,
4343
]
4444
.filter(Boolean)
4545
.join(', ')
@@ -53,64 +53,73 @@ function cacheControlFactory(
5353
}
5454
}
5555

56-
// These are roughly in order from shortest to longest
56+
// ### These are roughly in order from shortest to longest. ###
5757

58-
// If you do not want caching
58+
// If you do not want caching.
5959
export const noCacheControl = cacheControlFactory(0)
6060

61-
// Short cache for 4xx errors
62-
export const errorCacheControl = cacheControlFactory(60) // 1 minute
63-
64-
// This means we tell the browser to cache the XHR request for 1h
65-
const searchBrowserCacheControl = cacheControlFactory(60 * 60)
66-
// This tells the CDN to cache the response for 24 hours
67-
const searchCdnCacheControl = cacheControlFactory(60 * 60 * 24, {
68-
key: 'surrogate-control',
69-
})
70-
export function searchCacheControl(res: Response): void {
71-
searchBrowserCacheControl(res)
72-
searchCdnCacheControl(res)
73-
}
61+
// Short cache for 4xx errors.
62+
export const errorCacheControl = cacheControlFactory(ONE_MINUTE)
7463

75-
// 24 hours for CDN, we soft-purge this with each deploy
76-
const defaultCDNCacheControl = cacheControlFactory(60 * 60 * 24, {
64+
// For default cache control, up to one week in cache but much shorter in browser.
65+
// Most responses are under the default cache control policy.
66+
const browserCacheControl = cacheControlFactory(ONE_MINUTE)
67+
const defaultCDNCacheControl = cacheControlFactory(TEN_MINUTES, {
7768
key: 'surrogate-control',
69+
staleWhileRevalidate: ONE_WEEK,
70+
staleIfError: ONE_WEEK,
7871
})
79-
// Shorter because between deployments and their (sort) purges,
80-
// we don't want the browser to overly cache because with them we
81-
// can't control purging.
82-
const defaultBrowserCacheControl = cacheControlFactory(60)
83-
// A general default configuration that is useful to almost all responses
84-
// that can be cached.
8572
export function defaultCacheControl(res: Response): void {
73+
browserCacheControl(res)
8674
defaultCDNCacheControl(res)
87-
defaultBrowserCacheControl(res)
8875
}
76+
export const searchCacheControl = defaultCacheControl
8977

90-
// Vary on content type for pages that support content negotiation (HTML vs markdown)
78+
// For requests where the response can vary between a HTML and Markdown response
79+
// using the accept header.
9180
export function contentTypeCacheControl(res: Response): void {
9281
defaultCacheControl(res)
9382
res.append('vary', 'accept')
9483
}
9584

96-
// Vary on language when needed
97-
// x-user-language is a custom request header derived from req.cookie:user_language
98-
// accept-language is truncated to one of our available languages
85+
// Vary on language when needed.
86+
// `x-user-language` is a custom request header derived from `req.cookie:user_language`.
87+
// `accept-language` is truncated to one of our available languages.
9988
// https://bit.ly/3u5UeRN
10089
export function languageCacheControl(res: Response): void {
10190
defaultCacheControl(res)
10291
res.append('vary', 'accept-language, x-user-language')
10392
}
10493

105-
// Vary on both language and version for homepage redirects
106-
// x-user-version is a custom request header derived from req.cookie:user_version
94+
// Vary on both language and version for homepage redirects.
95+
// `x-user-version` is a custom request header derived from `req.cookie:user_version`.
10796
export function languageAndVersionCacheControl(res: Response): void {
10897
defaultCacheControl(res)
10998
res.append('vary', 'accept-language, x-user-language, x-user-version')
11099
}
111100

112-
// Long cache control for versioned assets: images, CSS, JS...
113-
export const assetCacheControl = cacheControlFactory(60 * 60 * 24 * 7, { immutable: true })
101+
// Long cache control for versioned assets: such as images, CSS, prebuilt JS.
102+
const assetBrowserCacheControl = cacheControlFactory(TEN_MINUTES)
103+
const assetCDNCacheControl = cacheControlFactory(ONE_WEEK, {
104+
key: 'surrogate-control',
105+
immutable: true,
106+
staleWhileRevalidate: ONE_WEEK,
107+
staleIfError: ONE_WEEK,
108+
})
109+
export function assetCacheControl(res: Response): void {
110+
assetBrowserCacheControl(res)
111+
assetCDNCacheControl(res)
112+
}
114113

115-
// Long caching for archived pages and assets
116-
export const archivedCacheControl = cacheControlFactory(60 * 60 * 24 * 365)
114+
// Long caching for archived pages and assets.
115+
const archivedBrowserCacheControl = cacheControlFactory(TEN_MINUTES)
116+
const archivedCDNCacheControl = cacheControlFactory(ONE_YEAR, {
117+
key: 'surrogate-control',
118+
immutable: true,
119+
staleWhileRevalidate: ONE_WEEK,
120+
staleIfError: ONE_WEEK,
121+
})
122+
export function archivedCacheControl(res: Response): void {
123+
archivedBrowserCacheControl(res)
124+
archivedCDNCacheControl(res)
125+
}

src/frame/tests/favicons.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@ import { describe, expect, test, vi } from 'vitest'
33
import { SURROGATE_ENUMS } from '@/frame/middleware/set-fastly-surrogate-key'
44
import { get } from '@/tests/helpers/e2etest'
55

6+
function getMaxAge(header: string | undefined): number {
7+
return Number(String(header).match(/(?:^|[ ,])max-age=(\d+)/)?.[1])
8+
}
9+
10+
// The CDN holds favicons for a long time because we can purge it on demand with
11+
// the manual surrogate key. The browser gets a shorter window because we can't.
12+
function expectAggressiveCaching(headers: Record<string, string>) {
13+
const cdnMaxAge = getMaxAge(headers['surrogate-control'])
14+
expect(headers['surrogate-control']).toContain('public')
15+
expect(headers['surrogate-control']).toContain('immutable')
16+
expect(cdnMaxAge).toBeGreaterThanOrEqual(60 * 60)
17+
18+
const browserMaxAge = getMaxAge(headers['cache-control'])
19+
expect(headers['cache-control']).toContain('public')
20+
expect(browserMaxAge).toBeGreaterThan(0)
21+
expect(browserMaxAge).toBeLessThan(cdnMaxAge)
22+
}
23+
624
describe('favicon assets', () => {
725
vi.setConfig({ testTimeout: 60 * 1000 })
826

@@ -12,16 +30,7 @@ describe('favicon assets', () => {
1230
expect(parseInt(res.headers['content-length'], 10)).toBeGreaterThan(0)
1331
expect(res.headers['content-type']).toBe('image/x-icon')
1432
expect(res.headers['set-cookie']).toBeUndefined()
15-
expect(res.headers['cache-control']).toContain('public')
16-
expect(res.headers['cache-control']).toContain('immutable')
17-
expect(res.headers['cache-control']).toMatch(/max-age=\d+/)
18-
const maxAgeSeconds = parseInt(
19-
(res.headers['cache-control'] || '').match(/max-age=(\d+)/)?.[1] || '',
20-
10,
21-
)
22-
// Let's not be too specific in the tests, just as long as it's testing
23-
// that it's a reasonably large number of seconds.
24-
expect(maxAgeSeconds).toBeGreaterThanOrEqual(60 * 60)
33+
expectAggressiveCaching(res.headers)
2534
expect(res.headers['surrogate-key']).toBe(SURROGATE_ENUMS.MANUAL)
2635
})
2736

@@ -31,16 +40,7 @@ describe('favicon assets', () => {
3140
expect(parseInt(res.headers['content-length'] || '', 10)).toBeGreaterThan(0)
3241
expect(res.headers['content-type']).toBe('image/png')
3342
expect(res.headers['set-cookie']).toBeUndefined()
34-
expect(res.headers['cache-control']).toContain('public')
35-
expect(res.headers['cache-control']).toContain('immutable')
36-
expect(res.headers['cache-control']).toMatch(/max-age=\d+/)
37-
const maxAgeSeconds = parseInt(
38-
(res.headers['cache-control'] || '').match(/max-age=(\d+)/)?.[1] || '',
39-
10,
40-
)
41-
// Let's not be too specific in the tests, just as long as it's testing
42-
// that it's a reasonably large number of seconds.
43-
expect(maxAgeSeconds).toBeGreaterThanOrEqual(60 * 60)
43+
expectAggressiveCaching(res.headers)
4444
expect(res.headers['surrogate-key']).toBe(SURROGATE_ENUMS.MANUAL)
4545
})
4646

src/frame/tests/server.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,25 @@ describe('server', () => {
7575
expect(surrogateKeySplit.includes(makeLanguageSurrogateKey('en'))).toBeTruthy()
7676
})
7777

78+
test('caches responses with short edge freshness and a week-long stale window', async () => {
79+
const week = 60 * 60 * 24 * 7
80+
for (const path of ['/en/get-started', '/robots.txt']) {
81+
const res = await get(path)
82+
expect(res.statusCode).toBe(200)
83+
const surrogate = res.headers['surrogate-control']
84+
expect(surrogate).toContain(`stale-while-revalidate=${week}`)
85+
expect(surrogate).toContain(`stale-if-error=${week}`)
86+
// Edge freshness is short (well under the week-long stale window).
87+
const maxAge = Number(surrogate.match(/(?:^|[ ,])max-age=(\d+)/)?.[1])
88+
expect(maxAge).toBeGreaterThan(0)
89+
expect(maxAge).toBeLessThan(week)
90+
}
91+
// Browser cache stays short: 60s, unpurgeable.
92+
const res = await get('/en/get-started')
93+
expect(res.headers['cache-control']).toMatch(/(^|[ ,])max-age=60([ ,]|$)/)
94+
})
95+
7896
test('sets fine-grained product and version surrogate keys on content pages', async () => {
79-
// docs-engineering#6719: content pages emit language, product, version, and
80-
// a product,language compound key so per-deploy purges can target the
81-
// tightest key instead of a whole language.
8297
const res = await get('/en/get-started')
8398
expect(res.statusCode).toBe(200)
8499
const keys = res.headers['surrogate-key'].split(/\s/g)

0 commit comments

Comments
 (0)