mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-10-23 21:53:27 -05:00
Merge pull request #124 from misenhower/codex/r2-json-cache-purge
Add JSON cache purging after R2 uploads
This commit is contained in:
@@ -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=
|
||||
|
||||
72
app/sync/CloudflareCachePurger.mjs
Normal file
72
app/sync/CloudflareCachePurger.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/sync/CloudflareCachePurger.test.mjs
Normal file
71
app/sync/CloudflareCachePurger.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user