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

@@ -0,0 +1,2 @@
# Default; ci-deploy.sh (blue/green) herschrijft dit bestand bij elke switch.
server 127.0.0.1:3002;
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
types {
text/html html htm shtml;
text/css css;
text/xml xml;
text/plain txt;
application/javascript js mjs;
application/json json map;
application/ld+json jsonld;
application/rss+xml rss;
application/wasm wasm;
application/xml xsd xsl;
font/ttf ttf;
font/otf otf;
font/woff woff;
font/woff2 woff2;
image/svg+xml svg svgz;
image/bmp bmp;
image/gif gif;
image/jpeg jpeg jpg;
image/png png;
image/webp webp;
image/avif avif;
image/x-icon ico cur;
video/mp4 mp4 m4v;
video/webm webm;
audio/mpeg mp3;
audio/ogg ogg;
audio/wav wav;
application/octet-stream dat bin swf;
application/zip zip;
application/gzip gz;
application/pdf pdf;
application/vnd.apple.mpegurl m3u8;
}
+49
View File
@@ -0,0 +1,49 @@
# Canonical nginx config for the EpicNabbo CMS edge.
# Source of truth: repository deployment/proxy/nginx-cms.conf (the site block)
# and this file. Installed/synced by scripts/nginx-sync.sh so it cannot be
# lost again while nginx keeps running on an in-memory copy.
#
# Traffic path: Cloudflare -> Traefik (:443) -> nginx (:9443) -> CMS (:3002).
# nginx is the last layer that can still rewrite Cache-Control, so it owns the
# headers it adds explicitly; everything proxied to the CMS is passed through
# untouched unless this file says otherwise.
user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 2048;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Compression is done once, at the edge (Traefik / Cloudflare). Enabling
# gzip here too would double-compress proxied responses and fight Vary.
gzip off;
sendfile on;
tcp_nopush on;
server_tokens off;
keepalive_timeout 30s;
client_max_body_size 64m;
client_body_buffer_size 16k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Blue/green cutover: ci-deploy.sh writes the active upstream here, and
# `proxy_pass http://cms_app` below follows it via graceful nginx -s reload.
upstream cms_app {
include /etc/nginx/snippets/cms_upstream_servers.conf;
}
# Cache policy maps and server blocks live in the site file so they are
# synced together and can never drift apart.
include /etc/nginx/sites-enabled/*.conf;
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Purge the Cloudflare edge cache for one or more Cache-Tags.
#
# These tags are emitted by nginx (deployment/proxy/nginx-cms.conf):
# cms-public - de publieke API-allowlist (staff/teams/guilds/shop/values/…)
# cms-gamedata - /gamedata/ (furnidata, config)
# cms-client - /client/ + /nitro-client/ (game assets)
# cms-camera - /camera/
#
# Usage:
# scripts/cf-purge.sh cms-public
# scripts/cf-purge.sh cms-public cms-gamedata cms-client cms-camera
#
# Reads CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID from the environment or the
# repository .env. Fails loudly with a clear message when they are missing or
# still placeholders, so a pipeline either purges or aborts — never silently
# pretends it did.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env"
BASE="https://api.cloudflare.com/client/v4"
[[ $# -ge 1 ]] || { echo "usage: $0 <tag> [tag ...]" >&2; exit 64; }
TAGS=("$@")
load_env() {
local name="$1"
if [[ -n "${!name:-}" ]]; then
printf -v "$name" '%s' "${!name}"
return 0
fi
if [[ -f "$ENV_FILE" ]]; then
local line
line="$(grep -m1 "^$name=" "$ENV_FILE" | cut -d= -f2- | tr -d "'\"")" || true
if [[ -n "$line" ]]; then
printf -v "$name" '%s' "$line"
return 0
fi
fi
return 1
}
load_env CLOUDFLARE_API_TOKEN || { echo "error: CLOUDFLARE_API_TOKEN not configured" >&2; exit 1; }
load_env CLOUDFLARE_ZONE_ID || { echo "error: CLOUDFLARE_ZONE_ID not configured" >&2; exit 1; }
# Placeholder guard: the repo .env historically carried 2-char dummy values.
if [[ "${#CLOUDFLARE_API_TOKEN}" -lt 16 || "${#CLOUDFLARE_ZONE_ID}" -lt 16 ]]; then
echo "error: Cloudflare credentials look like placeholders; add a real token to .env" >&2
exit 1
fi
body="$(python3 -c 'import json,sys; print(json.dumps({"tags": sys.argv[1:]}))' "${TAGS[@]}")"
resp="$(curl -sS -m 20 -X POST \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data "$body" \
"$BASE/zones/$CLOUDFLARE_ZONE_ID/purge_cache")"
if ! python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("success") else 1)' <<<"$resp"; then
echo "error: Cloudflare purge failed: $resp" >&2
exit 1
fi
echo "purged tags: ${TAGS[*]}"
+125
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -396,6 +396,12 @@ if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" ||
echo "Deployment verified: $sha" echo "Deployment verified: $sha"
# Een deploy kan de game client, furnidata (gamedata), camera en public API data
# verversen. Laat de Cloudflare edge-cache van die tags los (best-effort: alleen
# wanneer er een echte token + zone-id geconfigureerd is; no-op anders).
if [ -x "$deploy_dir/scripts/cf-purge.sh" ]; then
bash "$deploy_dir/scripts/cf-purge.sh" cms-public cms-gamedata cms-client cms-camera || true
fi
# Retain the current and previous releases; do not remove arbitrary named tags. # Retain the current and previous releases; do not remove arbitrary named tags.
while IFS= read -r tag; do while IFS= read -r tag; do
if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then
+110
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -13,6 +13,7 @@ import {
Items, Items,
Rooms, Rooms,
} from "@/lib/db"; } from "@/lib/db";
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -89,6 +90,7 @@ export async function disbandGuild(formData: FormData): Promise<void> {
targetId: id, targetId: id,
}); });
revalidatePath("/admin/guilds"); revalidatePath("/admin/guilds");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild disbanded");
} }
export async function updateGuild(formData: FormData): Promise<void> { export async function updateGuild(formData: FormData): Promise<void> {
@@ -151,4 +153,5 @@ export async function updateGuild(formData: FormData): Promise<void> {
revalidatePath("/admin/guilds"); revalidatePath("/admin/guilds");
revalidatePath(`/admin/guilds/${id}`); revalidatePath(`/admin/guilds/${id}`);
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild updated");
} }
+2
View File
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
import { CameraWeb, db } from "@/lib/db"; import { CameraWeb, db } from "@/lib/db";
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -33,4 +34,5 @@ export async function deletePhoto(formData: FormData): Promise<void> {
revalidatePath("/admin/photos"); revalidatePath("/admin/photos");
revalidatePath("/photos"); revalidatePath("/photos");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "photo deleted");
} }
+7
View File
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db"; import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
@@ -34,6 +35,7 @@ export async function createCategory(formData: FormData): Promise<void> {
// Unique name collision or DB error — ignore, page will re-render unchanged. // Unique name collision or DB error — ignore, page will re-render unchanged.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
} }
export async function deleteCategory(formData: FormData): Promise<void> { export async function deleteCategory(formData: FormData): Promise<void> {
@@ -53,6 +55,7 @@ export async function deleteCategory(formData: FormData): Promise<void> {
// Not found or DB error — ignore. // Not found or DB error — ignore.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
} }
export async function createValue(formData: FormData): Promise<void> { export async function createValue(formData: FormData): Promise<void> {
@@ -101,6 +104,7 @@ export async function createValue(formData: FormData): Promise<void> {
// DB error — ignore. // DB error — ignore.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
} }
export async function deleteValue(formData: FormData): Promise<void> { export async function deleteValue(formData: FormData): Promise<void> {
@@ -114,6 +118,7 @@ export async function deleteValue(formData: FormData): Promise<void> {
// Not found or DB error — ignore. // Not found or DB error — ignore.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
} }
export async function updateCategory(formData: FormData): Promise<void> { export async function updateCategory(formData: FormData): Promise<void> {
@@ -145,6 +150,7 @@ export async function updateCategory(formData: FormData): Promise<void> {
// Unique name collision or DB error — ignore. // Unique name collision or DB error — ignore.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
} }
export async function updateValue(formData: FormData): Promise<void> { export async function updateValue(formData: FormData): Promise<void> {
@@ -199,4 +205,5 @@ export async function updateValue(formData: FormData): Promise<void> {
// DB error — ignore. // DB error — ignore.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
} }
+3
View File
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteShopArticles } from "@/lib/db"; import { db, WebsiteShopArticles } from "@/lib/db";
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
@@ -149,6 +150,7 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
} }
revalidatePath(`/admin/shop/${id}`); revalidatePath(`/admin/shop/${id}`);
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop article updated");
redirect("/admin/shop"); redirect("/admin/shop");
} }
@@ -175,5 +177,6 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
return; return;
} }
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop article deleted");
redirect("/admin/shop"); redirect("/admin/shop");
} }
+3
View File
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteTeams } from "@/lib/db"; import { db, WebsiteTeams } from "@/lib/db";
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
export async function createTeam(formData: FormData): Promise<void> { export async function createTeam(formData: FormData): Promise<void> {
@@ -38,6 +39,7 @@ export async function createTeam(formData: FormData): Promise<void> {
}); });
revalidatePath("/admin/teams"); revalidatePath("/admin/teams");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "team edited");
} }
export async function deleteTeam(formData: FormData): Promise<void> { export async function deleteTeam(formData: FormData): Promise<void> {
@@ -47,4 +49,5 @@ export async function deleteTeam(formData: FormData): Promise<void> {
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id)); await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
revalidatePath("/admin/teams"); revalidatePath("/admin/teams");
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "team edited");
} }
+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( async function createIpRule(
ip: string, ip: string,
ttlSeconds: number, ttlSeconds: number,
+60
View File
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const purgeCacheByTags = vi.fn();
const cloudflareEnabled = vi.fn();
vi.mock("@/lib/cloudflare-api", () => ({
purgeCacheByTags: (...args: unknown[]) => purgeCacheByTags(...args),
cloudflareEnabled: (...args: unknown[]) => cloudflareEnabled(...args),
}));
import {
EDGE_CACHE_TAGS,
purgeEdgeCache,
resetEdgeCacheCoalescing,
} from "@/lib/edge-cache";
describe("purgeEdgeCache", () => {
beforeEach(() => {
vi.resetAllMocks();
resetEdgeCacheCoalescing();
});
it("is a no-op when Cloudflare is not configured", async () => {
cloudflareEnabled.mockReturnValue(false);
await purgeEdgeCache(["cms-public"], "test");
expect(purgeCacheByTags).not.toHaveBeenCalled();
});
it("purges the requested tags when enabled", async () => {
cloudflareEnabled.mockReturnValue(true);
await purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop edited");
expect(purgeCacheByTags).toHaveBeenCalledExactlyOnceWith(["cms-public"]);
});
it("coalesces repeated purges of the same tag within 30s", async () => {
cloudflareEnabled.mockReturnValue(true);
await purgeEdgeCache(["cms-public"], "first");
await purgeEdgeCache(["cms-public"], "second");
expect(purgeCacheByTags).toHaveBeenCalledTimes(1);
expect(purgeCacheByTags).toHaveBeenCalledWith(["cms-public"]);
});
it("purges again after the coalescing window has passed", async () => {
cloudflareEnabled.mockReturnValue(true);
await purgeEdgeCache(["cms-gamedata"], "first");
await purgeEdgeCache(["cms-gamedata"], "second");
expect(purgeCacheByTags).toHaveBeenCalledTimes(1);
resetEdgeCacheCoalescing();
await purgeEdgeCache(["cms-gamedata"], "third");
expect(purgeCacheByTags).toHaveBeenCalledTimes(2);
});
it("never throws on an upstream failure", async () => {
cloudflareEnabled.mockReturnValue(true);
purgeCacheByTags.mockRejectedValue(new Error("boom"));
await expect(
purgeEdgeCache(["cms-public"], "fails"),
).resolves.toBeUndefined();
});
});
+77
View File
@@ -0,0 +1,77 @@
import "server-only";
import { cloudflareEnabled, purgeCacheByTags } from "@/lib/cloudflare-api";
import { logger } from "@/lib/logger";
/**
* Edge-cache purging for the Cloudflare layer.
*
* nginx tags public responses with `Cache-Tag` (cms-public, cms-gamedata,
* cms-client, cms-camera) and a Cloudflare Cache Rule stores them at the
* edge. Whenever the CMS edits that data (catalog, roster, shop, …) the
* matching tag must be purged or visitors keep seeing the stale edge copy
* until the s-maxage expires. That purge happens here.
*
* The helper is deliberately small and safe:
* - no-op when Cloudflare is not configured (CLOUDFLARE_API_TOKEN /
* CLOUDFLARE_ZONE_ID absent or placeholder),
* - callers never await it (`void purgeEdgeCache(...)`),
* - purges of the same tag are coalesced to at most one per 30s so a burst
* of edits (a catalog import touches hundreds of rows) produces one
* request instead of hundreds,
* - any API failure is logged and swallowed — the origin stays fresh and
* the stale window is bounded by the endpoint's s-maxage.
*/
/** The tags nginx sets in `Cache-Tag` (deployment/proxy/nginx-cms.conf). */
export const EDGE_CACHE_TAGS = {
public: "cms-public",
gamedata: "cms-gamedata",
client: "cms-client",
camera: "cms-camera",
} as const;
const COALESCE_MS = 30_000;
const recentPurges = new Map<string, number>();
/** True when at least one of `tags` is worth purging right now. */
export function shouldPurge(
tags: string[],
now: number = Date.now(),
): string[] {
return tags.filter((tag) => (recentPurges.get(tag) ?? 0) + COALESCE_MS < now);
}
/**
* Best-effort Cloudflare edge purge for the given tags. Never throws and
* never blocks the caller. Safe to call on every admin mutation path.
*/
export async function purgeEdgeCache(
tags: string[],
reason: string,
): Promise<void> {
if (!cloudflareEnabled()) return;
const pending = shouldPurge(tags);
if (pending.length === 0) return;
try {
await purgeCacheByTags(pending);
const now = Date.now();
for (const tag of pending) recentPurges.set(tag, now);
logger.info("[edge-cache] Cloudflare purge sent", {
tags: pending,
reason,
});
} catch (error) {
// A failed purge is not fatal: the freshness bound is s-maxage anyway.
logger.warn("[edge-cache] Cloudflare purge failed", {
tags: pending,
reason,
err: error,
});
}
}
/** Test hook only — drop in-process coalescing state between unit runs. */
export function resetEdgeCacheCoalescing(): void {
recentPurges.clear();
}
+10
View File
@@ -73,6 +73,16 @@ export async function scheduleCatalogExport() {
after(async () => { after(async () => {
const { runCatalogExport } = await import("./catalog-git-export"); const { runCatalogExport } = await import("./catalog-git-export");
await runCatalogExport(); await runCatalogExport();
// Catalog + furnidata (gamedata) zijn net vers verwerkt: laat de
// Cloudflare edge-cache van die tags los. Fire-and-forget en
// no-op zonder token; s-maxage blijft de fallback.
const { EDGE_CACHE_TAGS, purgeEdgeCache } = await import(
"@/lib/edge-cache"
);
void purgeEdgeCache(
[EDGE_CACHE_TAGS.public, EDGE_CACHE_TAGS.gamedata],
"catalog-export",
);
}); });
} catch { } catch {
/* Standalone worker contexts use their scheduled retry. */ /* Standalone worker contexts use their scheduled retry. */
+1 -1
View File
@@ -78,6 +78,6 @@ export const proxy = async (req: import("next/server").NextRequest) => {
export const config = { export const config = {
matcher: [ matcher: [
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging).*)", "/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)",
], ],
}; };