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.
111 lines
3.5 KiB
TypeScript
111 lines
3.5 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
bundleExistsInDir,
|
|
bundleExistsInDirs,
|
|
bundleFileNames,
|
|
bundleWritePathInDir,
|
|
existingBundlePaths,
|
|
resolveBundleFileNameInDir,
|
|
resolveBundleInDir,
|
|
resolveBundleInDirs,
|
|
stripBundleExt,
|
|
} from "./bundle-file";
|
|
|
|
let dir: string;
|
|
let mirror: string;
|
|
|
|
beforeEach(async () => {
|
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), "hab-bundle-"));
|
|
mirror = await fs.mkdtemp(path.join(os.tmpdir(), "hab-bundle-mirror-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
await fs.rm(mirror, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("bundleFileNames", () => {
|
|
it("prefers `.hab` and keeps `.nitro` as the legacy fallback", () => {
|
|
expect(bundleFileNames("sofa_blue")).toEqual([
|
|
"sofa_blue.hab",
|
|
"sofa_blue.nitro",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("stripBundleExt", () => {
|
|
it("strips either extension", () => {
|
|
expect(stripBundleExt("sofa_blue.hab")).toBe("sofa_blue");
|
|
expect(stripBundleExt("sofa_blue.nitro")).toBe("sofa_blue");
|
|
expect(stripBundleExt("SOFA_BLUE.HAB")).toBe("SOFA_BLUE");
|
|
});
|
|
it("rejects anything else", () => {
|
|
expect(stripBundleExt("sofa_blue.png")).toBeNull();
|
|
expect(stripBundleExt("sofa_blue")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resolution", () => {
|
|
it("finds a `.hab` bundle", async () => {
|
|
await fs.writeFile(path.join(dir, "sofa_blue.hab"), "x");
|
|
expect(bundleExistsInDir(dir, "sofa_blue")).toBe(true);
|
|
expect(resolveBundleInDir(dir, "sofa_blue")).toBe(
|
|
path.join(dir, "sofa_blue.hab"),
|
|
);
|
|
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBe("sofa_blue.hab");
|
|
});
|
|
|
|
// The whole point of the fallback: an import must not report an item as
|
|
// missing just because its bundle predates the `.hab` convention.
|
|
it("still finds a legacy `.nitro` bundle", async () => {
|
|
await fs.writeFile(path.join(dir, "sofa_blue.nitro"), "x");
|
|
expect(bundleExistsInDir(dir, "sofa_blue")).toBe(true);
|
|
expect(resolveBundleInDir(dir, "sofa_blue")).toBe(
|
|
path.join(dir, "sofa_blue.nitro"),
|
|
);
|
|
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBe(
|
|
"sofa_blue.nitro",
|
|
);
|
|
});
|
|
|
|
it("reports a missing bundle", async () => {
|
|
expect(bundleExistsInDir(dir, "sofa_blue")).toBe(false);
|
|
expect(resolveBundleInDir(dir, "sofa_blue")).toBeNull();
|
|
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBeNull();
|
|
});
|
|
|
|
it("searches mirrors when the primary has nothing", async () => {
|
|
await fs.writeFile(path.join(mirror, "sofa_blue.nitro"), "x");
|
|
expect(bundleExistsInDirs([dir, mirror], "sofa_blue")).toBe(true);
|
|
expect(resolveBundleInDirs([dir, mirror], "sofa_blue")).toBe(
|
|
path.join(mirror, "sofa_blue.nitro"),
|
|
);
|
|
});
|
|
|
|
it("prefers `.hab` over a same-stem legacy file", async () => {
|
|
await fs.writeFile(path.join(dir, "sofa_blue.nitro"), "old");
|
|
await fs.writeFile(path.join(dir, "sofa_blue.hab"), "new");
|
|
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBe("sofa_blue.hab");
|
|
});
|
|
});
|
|
|
|
describe("writing", () => {
|
|
it("always writes `.hab`", () => {
|
|
expect(bundleWritePathInDir("/assets", "sofa_blue")).toBe(
|
|
path.join("/assets", "sofa_blue.hab"),
|
|
);
|
|
});
|
|
|
|
it("lists every existing variant so a delete can never leave one behind", async () => {
|
|
await fs.writeFile(path.join(dir, "sofa_blue.hab"), "x");
|
|
await fs.writeFile(path.join(mirror, "sofa_blue.nitro"), "x");
|
|
expect(existingBundlePaths([dir, mirror], "sofa_blue")).toEqual([
|
|
path.join(dir, "sofa_blue.hab"),
|
|
path.join(mirror, "sofa_blue.nitro"),
|
|
]);
|
|
});
|
|
});
|