Merge pull request #124 from misenhower/codex/r2-json-cache-purge

Add JSON cache purging after R2 uploads
This commit is contained in:
Matt Isenhower
2026-09-26 09:59:18 -07:00
committed by GitHub
7 changed files with 240 additions and 4 deletions

View File

@@ -48,6 +48,12 @@ R2_BUCKET=splatoon3-ink-assets
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
# Optional JSON edge caching. Deploy and verify purging before enabling cache rules.
# Token permission: Zone > Cache Purge > Purge, restricted to splatoon3.ink.
CLOUDFLARE_CACHE_PURGE_ZONE_ID=
CLOUDFLARE_CACHE_PURGE_API_TOKEN=
CLOUDFLARE_CACHE_PURGE_HOSTS=splatoon3.ink,assets.splatoon3.ink,dev.splatoon3.ink
# Twitter API parameters
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=

View File

@@ -0,0 +1,72 @@
import { setTimeout as sleep } from 'node:timers/promises';
export default class CloudflareCachePurger
{
constructor({ zoneId, apiToken, hosts, fetch: fetcher = globalThis.fetch, wait = sleep }) {
if (!/^[a-f0-9]{32}$/.test(zoneId ?? '') || !apiToken || !hosts?.length) {
throw new Error('Cache purging requires a zone ID, API token, and public hosts.');
}
if (hosts.some(host => !/^[a-z0-9.-]+$/.test(host)) || hosts.length > 100) {
throw new Error('Cache purge hosts must be hostnames without schemes or paths (at most 100).');
}
this.zoneId = zoneId;
this.apiToken = apiToken;
this.hosts = [...new Set(hosts)];
this.fetch = fetcher;
this.wait = wait;
}
async purgeData() {
// Prefix purges cover query-string variants and also recover a previous failed
// purge when the next sync has no changed objects. Images remain cached.
const body = JSON.stringify({
prefixes: this.hosts.map(host => `${host}/data/`),
});
for (let attempt = 0; attempt < 3; attempt++) {
let retryDelay = 1000 * 2 ** attempt;
let failure;
try {
const response = await this.fetch(
`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/purge_cache`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
body,
signal: AbortSignal.timeout(10_000),
},
);
const result = await response.json();
if (response.ok && result.success === true) return;
failure = new Error(`Cloudflare cache purge failed (HTTP ${response.status}).`);
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
throw Object.assign(failure, { permanent: true });
}
const retryAfter = Number(response.headers.get('Retry-After'));
if (Number.isFinite(retryAfter) && retryAfter > 0) {
retryDelay = Math.min(retryAfter * 1000, 30_000);
}
} catch (error) {
if (error.permanent) throw error;
// Do not expose response bodies or request credentials in logs.
failure = new Error('Cloudflare cache purge request failed.');
}
if (attempt === 2) throw failure;
await this.wait(retryDelay);
}
}
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import CloudflareCachePurger from './CloudflareCachePurger.mjs';
function setup(responses) {
const fetch = vi.fn();
for (const response of responses) fetch.mockResolvedValueOnce(response);
const wait = vi.fn();
const purger = new CloudflareCachePurger({
zoneId: 'a'.repeat(32),
apiToken: 'test-token',
hosts: ['splatoon3.ink', 'assets.splatoon3.ink'],
fetch,
wait,
});
return { fetch, wait, purger };
}
describe('Cloudflare cache purging', () => {
it('purges only the data prefixes on each public host', async () => {
const { fetch, purger } = setup([Response.json({ success: true })]);
await purger.purgeData();
const [url, options] = fetch.mock.calls[0];
expect(url).toBe(`https://api.cloudflare.com/client/v4/zones/${'a'.repeat(32)}/purge_cache`);
expect(options.method).toBe('POST');
expect(options.headers.Authorization).toBe('Bearer test-token');
expect(JSON.parse(options.body)).toEqual({
prefixes: ['splatoon3.ink/data/', 'assets.splatoon3.ink/data/'],
});
});
it('retries rate limits and honors Retry-After', async () => {
const { fetch, wait, purger } = setup([
Response.json({ success: false }, { status: 429, headers: { 'Retry-After': '12' } }),
Response.json({ success: true }),
]);
await purger.purgeData();
expect(fetch).toHaveBeenCalledTimes(2);
expect(wait).toHaveBeenCalledWith(12_000);
});
it('reports permanent authentication failure without retrying or leaking response details', async () => {
const { fetch, purger } = setup([
Response.json({ success: false, errors: ['sensitive detail'] }, { status: 403 }),
]);
await expect(purger.purgeData()).rejects.toThrow('Cloudflare cache purge failed (HTTP 403).');
expect(fetch).toHaveBeenCalledOnce();
});
it('rejects an unsuccessful API result even with HTTP 200', async () => {
const { fetch, purger } = setup(Array.from({ length: 3 }, () => Response.json({ success: false })));
await expect(purger.purgeData()).rejects.toThrow('Cloudflare cache purge failed');
expect(fetch).toHaveBeenCalledTimes(3);
});
it('retries network failures with a bounded attempt count', async () => {
const { fetch, purger } = setup([]);
fetch.mockRejectedValue(new Error('network failure with sensitive details'));
await expect(purger.purgeData()).rejects.toThrow('Cloudflare cache purge request failed.');
expect(fetch).toHaveBeenCalledTimes(3);
});
});

View File

