Files
Epicnabbo-Catalogus-Updated…/src/lib/imager-upstream.ts
T
openhands f81b114b69 perf(cache): single-flight avatar renders, cacheable public reads, cheap row counts
Three separate things that were each costing more than they needed to on the
hot path.

- Single-flight avatar renders. The disk cache was checked first and a miss
  went straight to the upstream, with nothing shared between callers, so a page
  requesting dozens of avatars at once turned N concurrent requests for one
  figure into N renders. A render is the most expensive operation this app
  does, and the duplication happened exactly when the cache had nothing to
  offer. Eight concurrent requests now cause one render instead of eight. The
  map lives on globalThis because Next can evaluate the module more than once
  per process, and two copies would each start their own render.

- Let public read-only routes be cached by a shared cache. Every JSON response
  was `cache-control: no-store`, so a CDN in front of the app could not answer
  any of it and every request reached the origin. publicCacheControl() opts a
  route in with s-maxage and stale-while-revalidate, using the same TTL as the
  server-side cache so the two layers cannot disagree. The default stays
  no-store: most routes here are personalised, admin-only or auth-dependent.
  /api/badges/leaderboard is deliberately left alone because it returns
  per-viewer rank entries to signed-in callers.

  Note this only takes effect once a cache rule exists for /api/* at the CDN, or
  the explicit `cache: "no-store"` is dropped from the client fetches (24 files
  do that today, including the /api/online poll). The headers alone are inert
  until one of those happens.

- Take the homepage row counts from the storage engine estimate instead of
  COUNT(*), which walks an index and gets slower as the tables grow. A missing
  or zero estimate falls back to the exact count rather than ever showing a
  wrong zero. The online count stays exact: it is an indexed read over a small
  subset and a few seconds of drift reads as broken rather than approximate.

The counters move into one module because the homepage and the boot warm-up
populate the same cache keys, so two implementations would race to write
different values into the same entry.

3223 tests pass.
2026-09-25 18:42:23 +02:00

241 lines
7.5 KiB
TypeScript

import "server-only";
import {
avatarCacheDir,
imagingCacheKey,
readImagingCache,
writeImagingCache,
} from "@/lib/imager-cache";
import { resolveImagerBase } from "@/lib/runtime-asset-config";
export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i;
export const FIGURE_MAX_LEN = 512;
export const FIGURE_MAX_PARTS = 24;
const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage";
// Effect renders (the client asks for effect=14 by default) are encoded as
// APNG animations and consistently need a little over 4s, so the primary
// budget has to stay well above that or every effected avatar times out. The
// public Habbo renderer is a best-effort safety net only: it is slow to fail,
// so it gets a short budget and the disk cache absorbs the difference.
export const PRIMARY_TIMEOUT_MS = 8_000;
export const FALLBACK_TIMEOUT_MS = 2_000;
// Keyed by cache key, so one render serves every concurrent request for the same
// figure. Held on globalThis because Next can evaluate this module more than once
// per process (multiple server bundles); separate copies would each start their
// own render and defeat the point entirely.
const globalForRenders = globalThis as typeof globalThis & {
imagingInFlight?: Map<string, Promise<ImagerResult>>;
};
const inFlight = globalForRenders.imagingInFlight ?? new Map();
globalForRenders.imagingInFlight = inFlight;
const MAX_IN_FLIGHT_RENDERS = 256;
export type ImagerSource =
| "primary"
| "fallback"
| "cache"
| "cache-stale"
| "unavailable";
export interface ImagerResult {
body: Uint8Array;
contentType: string;
source: ImagerSource;
}
export type AvatarQuery =
| { ok: true; params: URLSearchParams }
| { ok: false; message: string };
export class ImagerUnavailableError extends Error {
constructor(message = "Avatar imager unavailable") {
super(message);
this.name = "ImagerUnavailableError";
}
}
export function parseAvatarQuery(searchParams: URLSearchParams): AvatarQuery {
const figure = (
searchParams.get("figure") ??
searchParams.get("look") ??
""
).trim();
if (!figure) return { ok: false, message: "Missing figure parameter" };
if (figure.length > FIGURE_MAX_LEN || !FIGURE_RE.test(figure))
return { ok: false, message: "Invalid figure format" };
if (figure.split(".").length > FIGURE_MAX_PARTS)
return { ok: false, message: "Figure has too many parts" };
const sizeParam = searchParams.get("size");
const size: "s" | "m" | "l" =
sizeParam === "s" || sizeParam === "l" ? sizeParam : "m";
const direction = clampInt(searchParams.get("direction"), 0, 7, 2);
const headDirection = clampInt(
searchParams.get("head_direction") ?? searchParams.get("headDirection"),
0,
7,
3,
);
const headOnly =
searchParams.get("headonly") === "1" ||
searchParams.get("headOnly") === "1";
const gesture = searchParams.get("gesture") ?? undefined;
const action = searchParams.get("action") ?? undefined;
const effect = searchParams.get("effect") ?? undefined;
const params = new URLSearchParams({
figure,
direction: String(direction),
head_direction: String(headDirection),
size,
img_format: "png",
});
if (headOnly) params.set("headonly", "1");
if (gesture) params.set("gesture", gesture);
if (action) params.set("action", action);
if (effect) params.set("effect", effect);
const imgFormat = searchParams.get("img_format");
if (imgFormat) params.set("img_format", imgFormat);
return { ok: true, params };
}
export async function fetchAvatarImage(
origin: string,
params: URLSearchParams,
): Promise<ImagerResult> {
const primary = resolveImagerBase(origin);
// Renders are immutable per figure/variant: serve a previously cached
// render without touching the upstream at all. This keeps repeated avatar
// requests off the local renderer and survives upstream downtime.
const cacheKey = imagingCacheKey(`${primary}\n${params.toString()}`);
const cached = await readImagingCache(avatarCacheDir(), cacheKey);
if (cached) return { ...cached, source: "cache" };
// Single-flight a render. A page requests dozens of avatars at once and
// repeats figures, so without this a cold cache turns N concurrent requests
// for one figure into N upstream renders — the most expensive operation in
// the app, launched exactly when the cache has nothing to offer. The winner
// writes to the disk cache; the others wait for that one result.
const pending = inFlight.get(cacheKey);
if (pending) return pending;
// Defensive bound: entries are removed as soon as they settle, so this only
// matters if renders are somehow never cleaned up.
if (inFlight.size >= MAX_IN_FLIGHT_RENDERS) inFlight.clear();
const render = renderAvatar(primary, cacheKey, params).finally(() => {
inFlight.delete(cacheKey);
});
inFlight.set(cacheKey, render);
return render;
}
async function renderAvatar(
primary: string,
cacheKey: string,
params: URLSearchParams,
): Promise<ImagerResult> {
const primaryResult = await fetchUpstream(
primary,
params,
PRIMARY_TIMEOUT_MS,
);
if (primaryResult) {
await writeImagingCache(
avatarCacheDir(),
cacheKey,
primaryResult.body,
primaryResult.contentType,
);
return { ...primaryResult, source: "primary" };
}
if (isSameUpstream(primary, HABBO_PUBLIC_UPSTREAM)) {
throw new ImagerUnavailableError(
"Configured imager is already the fallback upstream",
);
}
const fallbackResult = await fetchUpstream(
HABBO_PUBLIC_UPSTREAM,
buildFallbackParams(params),
FALLBACK_TIMEOUT_MS,
);
// Deliberately not cached: a fallback render drops the effect and is only a
// degraded stand-in, so persisting it would keep serving the worse image
// for the whole cache lifetime. The local renderer caches its own renders,
// so the next request for this figure is cheap and returns the real one.
if (fallbackResult) {
return { ...fallbackResult, source: "fallback" };
}
// Both upstreams are down and nothing was cached before. Try once more
// against the cache in case a concurrent request just wrote this render,
// then give up so the caller can serve a graceful placeholder.
const recent = await readImagingCache(avatarCacheDir(), cacheKey);
if (recent) return { ...recent, source: "cache-stale" };
throw new ImagerUnavailableError();
}
async function fetchUpstream(
baseUrl: string,
params: URLSearchParams,
timeoutMs: number,
): Promise<Omit<ImagerResult, "source"> | null> {
const url = new URL(baseUrl);
for (const [key, value] of params) url.searchParams.set(key, value);
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
cache: "no-store",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});
if (!res.ok) return null;
return {
body: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type") || "image/png",
};
} catch {
return null;
}
}
function buildFallbackParams(params: URLSearchParams): URLSearchParams {
const fallback = new URLSearchParams(params);
// The public Habbo renderer has no effect rendering support.
fallback.delete("effect");
// The public Habbo renderer emits PNG/GIF, not APNG.
fallback.set("img_format", "png");
return fallback;
}
function isSameUpstream(a: string, b: string): boolean {
const normalize = (value: string) =>
value.replace(/\/+$/, "").replace(/^https?:/, "");
return normalize(a) === normalize(b);
}
function clampInt(
raw: string | null,
min: number,
max: number,
fallback: number,
): number {
if (raw == null) return fallback;
const n = Number.parseInt(raw, 10);
if (Number.isNaN(n) || n < min || n > max) return fallback;
return n;
}