Every write path normalises a bundle's texture to WebP Lossless, so the catalog icon was being read back with a PNG-only decoder. decodePng throws on anything that is not a PNG, the callers caught that and returned null, and the user-visible result was "no icon (not in source or bundle)" for every furniture whose source does not serve a standalone icon. Measured against the production asset tree, all 18,505 bundles were WebP; icon extraction succeeded on 0 of them. src/lib/services/imager/ decode-texture.ts keeps PNG on the dependency-free decoder and routes WebP through sharp, which is already a dependency and already encodes these textures. extractFurniIconPng and getPetIconPng become async; the five call sites (upload, clone import, furni import, icon repair and both icon routes) already awaited their surrounding work. Same root cause, second bug: the spritesheet frame key. Converters disagree on packing — some keep a trailing ".png", and some lowercase the whole key while leaving the bundle name mixed-case, so "LTD_fashionistaf" looks up frame "LTD_fashionistaf_LTD_fashionistaf_icon_a" and never finds "ltd_fashionistaf_ltd_fashionistaf_icon_a". Any mixed-case classname could therefore never match, which is most of the catalogue. findFrame tries the two exact spellings, then falls back to one case-insensitive pass. Extraction now succeeds on 18,483 of 18,505 bundles (99.88%); the 22 remainder are data, not code — 9 bundles ship no icon asset, 11 do not parse. Third: three catalogue icons exist only as .gif while catalogueIconUrl hardcoded .png, so the picker offered icons that could only ever 404, and 291 icons that ship as both formats were listed twice. The API now dedupes per id and the two renderers retry with .gif before falling back to the placeholder, matching what catalog-image-picker already did. Verified live: /gamedata/.../icon_1542.png returns 404 while icon_1542.gif returns 200. Separately, close the last hole in the memory cap. Every script in package.json routes through scripts/with-memory-cap.sh, but invoking the builder directly — from a terminal, an IDE or an agent — skipped the wrapper and ran unbounded, on a host with no swap where the OOM killer picks its victim across the whole machine. next.config.ts now refuses a production build that the wrapper has not marked, before anything allocates. next dev and next start are deliberately unaffected. README gains a Memory-capped commands section covering the per-script ceilings, the backends and the ulimit -v trap, and its stale version and script tables are corrected.
223 lines
6.0 KiB
TypeScript
223 lines
6.0 KiB
TypeScript
import { execSync } from "node:child_process";
|
|
import type { NextConfig } from "next";
|
|
import { PHASE_PRODUCTION_BUILD } from "next/constants";
|
|
import createNextIntlPlugin from "next-intl/plugin";
|
|
|
|
/**
|
|
* This host runs with `vm.overcommit_memory=0` and no swap, so a process that
|
|
* asks for more memory than is free gets OOM-killed by the kernel immediately.
|
|
* The killer picks its victim across the WHOLE machine — an unbounded build can
|
|
* take down the database, nginx and the live release with it.
|
|
*
|
|
* `scripts/with-memory-cap.sh` runs a heavy command in its own cgroup with a
|
|
* hard `MemoryMax`, so only that build dies and the site keeps serving. Every
|
|
* script in package.json goes through it.
|
|
*
|
|
* The one hole that leaves is running the builder by hand: `npx next build`,
|
|
* `pnpm exec next build`, or an IDE/agent task invoking it directly skips the
|
|
* wrapper entirely and is unbounded. This guard closes that. `next build`
|
|
* loads the config, so refusing here stops the build before it allocates
|
|
* anything. See the header of scripts/with-memory-cap.sh.
|
|
*/
|
|
function assertMemoryCapped(phase: string): void {
|
|
if (phase !== PHASE_PRODUCTION_BUILD) return;
|
|
if (process.env.CMS_MEMORY_CAPPED === "1") return;
|
|
|
|
throw new Error(
|
|
[
|
|
"Refusing to run an uncapped production build.",
|
|
"",
|
|
"On this host an unbounded `next build` gets OOM-killed by the kernel,",
|
|
"and the killer may take the database, nginx or the live release with it.",
|
|
"",
|
|
"Use the capped build instead:",
|
|
" pnpm build",
|
|
"",
|
|
"It runs the builder through scripts/with-memory-cap.sh, which puts it in",
|
|
"its own cgroup with a MemoryMax, so a runaway build fails alone.",
|
|
"",
|
|
"Already inside an isolated environment (Docker, a CI runner) where the",
|
|
"container itself is the boundary? Set CMS_MEMORY_CAPPED=1 explicitly.",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
const getGitCommit = () => {
|
|
try {
|
|
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const securityHeaders = [
|
|
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
|
{
|
|
key: "Strict-Transport-Security",
|
|
value: "max-age=63072000; includeSubDomains; preload",
|
|
},
|
|
{ key: "X-Frame-Options", value: "DENY" },
|
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
|
{
|
|
key: "Permissions-Policy",
|
|
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
|
},
|
|
];
|
|
|
|
const nextConfig: NextConfig = {
|
|
output: "standalone",
|
|
env: {
|
|
NEXT_PUBLIC_CMS_RELEASE:
|
|
process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit() || "unknown",
|
|
},
|
|
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
|
|
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
|
reactStrictMode: true,
|
|
compress: true,
|
|
productionBrowserSourceMaps: false,
|
|
// `isomorphic-dompurify` builds a DOM through jsdom on the server. Bundled,
|
|
// it drags jsdom's `browser/default-stylesheet.css` into the server chunk,
|
|
// where the path no longer exists and page-data collection dies with ENOENT
|
|
// on any page that sanitizes HTML. Kept external, Node resolves it from
|
|
// node_modules at runtime and the standalone output traces it in.
|
|
serverExternalPackages: [
|
|
"lzma-wasm",
|
|
"sharp",
|
|
"pino",
|
|
"pino-pretty",
|
|
"isomorphic-dompurify",
|
|
],
|
|
|
|
async redirects() {
|
|
return [
|
|
{
|
|
source: "/admin/import",
|
|
destination: "/admin/studio/furni",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/badges",
|
|
destination: "/admin/studio/badges",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/furni",
|
|
destination: "/admin/studio/furni",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/furni/upload",
|
|
destination: "/admin/studio/upload",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/clothing",
|
|
destination: "/admin/studio/clothing",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/effects",
|
|
destination: "/admin/studio/effects",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/pets",
|
|
destination: "/admin/studio/pets",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/clone",
|
|
destination: "/admin/studio/clone",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/sync",
|
|
destination: "/admin/studio/sync",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/repair-icons",
|
|
destination: "/admin/studio/repair-icons",
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: "/admin/import/audit",
|
|
destination: "/admin/studio/audit",
|
|
permanent: true,
|
|
},
|
|
];
|
|
},
|
|
|
|
turbopack: {
|
|
ignoreIssue: [
|
|
{
|
|
path: "**/src/lib/**",
|
|
},
|
|
],
|
|
},
|
|
|
|
experimental: {
|
|
optimizePackageImports: ["lucide-react", "date-fns"],
|
|
useTypeScriptCli: true,
|
|
hideLogsAfterAbort: true,
|
|
},
|
|
|
|
images: {
|
|
formats: ["image/avif", "image/webp"],
|
|
},
|
|
|
|
async headers() {
|
|
return [
|
|
{
|
|
source: "/(.*)",
|
|
headers: securityHeaders,
|
|
},
|
|
{
|
|
source: "/assets/(.*)",
|
|
headers: [
|
|
{
|
|
key: "Cache-Control",
|
|
value: "public, max-age=31536000, immutable",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
// Nitro client payload (~2.8GB across swf/nitro-assets): without
|
|
// caching every client open re-downloads hundreds of files.
|
|
// Fresh for 7 days, then serve stale + revalidate in background
|
|
// so asset updates still propagate without blocking players.
|
|
source: "/swf/(.*)",
|
|
headers: [
|
|
{
|
|
key: "Cache-Control",
|
|
value: "public, max-age=604800, stale-while-revalidate=2592000",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
source: "/nitro-assets/(.*)",
|
|
headers: [
|
|
{
|
|
key: "Cache-Control",
|
|
value: "public, max-age=604800, stale-while-revalidate=2592000",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
source: "/images/(.*)",
|
|
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
|
|
|
// Exported as a function so the build phase is known before the config is
|
|
// used. next-intl only accepts a plain object, so it is applied here.
|
|
export default function config(phase: string) {
|
|
assertMemoryCapped(phase);
|
|
return withNextIntl(nextConfig);
|
|
}
|