Skip to content

Commit 51f23ff

Browse files
harlan-zwclaude
andauthored
feat: CI build cache (#413)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c9058c4 commit 51f23ff

10 files changed

Lines changed: 330 additions & 6 deletions

File tree

docs/content/3.guides/3.cache.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,63 @@ reduce the load on your server.
99

1010
This caching layer uses SWR caching is enabled by default with a cache time of 72 hours.
1111

12-
## Cache Storage
12+
## Build Cache (CI Persistence)
13+
14+
For CI/CD environments, you can enable persistent build caching to avoid regenerating images between deployments when the output would be identical.
15+
16+
```ts [nuxt.config.ts]
17+
export default defineNuxtConfig({
18+
ogImage: {
19+
buildCache: true
20+
}
21+
})
22+
```
23+
24+
This stores rendered images in `node_modules/.cache/nuxt/og-image/` during prerendering. The cache automatically invalidates when:
25+
26+
- **Options change** - Different title, description, or other props
27+
- **Template changes** - The component file is modified
28+
- **Module version changes** - You upgrade `nuxt-og-image`
29+
30+
### CI Configuration
31+
32+
To persist the cache between CI runs, add the cache directory to your CI configuration:
33+
34+
::code-group
35+
36+
```yaml [GitHub Actions]
37+
- name: Cache OG Images
38+
uses: actions/cache@v4
39+
with:
40+
path: node_modules/.cache/nuxt/og-image
41+
key: og-images-${{ hashFiles('**/package-lock.json') }}
42+
restore-keys: |
43+
og-images-
44+
```
45+
46+
```yaml [GitLab CI]
47+
cache:
48+
paths:
49+
- node_modules/.cache/nuxt/og-image/
50+
```
51+
52+
::
53+
54+
### Custom Cache Directory
55+
56+
You can customize the cache location:
57+
58+
```ts [nuxt.config.ts]
59+
export default defineNuxtConfig({
60+
ogImage: {
61+
buildCache: {
62+
base: '.cache/og-image'
63+
}
64+
}
65+
})
66+
```
67+
68+
## Runtime Cache Storage
1369

1470
Nitro caching by default will use the memory as a cache storage. This means that if you restart your server, the cache will be cleared.
1571

docs/content/4.api/3.config.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ Extra component directories that should be used to resolve components.
9595
- Type: `boolean | (Record<string, any> & { driver: string })`{lang="ts"}
9696
- Default: `true`{lang="ts"}
9797

98-
Modify the cache behaviour.
98+
Modify the runtime cache behaviour.
9999

100100
Passing a boolean will enable or disable the runtime cache with the default options.
101101

@@ -114,6 +114,29 @@ export default defineNuxtConfig({
114114
})
115115
```
116116

117+
### `buildCache`
118+
119+
- Type: `boolean | { base?: string }`{lang="ts"}
120+
- Default: `false`{lang="ts"}
121+
122+
Enable persistent build cache for CI environments. Caches rendered images to disk so they persist between CI runs.
123+
124+
The cache key includes the options hash, component template hash, and module version, ensuring automatic invalidation when any of these change.
125+
126+
```ts
127+
export default defineNuxtConfig({
128+
ogImage: {
129+
buildCache: true
130+
// or with custom directory:
131+
// buildCache: { base: '.cache/og-image' }
132+
}
133+
})
134+
```
135+
136+
Default cache directory: `node_modules/.cache/nuxt/og-image/`
137+
138+
See the [Caching Guide](/docs/og-image/guides/cache#build-cache-ci-persistence) for CI configuration examples.
139+
117140
## `strictNuxtContentPaths`
118141

119142
- Type: `boolean`{lang="ts"}

playground/nuxt.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ export default defineNuxtConfig({
107107
// port: 6379,
108108
// },
109109
// },
110+
// Enable for CI persistent caching:
111+
// buildCache: true,
110112
debug: true,
111113
},
112114

src/module.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,15 @@ export interface ModuleOptions {
130130
* Only allow the prerendering and dev runtimes to generate images.
131131
*/
132132
zeroRuntime?: boolean
133-
133+
/**
134+
* Enable persistent build cache for CI environments.
135+
* Caches rendered images to disk so they persist between CI runs.
136+
*
137+
* @default false
138+
* @example true
139+
* @example { base: '.cache/og-image' }
140+
*/
141+
buildCache?: boolean | { base?: string }
134142
}
135143

136144
export interface ModuleHooks {
@@ -629,6 +637,14 @@ export {}
629637
nuxt.options.nitro.storage = nuxt.options.nitro.storage || {}
630638
nuxt.options.nitro.storage['nuxt-og-image'] = config.runtimeCacheStorage
631639
}
640+
641+
// Build cache for CI persistence (absolute path)
642+
const buildCachePath = typeof config.buildCache === 'object' && config.buildCache.base
643+
? config.buildCache.base
644+
: 'node_modules/.cache/nuxt/og-image'
645+
const buildCacheDir = config.buildCache
646+
? resolve(nuxt.options.rootDir, buildCachePath)
647+
: undefined
632648
nuxt.hooks.hook('modules:done', async () => {
633649
// allow other modules to modify runtime data
634650
const normalisedFonts: FontConfig[] = normaliseFontInput(config.fonts)
@@ -661,6 +677,7 @@ export {}
661677
debug: config.debug,
662678
// avoid adding credentials
663679
baseCacheKey,
680+
buildCacheDir,
664681
// convert the fonts to uniform type to fix ts issue
665682
fonts: normalisedFonts,
666683
hasNuxtIcon: hasNuxtModule('nuxt-icon') || hasNuxtModule('@nuxt/icon'),
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { OgImageComponent } from '../../../types'
2+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3+
// @ts-expect-error untyped
4+
import { componentNames } from '#og-image-virtual/component-names.mjs'
5+
import { join } from 'pathe'
6+
import { hashOgImageOptions } from '../../../shared/urlEncoding'
7+
import { useOgImageRuntimeConfig } from '../../utils'
8+
9+
interface CachedImage {
10+
data: string // base64
11+
expiresAt: number
12+
}
13+
14+
/**
15+
* Get the component hash for a given component name
16+
*/
17+
export function getComponentHash(componentName: string): string {
18+
const components = componentNames as OgImageComponent[]
19+
const component = components.find(
20+
c => c.pascalName === componentName || c.kebabName === componentName,
21+
)
22+
return component?.hash || ''
23+
}
24+
25+
/**
26+
* Generate a cache key that includes options, component hash, and version
27+
*/
28+
export function generateBuildCacheKey(
29+
options: Record<string, any>,
30+
extension: string,
31+
): string {
32+
const { version } = useOgImageRuntimeConfig()
33+
const componentHash = getComponentHash(options.component || 'NuxtSeo')
34+
const hash = hashOgImageOptions(options, componentHash, version)
35+
return `${hash}.${extension}`
36+
}
37+
38+
/**
39+
* Check if an image exists in the build cache
40+
*/
41+
export function getBuildCachedImage(
42+
options: Record<string, any>,
43+
extension: string,
44+
): Buffer | null {
45+
const { buildCacheDir } = useOgImageRuntimeConfig()
46+
if (!buildCacheDir)
47+
return null
48+
49+
const cacheKey = generateBuildCacheKey(options, extension)
50+
const cachePath = join(buildCacheDir, cacheKey)
51+
52+
if (!existsSync(cachePath))
53+
return null
54+
55+
const cached: CachedImage = JSON.parse(readFileSync(cachePath, 'utf-8'))
56+
57+
// Check expiry
58+
if (cached.expiresAt && cached.expiresAt < Date.now()) {
59+
return null
60+
}
61+
62+
return Buffer.from(cached.data, 'base64')
63+
}
64+
65+
/**
66+
* Save an image to the build cache
67+
*/
68+
export function setBuildCachedImage(
69+
options: Record<string, any>,
70+
extension: string,
71+
data: Buffer | Uint8Array,
72+
maxAgeSeconds: number,
73+
): void {
74+
const { buildCacheDir } = useOgImageRuntimeConfig()
75+
if (!buildCacheDir)
76+
return
77+
78+
const cacheKey = generateBuildCacheKey(options, extension)
79+
const cachePath = join(buildCacheDir, cacheKey)
80+
81+
// Ensure cache directory exists
82+
if (!existsSync(buildCacheDir)) {
83+
mkdirSync(buildCacheDir, { recursive: true })
84+
}
85+
86+
const cached: CachedImage = {
87+
data: Buffer.from(data).toString('base64'),
88+
expiresAt: Date.now() + (maxAgeSeconds * 1000),
89+
}
90+
91+
writeFileSync(cachePath, JSON.stringify(cached))
92+
}

src/runtime/server/util/eventHandlers.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useSiteConfig } from '#site-config/server/composables/useSiteConfig'
55
import { createError, getQuery, H3Error, proxyRequest, sendRedirect, setHeader, setResponseHeader } from 'h3'
66
import { parseURL } from 'ufo'
77
import { normaliseFontInput } from '../../shared'
8+
import { getBuildCachedImage, setBuildCachedImage } from '../og-image/cache/buildCache'
89
import { resolveContext } from '../og-image/context'
910
import { assets } from '../og-image/satori/font'
1011
import { html } from '../og-image/templates/html'
@@ -153,6 +154,14 @@ export async function imageEventHandler(e: H3Event) {
153154
statusMessage: `[Nuxt OG Image] Invalid request for og.${extension}.`,
154155
})
155156
}
157+
// Check build cache first (CI persistence)
158+
const buildCachedImage = import.meta.prerender
159+
? getBuildCachedImage(ctx.options, extension)
160+
: null
161+
if (buildCachedImage) {
162+
return buildCachedImage
163+
}
164+
156165
const cacheApi = await useOgImageBufferCache(ctx, {
157166
cacheMaxAgeSeconds: ctx.options.cacheMaxAgeSeconds,
158167
baseCacheKey,
@@ -175,6 +184,10 @@ export async function imageEventHandler(e: H3Event) {
175184
})
176185
}
177186
await cacheApi.update(image)
187+
// Save to build cache for CI persistence
188+
if (import.meta.prerender && ctx.options.cacheMaxAgeSeconds) {
189+
setBuildCachedImage(ctx.options, extension, image as Buffer, ctx.options.cacheMaxAgeSeconds)
190+
}
178191
}
179192
return image
180193
}

src/runtime/shared/urlEncoding.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,18 @@ function simpleHash(str: string): string {
9393
/**
9494
* Generate a deterministic hash from options object
9595
* Excludes _path so images with same options can be cached across pages
96+
* Optionally includes componentHash and version for cache busting
9697
*/
97-
export function hashOgImageOptions(options: Record<string, any>): string {
98+
export function hashOgImageOptions(
99+
options: Record<string, any>,
100+
componentHash?: string,
101+
version?: string,
102+
): string {
98103
const { _path, _hash, ...hashableOptions } = options
99-
const json = JSON.stringify(hashableOptions)
100-
return simpleHash(json)
104+
const hashInput = componentHash || version
105+
? [hashableOptions, componentHash || '', version || '']
106+
: hashableOptions
107+
return simpleHash(JSON.stringify(hashInput))
101108
}
102109

103110
/**

src/runtime/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface OgImageRuntimeConfig {
4545
zeroRuntime: boolean
4646

4747
componentDirs?: string[]
48+
/** Directory for persistent build cache (CI caching) */
49+
buildCacheDir?: string
4850

4951
app: {
5052
baseURL: string

test/e2e/build-cache.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { existsSync, readdirSync, rmSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
import { createResolver } from '@nuxt/kit'
4+
import { $fetch, setup } from '@nuxt/test-utils/e2e'
5+
import { describe, expect, it } from 'vitest'
6+
7+
const { resolve } = createResolver(import.meta.url)
8+
const fixtureDir = resolve('../fixtures/basic')
9+
const cacheDir = join(fixtureDir, 'node_modules/.cache/nuxt/og-image')
10+
11+
// Clean up cache before test
12+
if (existsSync(cacheDir)) {
13+
rmSync(cacheDir, { recursive: true })
14+
}
15+
16+
await setup({
17+
rootDir: fixtureDir,
18+
server: true,
19+
build: true,
20+
nuxtConfig: {
21+
ogImage: {
22+
buildCache: true,
23+
},
24+
},
25+
})
26+
27+
describe('build cache', () => {
28+
it('creates cache directory during prerender', async () => {
29+
// Fetch a page to ensure OG images are generated
30+
const html = await $fetch('/satori')
31+
expect(html).toContain('og:image')
32+
33+
// Check that cache directory was created
34+
expect(existsSync(cacheDir)).toBe(true)
35+
})
36+
37+
it('caches rendered images to disk', async () => {
38+
// List files in cache directory
39+
const files = readdirSync(cacheDir)
40+
41+
// Should have at least one cached image
42+
expect(files.length).toBeGreaterThan(0)
43+
44+
// Files should be JSON (containing base64 image data and expiry)
45+
const hasJsonFiles = files.some(f => f.endsWith('.png') || f.endsWith('.jpg'))
46+
expect(hasJsonFiles).toBe(true)
47+
})
48+
49+
it('cache key includes component hash and version', async () => {
50+
// The cache files should have hash-based names
51+
const files = readdirSync(cacheDir)
52+
53+
// Files should be named with hashes (alphanumeric)
54+
const allHashNames = files.every(f => /^[a-z0-9]+\.(?:png|jpg|jpeg)$/i.test(f))
55+
expect(allHashNames).toBe(true)
56+
})
57+
})

0 commit comments

Comments
 (0)