feat(catalog): store furniture bundles as .hab instead of .nitro
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m37s
CI / tests-ui (push) Successful in 2m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped

Every bundle the CMS writes — upload, clone, sync, repair and the pet /
effect / figure importers — now lands as `<classname>.hab`, the extension
this deployment's renderer asks for. `.hab` and `.nitro` are the same
container, so an upload of either extension is accepted.

Resolution goes through one module, src/lib/furni/bundle-file.ts, so
nothing has to know the extension twice. Every existence check probes
`.hab` first and falls back to `.nitro`: the on-disk asset set is still
predominantly `.nitro`, and without the fallback Studio would report every
imported item as missing and the cleanup scan would classify 18k live
bundles as fake leftovers. Downloads are unchanged — Habbo's CDN and every
configured clone source still serve `.nitro`, so the conversion happens on
write, not on request.

Deliberately unchanged: the staged-attachment store in furni-attachment.ts
keys on a UUID and never reaches the client, so renaming it would break
in-flight recovery jobs.

Adds scripts/migrate-nitro-to-hab.ts to rename the existing asset set. It
refuses to run without --dry-run or --yes, never overwrites an existing
.hab, never deletes, and is idempotent.

Note: renderer-config.json lives outside this repo and was patched to
.hab separately; that file is served with a 30-day max-age, so returning
clients need a cms-client cache purge to pick the change up.
This commit is contained in:
openhands committed 2026-10-10 17:09:36 +02:00
1 parent fdb7af7ef5
commit adffac7360
46 files changed
+1001 -210

No files matched your search

