Skip to content

Commit 9327df3

Browse files
heiskrCopilot
andauthored
Emit fine-grained surrogate keys on content responses (#62180)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 10d4c89 commit 9327df3

2 files changed

Lines changed: 84 additions & 1 deletion

File tree

src/frame/middleware/set-fastly-surrogate-key.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Request, Response, NextFunction } from 'express'
22

33
import { ExtendedRequest } from '@/types'
4+
import type { Page, Version } from '@/types'
45

56
// Fastly provides a Soft Purge feature that allows you to mark content as outdated (stale) instead of permanently
67
// purging and thereby deleting it from Fastly's caches. Objects invalidated with Soft Purge will be treated as
@@ -40,7 +41,13 @@ export function setLanguageFastlySurrogateKey(
4041
res: Response,
4142
next: NextFunction,
4243
) {
43-
res.set(KEY, makeLanguageSurrogateKey(req.language))
44+
const context = req.context
45+
const keys = makeContentSurrogateKeys({
46+
langCode: req.language,
47+
productId: productSurrogateId(context?.page),
48+
versionKey: versionSurrogateKey(context?.currentVersionObj),
49+
})
50+
res.set(KEY, keys.join(' '))
4451
return next()
4552
}
4653

@@ -50,3 +57,63 @@ export function makeLanguageSurrogateKey(langCode?: string) {
5057
}
5158
return `language:${langCode}`
5259
}
60+
61+
// Build the fine-grained surrogate keys for a content response (docs-engineering#6719).
62+
// A content page is exactly one of each axis, so ~4 keys per page, well under
63+
// Fastly's 16 KB Surrogate-Key header limit:
64+
//
65+
// language:<code> (also emitted for non-content responses)
66+
// product:<top-level dir> e.g. product:actions (~36)
67+
// version:<short release slug> e.g. version:ghes-3.14 (~7-8)
68+
// product:<x>,language:<y> compound, for targeted translation purges
69+
//
70+
// These keys are inert: nothing purges the new keys yet. docs-engineering#6720
71+
// will use them to target the tightest key that covers a deploy instead of
72+
// soft-purging a whole language. Space is the Fastly delimiter; colons and
73+
// commas are fine (we already ship `language:en` and `api-search:en`). The
74+
// language key stays first so anything that only reads the first token (e.g.
75+
// the caching-headers test helper) keeps working.
76+
export function makeContentSurrogateKeys({
77+
langCode,
78+
productId,
79+
versionKey,
80+
}: {
81+
langCode?: string
82+
productId?: string
83+
versionKey?: string
84+
}): string[] {
85+
const keys = [makeLanguageSurrogateKey(langCode)]
86+
if (productId) {
87+
keys.push(`product:${productId}`)
88+
if (langCode) {
89+
keys.push(`product:${productId},language:${langCode}`)
90+
}
91+
}
92+
if (versionKey) {
93+
keys.push(`version:${versionKey}`)
94+
}
95+
return keys
96+
}
97+
98+
// Derive the product id for the `product:` surrogate key from a content page's
99+
// path. The top-level content directory is the product id (mirrors
100+
// Page.parentProductId), e.g. `actions`. Returns undefined for non-content
101+
// responses and the top-level homepage (`content/index.md`).
102+
function productSurrogateId(page?: Page): string | undefined {
103+
const relativePath = page?.relativePath
104+
if (!relativePath) return undefined
105+
const id = relativePath.split('/')[0]
106+
if (!id || id.endsWith('.md')) return undefined
107+
return id
108+
}
109+
110+
// Derive the short release slug for the `version:` surrogate key, e.g. `fpt`,
111+
// `ghec`, `ghes-3.14`. Numbered releases (GHES) get the release appended so a
112+
// version-scoped purge can target a single release; unnumbered plans use the
113+
// plain short name.
114+
function versionSurrogateKey(versionObj?: Version): string | undefined {
115+
if (!versionObj) return undefined
116+
return versionObj.hasNumberedReleases
117+
? `${versionObj.shortName}-${versionObj.currentRelease}`
118+
: versionObj.shortName
119+
}

src/frame/tests/server.ts

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

78+
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.
82+
const res = await get('/en/get-started')
83+
expect(res.statusCode).toBe(200)
84+
const keys = res.headers['surrogate-key'].split(/\s/g)
85+
// Language key stays first for anything that only reads the first token.
86+
expect(keys[0]).toBe(makeLanguageSurrogateKey('en'))
87+
expect(keys).toContain('product:get-started')
88+
expect(keys).toContain('product:get-started,language:en')
89+
expect(keys.some((key: string) => /^version:.+/.test(key))).toBe(true)
90+
// Stays well under Fastly's limits: about 4 keys per page.
91+
expect(keys.length).toBeLessThanOrEqual(6)
92+
})
93+
7894
test('does not render duplicate <html> or <body> tags', async () => {
7995
const $ = await getDOM('/en')
8096
expect($('html').length).toBe(1)

0 commit comments

Comments
 (0)