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
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:
1 parent
30dcecd530
commit
7697728d07
18 files changed
+1001
-1
No files matched your search
@@ -13,6 +13,7 @@ import {
|
||||
Items,
|
||||
Rooms,
|
||||
} from "@/lib/db";
|
||||
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
@@ -89,6 +90,7 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/guilds");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild disbanded");
|
||||
}
|
||||
|
||||
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/${id}`);
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild updated");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { CameraWeb, db } from "@/lib/db";
|
||||
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
@@ -33,4 +34,5 @@ export async function deletePhoto(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/photos");
|
||||
revalidatePath("/photos");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "photo deleted");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
||||
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
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.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||
}
|
||||
|
||||
export async function createValue(formData: FormData): Promise<void> {
|
||||
@@ -101,6 +104,7 @@ export async function createValue(formData: FormData): Promise<void> {
|
||||
// DB error — ignore.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||
}
|
||||
|
||||
export async function updateValue(formData: FormData): Promise<void> {
|
||||
@@ -199,4 +205,5 @@ export async function updateValue(formData: FormData): Promise<void> {
|
||||
// DB error — ignore.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteShopArticles } from "@/lib/db";
|
||||
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
@@ -149,6 +150,7 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
revalidatePath(`/admin/shop/${id}`);
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop article updated");
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
|
||||
@@ -175,5 +177,6 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop article deleted");
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteTeams } from "@/lib/db";
|
||||
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function createTeam(formData: FormData): Promise<void> {
|
||||
@@ -38,6 +39,7 @@ export async function createTeam(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/teams");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "team edited");
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
revalidatePath("/admin/teams");
|
||||
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "team edited");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -73,6 +73,16 @@ export async function scheduleCatalogExport() {
|
||||
after(async () => {
|
||||
const { runCatalogExport } = await import("./catalog-git-export");
|
||||
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 {
|
||||
/* Standalone worker contexts use their scheduled retry. */
|
||||
|
||||
+1
-1
@@ -78,6 +78,6 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging).*)",
|
||||
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)",
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user