@@ -17,21 +17,29 @@ function isPublicData(key) {
export default class R2Syncer
{
constructor({ config = {}, localPath, s3Client, syncClient } = {}) {
constructor({ config = {}, localPath, s3Client, syncClient, cachePurger } = {}) {
this.config = config;
this._s3Client = s3Client;
this._syncClient = syncClient;
this._localPath = localPath;
this.cachePurger = cachePurger;
}
async upload() {
this.log('Uploading files...');
return this.syncClient.sync(this.localPath, this.publicBucket, {
const result = await this.syncClient.sync(this.localPath, this.publicBucket, {
filters: this.filters,
relocations: this.relocations,
commandInput: input => this.commandInput(input),
});
if (this.cachePurger) {
await this.cachePurger.purgeData();
this.log('Purged public data caches.');
}
return result;
}
commandInput(input) {
@@ -42,6 +50,10 @@ export default class R2Syncer
: undefined,
};
if (this.cachePurger && input.Key.startsWith('data/') && input.Key.endsWith('.json')) {
result.CacheControl = 'public, max-age=0, s-maxage=60, must-revalidate';
}
return result;
}

View File

@@ -3,7 +3,7 @@ import os from 'node:os';
import fs from 'node:fs/promises';
import { ListObjectsV2Command, PutObjectCommand } from '@aws-sdk/client-s3';
import { S3SyncClient } from 's3-sync-client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import R2Syncer from './R2Syncer.mjs';
class FakeSyncClient
@@ -82,6 +82,48 @@ describe('R2Syncer', () => {
})));
});
it('purges after uploads complete, including syncs with no changes', async () => {
const order = [];
const syncClient = {
sync: async () => {
order.push('uploaded');
return { created: [], updated: [], deleted: [] };
},
};
const cachePurger = { purgeData: async () => order.push('purged') };
const syncer = new R2Syncer({ config, syncClient, cachePurger });
await syncer.upload();
await syncer.upload();
expect(order).toEqual(['uploaded', 'purged', 'uploaded', 'purged']);
expect(syncer.commandInput({ Key: 'data/schedules.json' }).CacheControl)
.toBe('public, max-age=0, s-maxage=60, must-revalidate');
expect(syncer.commandInput({ Key: 'splatnet/image.png' }).CacheControl).toBeUndefined();
});
it('does not report success when purging fails', async () => {
const syncer = new R2Syncer({
config,
syncClient: new FakeSyncClient,
cachePurger: { purgeData: async () => { throw new Error('purge failed'); } },
});
await expect(syncer.upload()).rejects.toThrow('purge failed');
});
it('does not purge before a failed upload batch has completed', async () => {
const purgeData = vi.fn();
const syncer = new R2Syncer({
config,
syncClient: { sync: async () => { throw new Error('upload failed'); } },
cachePurger: { purgeData },
});
await expect(syncer.upload()).rejects.toThrow('upload failed');
expect(purgeData).not.toHaveBeenCalled();
});
it('uploads only public data and generated images with canonical R2 keys', async () => {
let syncClient = new FakeSyncClient;
let syncer = new R2Syncer({ config, syncClient });

View File

@@ -1,5 +1,20 @@
import S3Syncer from './S3Syncer.mjs';
import R2Syncer from './R2Syncer.mjs';
import CloudflareCachePurger from './CloudflareCachePurger.mjs';
function cachePurger() {
const zoneId = process.env.CLOUDFLARE_CACHE_PURGE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_CACHE_PURGE_API_TOKEN;
if (!zoneId && !apiToken) return;
return new CloudflareCachePurger({
zoneId,
apiToken,
hosts: (process.env.CLOUDFLARE_CACHE_PURGE_HOSTS ?? '')
.split(',').map(host => host.trim()).filter(Boolean),
});
}
export function canSyncS3() {
return !!(
@@ -28,13 +43,18 @@ export function canUpload() {
}
export async function upload() {
// Validate purge settings before starting uploads to either destination.
const purger = canUploadR2() ? cachePurger() : undefined;
let uploads = [];
if (canSyncS3()) {
uploads.push((new S3Syncer).upload());
}
if (canUploadR2()) {
uploads.push((new R2Syncer({ config: r2Configuration() })).upload());
uploads.push((new R2Syncer({
config: r2Configuration(),
cachePurger: purger,
})).upload());
}
if (uploads.length === 0) {

View File

@@ -43,6 +43,9 @@ const environmentKeys = [
'R2_BUCKET',
'R2_ENDPOINT',
'SITE_URL',
'CLOUDFLARE_CACHE_PURGE_ZONE_ID',
'CLOUDFLARE_CACHE_PURGE_API_TOKEN',
'CLOUDFLARE_CACHE_PURGE_HOSTS',
];
function configureLegacy() {
@@ -105,4 +108,14 @@ describe('sync orchestration', () => {
expect(mocks.legacyUpload).not.toHaveBeenCalled();
expect(mocks.r2Upload).toHaveBeenCalledOnce();
});
it('rejects incomplete purge credentials before publishing anything', async () => {
configureLegacy();
configureR2();
process.env.CLOUDFLARE_CACHE_PURGE_ZONE_ID = 'a'.repeat(32);
await expect(upload()).rejects.toThrow('Cache purging requires');
expect(mocks.legacyUpload).not.toHaveBeenCalled();
expect(mocks.r2Upload).not.toHaveBeenCalled();
});
});