feat(api): purge Cloudflare CDN before and after OpenAPI spec upload (#704)

* feat(api): purge Cloudflare CDN before and after OpenAPI spec upload

Purge api.cdn.clsrio.com edge cache for openapi-latest and the dated
spec URL so deploys are not blocked by the 1h Cache-Control TTL on R2
objects. Uses the same CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID
pattern as embed uploads; skips when credentials are unset.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(api): purge OpenAPI CDN cache only after upload

Match the embeds upload pattern: update R2 first, then invalidate
edge cache so clients refetch the new spec.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(api): treat Cloudflare purge 2xx as success when body is unparseable

Avoid false upload failures when the purge API returns 200 with a
non-JSON body. Addresses Greptile review on #704.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-02 12:11:00 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent ec61bfe7d9
commit efdcfba68d
3 changed files with 84 additions and 2 deletions
+68 -2
View File
@@ -9,7 +9,65 @@ import { app } from '../src/app';
import { env } from '@cio/core/config/env';
import { generateSpecs } from 'hono-openapi';
import { join } from 'path';
import { enrichPublicApiOpenApiSpec } from '@cio/utils/openapi/public-api';
import { enrichPublicApiOpenApiSpec, PUBLIC_API_OPENAPI_URL } from '@cio/utils/openapi/public-api';
const OPENAPI_CDN_BASE_URL = PUBLIC_API_OPENAPI_URL.replace(/\/openapi-latest\.json$/, '');
/** Cloudflare allows a limited number of URLs per purge request. */
const PURGE_URL_BATCH = 30;
/**
* Purge Cloudflare edge cache for public OpenAPI spec URLs.
* R2 is origin storage; purging fixes stale responses when the hostname is proxied through Cloudflare.
*/
async function purgeCloudflareCdnForUrls(urls: string[]): Promise<void> {
if (process.env.OPENAPI_SKIP_CDN_PURGE === '1' || process.env.OPENAPI_SKIP_CDN_PURGE === 'true') {
console.log('Skipping Cloudflare CDN purge (OPENAPI_SKIP_CDN_PURGE is set).');
return;
}
const token = process.env.CLOUDFLARE_API_TOKEN;
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
if (!token || !zoneId) {
console.log(
'Skipping Cloudflare CDN cache purge. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID to purge after upload.'
);
return;
}
if (urls.length === 0) {
return;
}
const uniqueUrls = [...new Set(urls)];
for (let index = 0; index < uniqueUrls.length; index += PURGE_URL_BATCH) {
const batch = uniqueUrls.slice(index, index + PURGE_URL_BATCH);
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ files: batch })
});
const payload: unknown = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`Cloudflare CDN purge failed: ${response.status} ${JSON.stringify(payload)}`);
}
console.log(`Purged Cloudflare CDN cache for ${batch.length} URL(s).`);
}
}
function getOpenApiCdnUrls(uploadKey: string): string[] {
const datedFileName = uploadKey.split('/').pop()!;
return [PUBLIC_API_OPENAPI_URL, `${OPENAPI_CDN_BASE_URL}/${datedFileName}`];
}
interface UploadOptions {
key: string;
@@ -152,18 +210,21 @@ class OpenAPISpecGenerator {
if (this.s3Client) {
const uploadKey = `openapi/public-api/openapi-${new Date().toISOString().split('T')[0]}.json`;
const cdnUrls = getOpenApiCdnUrls(uploadKey);
await this.uploadToR2({
key: uploadKey,
content: spec,
contentType: 'application/json'
});
// Also upload as latest version
await this.uploadToR2({
key: 'openapi/public-api/openapi-latest.json',
content: spec,
contentType: 'application/json'
});
await purgeCloudflareCdnForUrls(cdnUrls);
}
console.log('🎉 OpenAPI spec generation and upload completed successfully!');
@@ -189,6 +250,11 @@ Environment Variables Required for R2 Upload:
CLOUDFLARE_ACCESS_KEY
CLOUDFLARE_SECRET_ACCESS_KEY
CLOUDFLARE_ACCOUNT_ID
Optional — purge Cloudflare CDN cache for api.cdn.clsrio.com (after upload):
CLOUDFLARE_API_TOKEN (Zone → Cache Purge → Purge)
CLOUDFLARE_ZONE_ID
OPENAPI_SKIP_CDN_PURGE=1 to skip purge
`);
return;
}