11import type { Request , Response , NextFunction } from 'express'
22
33import { 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+ }
0 commit comments