feat(cache): single-owner caching across nginx, edge and content edits
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m41s
CI / tests-ui (push) Successful in 2m27s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m35s

Rebuild production nginx from the repo (deployment/proxy/*) with a single
Cache-Control owner per route: the app stays the source, nginx only manages
headers, and Cloudflare stores the public API allowlist at the edge.

- deployment/proxy: nginx.conf, mime.types, nginx-cms.conf and the
  blue/green upstream snippet; config backed by scripts/nginx-sync.sh
  (idempotent install + reload, --check/--force).
- nginx serves Cache-Tag headers on the public allowlist (cms-public),
  gamedata, client and camera responses so the edge and purge stay in sync.
- src/lib/edge-cache.ts + tests: coalesced, fire-and-forget edge purges that
  no-op unless Cloudflare is configured; scripts/cf-purge.sh and
  cf-setup-cache.sh create and purge the cache rule.
- src/lib/cloudflare-api.ts: purgeCacheByTags/purgeCacheByUrls.
- Purge hooks after catalog exports (public + gamedata) and on shop, team,
  guild, photo and rare-values edits; ci-deploy purges after each release.
- src/proxy.ts excludes the imaging/images docs from the middleware matcher.
This commit is contained in:
openhands committed 2026-09-28 21:55:18 +02:00
1 parent 30dcecd530
commit 7697728d07
18 files changed
+1001 -1

No files matched your search

+57
View File
@@ -165,6 +165,63 @@ export async function verifyCloudflareConnection(): Promise<CloudflareConnection
}
}
/**
* Purge specific URLs from the Cloudflare edge cache (POST purge_cache).
* At most 30 URLs are allowed per call by the API; split larger batches.
*/
export async function purgeCacheByUrls(
urls: string[],
): Promise<{ purged: number }> {
const config = getCloudflareApiConfig();
if (!config.zoneId) {
throw new CloudflareApiError("CLOUDFLARE_ZONE_ID is not configured");
}
const zoneId: string = config.zoneId;
if (urls.length === 0) return { purged: 0 };
const envelopes = await Promise.all(
chunk(urls, 30).map((files) =>
cloudflareRequest<{ id: string }>(
`/zones/${encodeURIComponent(zoneId)}/purge_cache`,
{ method: "POST", body: { files } },
),
),
);
return { purged: envelopes.length };
}
/**
* Purge every cached response that carried one of the given Cache-Tag values
* (the tags nginx emits in the `Cache-Tag` header). This is the cheap, exact
* way to drop the public API / gamedata / client edge cache after a CMS edit
* or deploy, without touching unrelated cached objects.
*/
export async function purgeCacheByTags(
tags: string[],
): Promise<{ purged: number }> {
const config = getCloudflareApiConfig();
if (!config.zoneId) {
throw new CloudflareApiError("CLOUDFLARE_ZONE_ID is not configured");
}
const zoneId: string = config.zoneId;
if (tags.length === 0) return { purged: 0 };
const envelopes = await Promise.all(
chunk(tags, 30).map((tagGroup) =>
cloudflareRequest<{ id: string }>(
`/zones/${encodeURIComponent(zoneId)}/purge_cache`,
{ method: "POST", body: { tags: tagGroup } },
),
),
);
return { purged: envelopes.length };
}
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size)
out.push(items.slice(i, i + size));
return out;
}
async function createIpRule(
ip: string,
ttlSeconds: number,