Skip to content

Commit 0b81718

Browse files
committed
fix: burst cache when og image templates change
1 parent bd932ad commit 0b81718

File tree

5 files changed

+71
-5
lines changed

5 files changed

+71
-5
lines changed

src/runtime/app/utils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,15 +174,22 @@ export function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOpti
174174
const { defaults } = useOgImageRuntimeConfig()
175175
const extension = _options?.extension || defaults?.extension || 'png'
176176
const isStatic = import.meta.prerender
177+
const options: Record<string, any> = { ..._options, _path: _pagePath }
178+
// Include the component template hash so that template changes produce different URLs,
179+
// busting CDN/build caches (Vercel, social platform crawlers like Twitter/Facebook, etc.)
180+
const componentName = _options?.component || defaults?.component || componentNames?.[0]?.pascalName
181+
const component = componentNames?.find((c: any) => c.pascalName === componentName || c.kebabName === componentName)
182+
if (component?.hash)
183+
options._componentHash = component.hash
177184
// Build URL with encoded options (Cloudinary-style)
178185
// Include _path so the server knows which page to render
179186
// Pass defaults to skip encoding default values in URL
180-
const result = buildOgImageUrl({ ..._options, _path: _pagePath }, extension, isStatic, defaults)
181-
let path = joinURL('/', baseURL, result.url)
182187
// For dynamic images, append build ID to bust external platform caches (Telegram, Facebook, etc.)
183188
if (!isStatic && runtimeConfig.app.buildId) {
184189
path = withQuery(path, { _v: runtimeConfig.app.buildId })
185190
}
191+
const result = buildOgImageUrl(options, extension, isStatic, defaults)
192+
const path = joinURL('/', baseURL, result.url)
186193
return {
187194
path,
188195
hash: result.hash,

src/runtime/server/og-image/context.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,10 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
115115

116116
// basePath is used for route rules matching - can be provided via _path param
117117
const basePath = withoutTrailingSlash(urlOptions._path || '/')
118+
const componentHash = urlOptions._componentHash || ''
118119
delete urlOptions._path
119120
delete urlOptions._hash // Remove internal hash field
121+
delete urlOptions._componentHash // Not needed for rendering
120122

121123
const basePathWithQuery = queryParams._query && typeof queryParams._query === 'object'
122124
? withQuery(basePath, queryParams._query)
@@ -146,8 +148,10 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
146148
const rendererType = normalised.renderer
147149
// In hash mode, basePath is always '/' (since _path isn't in the prerender cache payload),
148150
// so use the options hash directly as cache key to avoid all hash-mode images sharing one cache entry.
149-
const key = normalised.options.cacheKey
151+
// Component hash is appended so template changes invalidate the runtime cache.
152+
const baseCacheKey = normalised.options.cacheKey
150153
|| (hashMatch ? `hash:${hashMatch[1]}` : resolvePathCacheKey(e, basePathWithQuery, runtimeConfig.cacheQueryParams))
154+
const key = componentHash ? `${baseCacheKey}:${componentHash}` : baseCacheKey
151155

152156
let renderer: ((typeof SatoriRenderer | typeof BrowserRenderer | typeof TakumiRenderer) & { __mock__?: true }) | undefined
153157
switch (rendererType) {

src/runtime/server/utils.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { H3Event } from 'h3'
2-
import type { OgImageOptions, OgImageRuntimeConfig } from '../types'
2+
import type { OgImageComponent, OgImageOptions, OgImageRuntimeConfig } from '../types'
3+
import { componentNames } from '#og-image-virtual/component-names.mjs'
34
import { useRuntimeConfig } from 'nitropack/runtime'
45
import { joinURL } from 'ufo'
56
import { buildOgImageUrl } from '../shared'
@@ -14,9 +15,16 @@ export function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOpti
1415
const { defaults } = useOgImageRuntimeConfig()
1516
const extension = _options?.extension || defaults.extension
1617
const isStatic = import.meta.prerender
18+
const options: Record<string, any> = { ..._options, _path: _pagePath }
19+
// Include the component template hash so that template changes produce different URLs,
20+
// busting CDN/build caches (Vercel, social platform crawlers like Twitter/Facebook, etc.)
21+
const componentName = _options?.component || defaults.component || (componentNames as OgImageComponent[])?.[0]?.pascalName
22+
const component = (componentNames as OgImageComponent[])?.find(c => c.pascalName === componentName || c.kebabName === componentName)
23+
if (component?.hash)
24+
options._componentHash = component.hash
1725
// Include _path so the server knows which page to render
1826
// Pass defaults to skip encoding default values in URL
19-
const result = buildOgImageUrl({ ..._options, _path: _pagePath }, extension, isStatic, defaults)
27+
const result = buildOgImageUrl(options, extension, isStatic, defaults)
2028
return {
2129
path: joinURL('/', baseURL, result.url),
2230
hash: result.hash,

src/runtime/shared/urlEncoding.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const PARAM_ALIASES: Record<string, string> = {
4646
cache: 'cacheMaxAgeSeconds',
4747
p: '_path', // page path - needs alias since _path starts with underscore
4848
q: '_query', // query params - needs alias since _query starts with underscore
49+
ch: '_componentHash', // component template hash for cache busting prerendered URLs
4950
}
5051

5152
// Reverse mapping (param name -> alias)
@@ -75,6 +76,7 @@ const KNOWN_PARAMS = new Set([
7576
'fonts',
7677
'_query',
7778
'_hash',
79+
'_componentHash',
7880
'socialPreview',
7981
'props',
8082
'_path',

test/unit/urlEncoding.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,51 @@ describe('urlEncoding', () => {
393393
})
394394
})
395395

396+
describe('_componentHash cache busting', () => {
397+
it('encodes _componentHash as ch alias', () => {
398+
const encoded = encodeOgImageParams({ props: { title: 'Hello' }, _componentHash: 'abc123' })
399+
expect(encoded).toContain('ch_abc123')
400+
expect(encoded).toContain('title_Hello')
401+
})
402+
403+
it('different _componentHash produces different URL', () => {
404+
const url1 = buildOgImageUrl({ props: { title: 'Hello' }, _componentHash: 'hash1' }, 'png', true)
405+
const url2 = buildOgImageUrl({ props: { title: 'Hello' }, _componentHash: 'hash2' }, 'png', true)
406+
expect(url1.url).not.toBe(url2.url)
407+
})
408+
409+
it('same _componentHash produces same URL', () => {
410+
const url1 = buildOgImageUrl({ props: { title: 'Hello' }, _componentHash: 'hash1' }, 'png', true)
411+
const url2 = buildOgImageUrl({ props: { title: 'Hello' }, _componentHash: 'hash1' }, 'png', true)
412+
expect(url1.url).toBe(url2.url)
413+
})
414+
415+
it('decodes _componentHash as known param (not as prop)', () => {
416+
const encoded = encodeOgImageParams({ props: { title: 'Hello' }, _componentHash: 'abc123' })
417+
const decoded = decodeOgImageParams(encoded)
418+
expect(decoded._componentHash).toBe('abc123')
419+
expect(decoded.props?.ch).toBeUndefined()
420+
expect(decoded.props?._componentHash).toBeUndefined()
421+
})
422+
423+
it('_componentHash affects hash mode URLs too', () => {
424+
const longTitle = 'A'.repeat(250)
425+
const opts1 = { props: { title: longTitle }, _componentHash: 'hash1' }
426+
const opts2 = { props: { title: longTitle }, _componentHash: 'hash2' }
427+
const result1 = buildOgImageUrl(opts1, 'png', true)
428+
const result2 = buildOgImageUrl(opts2, 'png', true)
429+
expect(result1.hash).toBeDefined()
430+
expect(result2.hash).toBeDefined()
431+
expect(result1.hash).not.toBe(result2.hash)
432+
})
433+
434+
it('_componentHash included in hashOgImageOptions (not excluded like _path)', () => {
435+
const hash1 = hashOgImageOptions({ width: 1200, _componentHash: 'hash1' })
436+
const hash2 = hashOgImageOptions({ width: 1200, _componentHash: 'hash2' })
437+
expect(hash1).not.toBe(hash2)
438+
})
439+
})
440+
396441
describe('non-ASCII encode/decode roundtrip', () => {
397442
function roundtrip(props: Record<string, any>) {
398443
const encoded = encodeOgImageParams({ props })

0 commit comments

Comments
 (0)