+51 -45
View File
@@ -4,7 +4,14 @@ import path from "node:path";
import { promisify } from "node:util";
import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, execResult, ItemsBase, queryRows } from "@/lib/db";
import { isSafeAssetName } from "@/lib/furni/classname";
import {
bundleExistsInDir,
bundleFileNames,
bundleWritePathInDir,
resolveBundleInDir,
stripBundleExt,
} from "@/lib/furni/bundle-file";
import { isSafeAssetName, LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
import { offerPurchasabilityProblems } from "@/lib/furni/offer-purchasability";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
@@ -449,8 +456,10 @@ export async function syncAssetsToGamedataBundle(): Promise<{
"bundled/furniture",
);
if (nitroDir !== nitroTarget) {
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
entry.endsWith(".nitro"),
const copied = await syncDirMissingFiles(
nitroDir,
nitroTarget,
(entry) => stripBundleExt(entry) !== null,
);
copiedNitros.push(...copied);
}
@@ -721,10 +730,17 @@ export async function importSingleFurni(params: {
/*turbopackIgnore: true*/ swfDir,
`${safeSwfName}.swf`,
);
const nitroPath = getRuntimePath(
// Imports write `.hab`; a pre-existing legacy `.nitro` for the same
// classname still counts as "already present" so a colour variant sharing a
// base classname never overwrites a bundle that is already on disk.
const nitroPath = bundleWritePathInDir(
/*turbopackIgnore: true*/ nitroDir,
`${safeNitroName}.nitro`,
safeNitroName,
);
const nitroExists = () =>
bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
const resolveNitro = () =>
resolveBundleInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
const iconPath = getRuntimePath(
/*turbopackIgnore: true*/ iconDir,
`${iconFileName}_icon.png`,
@@ -733,7 +749,7 @@ export async function importSingleFurni(params: {
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir);
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir);
if (params.providedNitro && !existsSync(nitroPath)) {
if (params.providedNitro && !nitroExists()) {
await fs.writeFile(
nitroPath,
await toWebpLosslessOrKeep(params.providedNitro, warnings),
@@ -801,9 +817,9 @@ export async function importSingleFurni(params: {
// disk — this happens for colour variants that share the base classname's
// nitro file. The SWF is only used for Nitro conversion (not needed) and
// icon extraction (the nitro bundle is used as a fallback for that below).
existsSync(/*turbopackIgnore: true*/ nitroPath) ||
nitroExists() ||
params.providedNitro ||
(params.preserveAssets && existsSync(nitroPath))
(params.preserveAssets && nitroExists())
? Promise.resolve(false)
: (preservingExisting || params.preserveAssets) && existsSync(swfPath)
? Promise.resolve(true)
@@ -817,11 +833,7 @@ export async function importSingleFurni(params: {
// ── Nitro bundle fallback: try downloading pre-made .nitro ────────
// Only relevant when the SWF download failed, so it runs after SWF.
let nitroDownloadOk = false;
if (
!swfOk &&
sourceNitroBaseUrl &&
!existsSync(/*turbopackIgnore: true*/ nitroPath)
) {
if (!swfOk && sourceNitroBaseUrl && !nitroExists()) {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
if (dl.ok) nitroDownloadOk = true;
@@ -858,8 +870,8 @@ export async function importSingleFurni(params: {
!swfOk &&
!nitroDownloadOk &&
!params.providedNitro &&
!existsSync(/*turbopackIgnore: true*/ nitroPath) &&
!(params.preserveAssets && existsSync(nitroPath))
!nitroExists() &&
!(params.preserveAssets && nitroExists())
)
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
@@ -867,8 +879,8 @@ export async function importSingleFurni(params: {
await onProgress?.("converting");
let conversionResult: ConversionResult | null = null;
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
warnings.push("Nitro file already exists (shared across color variants)");
if (nitroExists()) {
warnings.push("Bundle file already exists (shared across color variants)");
} else {
try {
if (existsSync(/*turbopackIgnore: true*/ swfPath)) {
@@ -897,7 +909,7 @@ export async function importSingleFurni(params: {
}
}
if (!existsSync(nitroPath) && params.resolveMissingNitro) {
if (!nitroExists() && params.resolveMissingNitro) {
await onProgress?.("checking_sources");
const recovered = await params.resolveMissingNitro(rev);
if (recovered)
@@ -912,9 +924,11 @@ export async function importSingleFurni(params: {
);
}
if (!iconOk && existsSync(nitroPath)) {
if (!iconOk && nitroExists()) {
try {
const icon = extractFurniIconPng(await fs.readFile(nitroPath));
const icon = extractFurniIconPng(
await fs.readFile(resolveNitro() ?? nitroPath),
);
if (icon) {
await fs.writeFile(iconPath, icon);
iconOk = true;
@@ -929,7 +943,7 @@ export async function importSingleFurni(params: {
// Also validate that the .nitro file is non-trivial (≥ 128 bytes — a bare
// Nitro bundle header is larger than that, anything smaller is corrupt/empty).
const nitroStat = await fs
.stat(/*turbopackIgnore: true*/ nitroPath)
.stat(resolveNitro() ?? nitroPath)
.catch(() => null);
const nitroValid = nitroStat && nitroStat.size >= 128;
@@ -939,7 +953,7 @@ export async function importSingleFurni(params: {
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
);
try {
await fs.unlink(nitroPath);
await fs.unlink(resolveNitro() ?? nitroPath);
} catch {
/* ignore */
}
@@ -1003,8 +1017,8 @@ export async function importSingleFurni(params: {
warnings,
),
mirrorFileToDirs(
nitroPath,
`${safeNitroName}.nitro`,
resolveNitro() ?? nitroPath,
`${safeNitroName}${LOCAL_BUNDLE_EXT}`,
mirrorNitroDirs,
warnings,
),
@@ -1077,11 +1091,11 @@ export async function importSingleFurni(params: {
let nitroJsonForModes: Record<string, unknown> | null = null;
try {
const parsed = parseNitroBundle(
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
await fs.readFile(resolveNitro() ?? nitroPath),
);
nitroJsonForModes = parsed.json as Record<string, unknown>;
} catch {
// no .nitro JSON available — keyword fallback only
// no bundle JSON available — keyword fallback only
}
const autoInteraction = autoDetectInteraction(classname, bestName, {
...realFlags,
@@ -1257,17 +1271,15 @@ async function realNitroModes(classname: string): Promise<number | undefined> {
const cached = nitroModesCache.get(base);
if (cached !== undefined) return cached;
try {
const nitroPath = getRuntimePath(
const nitroPath = resolveBundleInDir(
/*turbopackIgnore: true*/ FURNI_NITRO_DIR,
`${base}.nitro`,
base,
);
if (!existsSync(nitroPath)) {
if (!nitroPath) {
nitroModesCache.set(base, undefined);
return undefined;
}
const parsed = parseNitroBundle(
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
);
const parsed = parseNitroBundle(await fs.readFile(nitroPath));
const modes = nitroAnimationStatesCount(
parsed.json as Record<string, unknown>,
);
@@ -1481,11 +1493,13 @@ export async function deleteImportedItem(classname: string): Promise<{
errors.push(`FurnitureData.json: ${(err as Error).message}`);
}
// 4. Delete files (.nitro, .swf, icon)
// 4. Delete files (bundle — `.hab` and any legacy `.nitro` — plus .swf, icon)
const filesToDelete = [
getRuntimePath(
/*turbopackIgnore: true*/ nitroDir,
`${baseClassname}.nitro`,
...[nitroDir, ...assetTargets.mirrorDirs.map((d) => d.nitroDir)].flatMap(
(dir) =>
bundleFileNames(baseClassname).map((name) =>
getRuntimePath(/*turbopackIgnore: true*/ dir, name),
),
),
getRuntimePath(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`),
getRuntimePath(
@@ -1493,10 +1507,6 @@ export async function deleteImportedItem(classname: string): Promise<{
`${iconFileName}_icon.png`,
),
...assetTargets.mirrorDirs.flatMap((dirs) => [
getRuntimePath(
/*turbopackIgnore: true*/ dirs.nitroDir,
`${baseClassname}.nitro`,
),
getRuntimePath(
/*turbopackIgnore: true*/ dirs.swfDir,
`${baseClassname}.swf`,
@@ -1541,12 +1551,8 @@ export async function cleanupConvertedSwfs(): Promise<{
for (const swfFile of swfFiles) {
const cn = swfFile.replace(".swf", "");
const nitroFilePath = getRuntimePath(
/*turbopackIgnore: true*/ nitroDir,
`${cn}.nitro`,
);
if (existsSync(/*turbopackIgnore: true*/ nitroFilePath)) {
if (bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, cn)) {
try {
await fs.unlink(
getRuntimePath(/*turbopackIgnore: true*/ swfDir, swfFile),