diff --git a/deployment/proxy/nginx-cms.conf b/deployment/proxy/nginx-cms.conf index 6446a38d..04725b77 100644 --- a/deployment/proxy/nginx-cms.conf +++ b/deployment/proxy/nginx-cms.conf @@ -233,6 +233,56 @@ server { location = /gamedata { return 301 /gamedata/config/; } location = /gamedata/ { return 301 /gamedata/config/; } + + # ─── Gamedata: drie cache-klassen, want niet alles onder /gamedata/ is + # even veranderlijk. + # + # Dit pad had één regel voor de hele boom: `max-age=604800` (7 dagen). De + # Habbo-client haalt FurnitureData.json hier op, dus na een import bleef het + # client-side dagenlang de oude versie tonen — een nieuw geïmporteerd + # meubel was gewoon onzichtbaar. De purge van de `cms-gamedata`-tag + # (edge-cache.ts) raakt alleen de Cloudflare-kopie, niet de browser. + # + # 1. config/ — FurnitureData.json + de vertaalde bestanden. Verandert + # bij elke import. Kort, en `must-revalidate` sluit de + # "stuur uit de cache"-route uit zodat de client na de + # TTL een 304 vraagt in plaats van de oude body te hergebruiken. + # 2. bundled/ — nitro-bundles per sprite. De inhoud kan veranderen zonder + # dat de bestandsnaam verandert (schalen, repareren), dus + # ook revalideren, maar minder vaak: ze worden veel vaker + # opgehaald dan ze worden geschreven. + # 3. alles wat overblijft (c_images, album*, clothes, …) — content-addressed + # of per item uniek, nooit herschreven onder dezelfde naam. Blijft lang. + location ^~ /gamedata/config/ { + alias /var/www/Gamedata/config/; + add_header Cache-Control "public, max-age=300, must-revalidate"; + access_log off; + add_header Cache-Tag "cms-gamedata"; + + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + if ($cors_headers) { + add_header Access-Control-Max-Age 1728000; + add_header Content-Type "text/plain; charset=utf-8"; + return 204; + } + } + + location ^~ /gamedata/bundled/ { + alias /var/www/Gamedata/bundled/; + add_header Cache-Control "public, max-age=3600, must-revalidate"; + access_log off; + add_header Cache-Tag "cms-gamedata"; + + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + if ($cors_headers) { + add_header Access-Control-Max-Age 1728000; + add_header Content-Type "text/plain; charset=utf-8"; + return 204; + } + } + location /gamedata/ { alias /var/www/Gamedata/; add_header Cache-Control "public, max-age=604800"; diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index 9bf4f092..771caf40 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -464,6 +464,24 @@ async function main() { logger.info("Scheduled: nitro auto-clean (daily 02:00)", { module: "jobs", }); + + new Cron("*/30 * * * *", () => { + // Purge the gamedata edge tag periodically as a safety net in case a + // single import failed to emit a purge (e.g. Cloudflare disabled at the + // moment of write). Without it, a long TTL on /gamedata/ would keep the + // client stuck on old FurnitureData.json until the browser or CDN cache + // expired. No-op when Cloudflare is not configured. + import("../src/lib/edge-cache") + .then(({ EDGE_CACHE_TAGS, purgeEdgeCache }) => + purgeEdgeCache([EDGE_CACHE_TAGS.gamedata], "jobs-safety-net"), + ) + .catch((e) => + captureWorkerError(e, "Gamedata edge purge (safety net) failed"), + ); + }); + logger.info("Scheduled: gamedata edge purge safety net (every 30 min)", { + module: "jobs", + }); await Promise.all([ backupEmulatorJar(), cleanupOldLogs(), diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 30d438c2..306eb112 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -413,9 +413,6 @@ export async function translateCatalogItems(input: { }; } const { items } = parsed.data; - const { invalidateFurniDataCache } = await import( - "@/lib/services/catalog-items-loader" - ); const { patchFurniEntryNames } = await import("@/lib/services/furni-data"); const { patchLocalizedFurniDataEntries } = await import( "@/lib/services/furni-data-i18n" @@ -510,9 +507,9 @@ export async function translateCatalogItems(input: { furniPatches.length > 0 ? await patchFurniEntryNames(furniPatches) : { updated: 0, inserted: 0 }; - if (furniResult.updated > 0 || furniResult.inserted > 0) { - invalidateFurniDataCache(); - } + // `furniResult` needs no follow-up: `patchFurniEntryNames` writes through + // `writeFurniData`, which resets the shared in-process cache and purges + // the gamedata edge tag itself. if (localizedEntries.length > 0) { try { diff --git a/src/lib/services/catalog-items-loader.ts b/src/lib/services/catalog-items-loader.ts index b3c9cf4b..524955a4 100644 --- a/src/lib/services/catalog-items-loader.ts +++ b/src/lib/services/catalog-items-loader.ts @@ -1,52 +1,39 @@ -import { promises as fs } from "node:fs"; import { asc, sql } from "drizzle-orm"; import { numericValue } from "@/features/catalog/domain/offer-input"; import { CatalogPages, CatalogPagesBc, db, queryRows } from "@/lib/db"; -import { getFurnitureDataPath } from "@/lib/services/furni-data"; +import { readFurniData } from "@/lib/services/furni-data"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; -// Cache FurnitureData.json in memory with TTL to avoid repeated disk I/O -let furniDataCache: { - roomitemtypes?: { - furnitype?: Array<{ - id: number; - description?: string; - classname?: string; - revision?: number; - }>; - }; - wallitemtypes?: { - furnitype?: Array<{ - id: number; - description?: string; - classname?: string; - revision?: number; - }>; - }; -} | null = null; -let furniDataCacheTime = 0; -const FURNI_CACHE_TTL = 30_000; // 30 seconds +interface FurnitypeEntry { + id: number; + description?: string; + classname?: string; + revision?: number; +} -async function getFurnitureData() { - if (furniDataCache && Date.now() - furniDataCacheTime < FURNI_CACHE_TTL) - return furniDataCache; - const furniDataPath = await getFurnitureDataPath(); +interface FurnitureData { + roomitemtypes?: { furnitype?: FurnitypeEntry[] }; + wallitemtypes?: { furnitype?: FurnitypeEntry[] }; +} + +/** + * FurnitureData.json via the shared reader. + * + * This module used to keep its own 30s TTL copy of the file next to the + * mtime-validated cache in `furni-data.ts`. Two caches over one file meant an + * import cleared only the first one, so this loader kept serving the + * pre-import furnidata (empty descriptions and revisions) until the TTL ran + * out. `readFurniData` revalidates on `mtimeMs`+`size` and is reset by every + * write, so there is now exactly one cache and it cannot go stale on its own. + */ +async function getFurnitureData(): Promise { try { - const raw = await fs.readFile(furniDataPath, "utf-8"); - furniDataCache = JSON.parse(raw); - furniDataCacheTime = Date.now(); - return furniDataCache; + return (await readFurniData()) as FurnitureData; } catch { return null; } } -/** Invalidate the FurnitureData cache after writes */ -export function invalidateFurniDataCache() { - furniDataCache = null; - furniDataCacheTime = 0; -} - interface RawItem { id: number; itemIds: string; @@ -292,7 +279,7 @@ export async function loadCatalogItemsData( spriteToBaseId.set(b.spriteId, b.id); } - // Read FurnitureData.json (cached in memory) + // Read FurnitureData.json through the shared mtime-validated reader. const foundBaseIds = new Set(); const furniDescriptionMap: Record = {}; const furniRevisionMap: Record< diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index ae9b8ef5..13b4b22f 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -277,6 +277,35 @@ export async function writeFurniData( // The file just changed: force the next read to reparse instead of serving // a possibly-mutated cached object. cachedFurniData = null; + void purgeFurniGamedataEdge( + `furnidata written (${targets.length} target(s))`, + ); +} + +/** + * Drop the edge copy of every gamedata file after a furniture write. + * + * The Habbo client reads FurnitureData.json from /gamedata/, which nginx + * serves with its own TTL and Cloudflare with an edge TTL. Neither knows + * about a write that happened on disk, so without this purge a freshly + * imported item stays invisible in the client until both expire. The purge + * used to hang off the catalog Git export, which is disabled in production, + * so in practice it never ran. + * + * Fire-and-forget and swallowed on every level: a stale edge copy is bounded + * by the edge TTL, so failing to purge must never fail the import itself. + * Standalone scripts reach this through `tsx` without the react-server + * condition, where `server-only` throws on import, hence the try/catch. + */ +async function purgeFurniGamedataEdge(reason: string): Promise { + try { + const { EDGE_CACHE_TAGS, purgeEdgeCache } = await import( + "@/lib/edge-cache" + ); + await purgeEdgeCache([EDGE_CACHE_TAGS.gamedata], reason); + } catch { + /* no Cloudflare credentials, or a non-server runtime: nothing to purge */ + } } export function buildFurniEntry(params: {