Skip to content

Commit 5020d29

Browse files
committed
feat: add CDN cache purging for Storage files
1 parent 663adfd commit 5020d29

File tree

7 files changed

+205
-11
lines changed

7 files changed

+205
-11
lines changed

README.md

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@ Built on top of `@payloadcms/plugin-cloud-storage` for easy integration with Pay
99
## Table of Contents
1010

1111
- [Features](#features)
12-
- [Performance Recommendation](#-performance-recommendation)
12+
- [Performance Recommendation](#-performance-recommendation)
1313
- [Installation](#installation)
1414
- [Quick Start](#quick-start)
1515
- [Configuration](#configuration)
1616
- [Collections](#collections-configuration)
1717
- [Storage](#storage-configuration)
1818
- [Stream](#stream-configuration)
19+
- [Cache Purging](#cache-purging-configuration)
1920
- [Admin Thumbnails](#admin-thumbnail-configuration)
2021
- [Access Control](#access-control-configuration)
22+
- [CDN Cache Management](#cdn-cache-management)
2123
- [Getting API Keys](#getting-api-keys)
2224
- [Storage Regions](#storage-regions)
2325
- [Examples](#examples)
@@ -28,6 +30,7 @@ Built on top of `@payloadcms/plugin-cloud-storage` for easy integration with Pay
2830
- Handle videos with Bunny Stream (HLS, MP4, thumbnails)
2931
- Show thumbnails in your admin panel
3032
- Control access via Payload or direct CDN links
33+
- Automatic CDN cache purging for updated files
3134

3235
## ⚡ Performance Recommendation
3336

@@ -157,6 +160,29 @@ stream: {
157160
158161
**Important**: Video support is always available, even without Bunny Stream configured. If Bunny Stream is disabled, video files will simply be uploaded to Bunny Storage like any other file type. Bunny Stream just provides enhanced video features (streaming, adaptive bitrates, thumbnails).
159162

163+
### Cache Purging Configuration
164+
165+
Enable automatic CDN cache purging for storage files (not applicable to Stream):
166+
167+
```typescript
168+
purge: {
169+
// Enable cache purging
170+
enabled: true,
171+
172+
// Your Bunny API key
173+
apiKey: string,
174+
175+
// Optional: wait for purge to complete (default: false)
176+
async?: boolean
177+
}
178+
```
179+
180+
When enabled, the plugin will automatically purge the CDN cache after:
181+
- File uploads
182+
- File deletions
183+
184+
This ensures that visitors always see the most up-to-date version of your files, which is especially important when replacing existing files (e.g., during image cropping operations).
185+
160186
### Admin Thumbnail Configuration
161187

162188
Control thumbnails in your admin panel:
@@ -177,6 +203,8 @@ adminThumbnail: {
177203
}
178204
```
179205

206+
When `appendTimestamp` is enabled (or using the default setting), the plugin automatically adds a timestamp parameter to image URLs in the admin panel. This ensures that when files are updated, the admin UI always shows the latest version without browser caching issues.
207+
180208
The `queryParams` option is particularly useful when used with Bunny's Image Optimizer service, allowing you to resize, crop, and optimize images on-the-fly.
181209

182210
### Access Control Configuration
@@ -207,6 +235,47 @@ When `disablePayloadAccessControl: true`:
207235
- Faster delivery but open access
208236
- No need for MP4 fallback
209237

238+
## CDN Cache Management
239+
240+
There are two approaches to managing the CDN cache for your Bunny Storage files:
241+
242+
### Option 1: Automatic Cache Purging
243+
244+
You can enable automatic cache purging whenever files are uploaded or deleted:
245+
246+
```typescript
247+
purge: {
248+
enabled: true,
249+
apiKey: process.env.BUNNY_API_KEY,
250+
async: false // Wait for purge to complete (default: false)
251+
}
252+
```
253+
254+
This is the most comprehensive approach as it ensures the CDN cache is immediately purged when files change, making the updated content available to all visitors.
255+
256+
### Option 2: Timestamp-Based Cache Busting
257+
258+
For the admin panel specifically, you can use timestamp-based cache busting:
259+
260+
1. First, configure the plugin to add timestamps to image URLs:
261+
262+
```typescript
263+
adminThumbnail: {
264+
appendTimestamp: true
265+
}
266+
```
267+
268+
2. In your Bunny Pull Zone settings:
269+
- Go to the "Caching" section
270+
- Enable "Vary Cache" for "URL Query String"
271+
- Add "t" to the "Query String Vary Parameters" list
272+
273+
This approach only affects how images are displayed in the admin panel and doesn't purge the actual CDN cache. It appends a timestamp parameter (`?t=1234567890`) to image URLs, causing Bunny CDN to treat each timestamped URL as a unique cache entry.
274+
275+
Choose the approach that best fits your needs:
276+
- Use **automatic cache purging** for immediate updates everywhere
277+
- Use **timestamp-based cache busting** for a simpler setup that only affects the admin panel
278+
210279
## Getting API Keys
211280

212281
### Bunny Storage API Key
@@ -233,6 +302,16 @@ To find your Bunny Stream API key:
233302
5. Find "CDN Hostname" for your `hostname` setting (like "vz-example-123.b-cdn.net")
234303
6. The "API Key" is found at the bottom of the page
235304

305+
### Bunny API Key
306+
307+
To find your Bunny API key (used for cache purging):
308+
309+
1. Go to your Bunny.net dashboard
310+
2. Click on your account in the top-right corner
311+
3. Select "Account settings" from the dropdown menu
312+
4. Click on "API" in the sidebar menu
313+
5. Copy the API key displayed on the page
314+
236315
## Storage Regions
237316

238317
Choose where to store your files. If you don't pick a region, the default storage location is used.
@@ -281,6 +360,28 @@ bunnyStorage({
281360
})
282361
```
283362

363+
### With Cache Purging Enabled
364+
365+
```typescript
366+
bunnyStorage({
367+
collections: {
368+
media: true,
369+
},
370+
options: {
371+
storage: {
372+
apiKey: process.env.BUNNY_STORAGE_API_KEY,
373+
hostname: 'storage.example.b-cdn.net',
374+
zoneName: 'my-zone',
375+
},
376+
purge: {
377+
enabled: true,
378+
apiKey: process.env.BUNNY_API_KEY,
379+
async: false, // Wait for purge to complete
380+
},
381+
},
382+
})
383+
```
384+
284385
### With Bunny Stream & Direct CDN Access
285386

286387
```typescript
@@ -304,6 +405,10 @@ bunnyStorage({
304405
libraryId: '123456',
305406
thumbnailTime: 5000, // 5 seconds in milliseconds
306407
},
408+
purge: {
409+
enabled: true,
410+
apiKey: process.env.BUNNY_API_KEY,
411+
},
307412
},
308413
})
309414
```
@@ -332,6 +437,10 @@ bunnyStorage({
332437
mp4Fallback: { enabled: true }, // Required with access control
333438
thumbnailTime: 5000, // 5 seconds in milliseconds
334439
},
440+
purge: {
441+
enabled: true,
442+
apiKey: process.env.BUNNY_API_KEY,
443+
},
335444
},
336445
})
337446
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@seshuk/payload-storage-bunny",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "Payload storage adapter for Bunny.net",
55
"author": "Maxim Seshuk",
66
"license": "MIT",

src/generateURL.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ export const getGenerateURL = ({ storage, stream }: BunnyAdapterOptions): Genera
1010
return `https://${stream.hostname}/${data.bunnyVideoId}/playlist.m3u8`
1111
}
1212

13-
return `https://${storage.hostname}/${posix.join(prefix, filename)}`
13+
return `https://${storage.hostname}/${encodeURI(posix.join(prefix, filename))}`
1414
}
1515
}

src/handleDelete.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,24 @@ import { APIError } from 'payload'
66

77
import type { BunnyAdapterOptions } from './types.js'
88

9-
import { getStorageUrl, getVideoFromDoc } from './utils.js'
9+
import { getGenerateURL } from './generateURL.js'
10+
import { getStorageUrl, getVideoFromDoc, purgeBunnyCache } from './utils.js'
1011

11-
export const getHandleDelete = ({ storage, stream }: BunnyAdapterOptions): HandleDelete => {
12-
return async ({ doc, filename, req }) => {
12+
export const getHandleDelete = ({ purge, storage, stream }: BunnyAdapterOptions): HandleDelete => {
13+
return async ({ collection, doc, filename, req }) => {
1314
try {
1415
const video = getVideoFromDoc(doc, filename)
1516

17+
let fileUrl: null | string = null
18+
if (!video && purge && purge.enabled) {
19+
fileUrl = await getGenerateURL({ storage, stream })({
20+
collection,
21+
data: doc,
22+
filename,
23+
prefix: doc.prefix || '',
24+
})
25+
}
26+
1627
if (stream && video) {
1728
await ky.delete(
1829
`https://video.bunnycdn.com/library/${stream.libraryId}/videos/${video.videoId}`,
@@ -37,6 +48,10 @@ export const getHandleDelete = ({ storage, stream }: BunnyAdapterOptions): Handl
3748
timeout: 120000,
3849
},
3950
)
51+
52+
if (purge && purge.enabled && fileUrl) {
53+
await purgeBunnyCache(fileUrl, purge, req)
54+
}
4055
}
4156
} catch (err) {
4257
if (err instanceof HTTPError) {

src/handleUpload.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { APIError } from 'payload'
66

77
import type { BunnyAdapterOptions } from './types.js'
88

9-
import { getStorageUrl } from './utils.js'
9+
import { getGenerateURL } from './generateURL.js'
10+
import { getStorageUrl, purgeBunnyCache } from './utils.js'
1011

1112
type Args = { prefix?: string } & BunnyAdapterOptions
1213

13-
export const getHandleUpload = ({ prefix, storage, stream }: Args): HandleUpload => {
14-
return async ({ data, file, req }) => {
14+
export const getHandleUpload = ({ prefix, purge, storage, stream }: Args): HandleUpload => {
15+
return async ({ collection, data, file, req }) => {
1516
data.url = null
1617
data.thumbnailURL = null
1718

@@ -57,6 +58,11 @@ export const getHandleUpload = ({ prefix, storage, stream }: Args): HandleUpload
5758

5859
data.filename = fileName
5960
data.bunnyVideoId = null
61+
62+
if (purge && purge.enabled) {
63+
const url = await getGenerateURL({ storage, stream })({ collection, data, filename: fileName, prefix: prefix || '' })
64+
await purgeBunnyCache(url, purge, req)
65+
}
6066
}
6167

6268
return data

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export type AdminThumbnailOptions = {
88

99
export type BunnyAdapterOptions = {
1010
adminThumbnail?: AdminThumbnailOptions | boolean
11+
purge?: {
12+
apiKey: string
13+
async?: boolean
14+
enabled: boolean
15+
}
1116
storage: {
1217
apiKey: string
1318
hostname: string

src/utils.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import type { TypeWithID } from 'payload'
1+
import type { PayloadRequest, TypeWithID } from 'payload'
22

3-
import type { BunnyStorageOptions, BunnyVideoMeta } from './types.js'
3+
import ky, { HTTPError } from 'ky'
4+
5+
import type { BunnyAdapterOptions, BunnyStorageOptions, BunnyVideoMeta } from './types.js'
46

57
export const getStorageUrl = (region: string | undefined) => {
68
if (!region) {
@@ -42,6 +44,14 @@ export const validateOptions = (
4244
errors.push('Hostname in storage settings cannot contain "storage.bunnycdn.com"')
4345
}
4446

47+
if (storageOptions.options.purge) {
48+
const { purge } = storageOptions.options
49+
50+
if (purge.enabled && !purge.apiKey) {
51+
errors.push('When purge is enabled, an API key must be provided')
52+
}
53+
}
54+
4555
if (storageOptions.options.stream) {
4656
const collectionsWithAccessControl = Object.entries(storageOptions.collections).filter(([_, collection]) =>
4757
typeof collection === 'object' &&
@@ -64,4 +74,53 @@ export const validateOptions = (
6474
`Bunny Storage configuration error: ${errors.join('; ')}. Please refer to the documentation: https://github.com/maximseshuk/payload-storage-bunny`,
6575
)
6676
}
77+
}
78+
79+
export const purgeBunnyCache = async (
80+
url: string,
81+
options: BunnyAdapterOptions['purge'],
82+
req?: PayloadRequest,
83+
): Promise<boolean> => {
84+
if (!options || !options.enabled) {
85+
return false
86+
}
87+
88+
try {
89+
await ky.post('https://api.bunny.net/purge', {
90+
headers: {
91+
AccessKey: options.apiKey,
92+
},
93+
searchParams: {
94+
async: options.async || false,
95+
url,
96+
},
97+
timeout: 30000,
98+
})
99+
100+
return true
101+
} catch (err) {
102+
if (req) {
103+
if (err instanceof HTTPError) {
104+
const errorResponse = await err.response.text()
105+
106+
req.payload.logger.error({
107+
action: 'Cache purge',
108+
error: {
109+
response: errorResponse,
110+
status: err.response.status,
111+
statusText: err.response.statusText,
112+
},
113+
url,
114+
})
115+
} else {
116+
req.payload.logger.error({
117+
action: 'Cache purge',
118+
error: err,
119+
url,
120+
})
121+
}
122+
}
123+
124+
return false
125+
}
67126
}

0 commit comments

Comments
 (0)