fix(catalog): stream single imports and preserve response errors

This commit is contained in:
Simo committed 2026-09-05 15:16:42 +02:00
1 parent f0dcbee162
commit 33b6d1520e
5 files changed
+351 -142

No files matched your search

+142 -135
View File
@@ -39,6 +39,7 @@ import {
} from "@/lib/services/furni-import";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { listFurniture } from "@/lib/services/habbofurni";
import { streamImportResponse } from "@/lib/services/import/core/single-response";
import { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
@@ -682,144 +683,150 @@ export const POST = withAdmin(
});
}
// ── Single import ────────────────────────────────────────────────
const {
id,
classname,
name,
description,
type,
revision,
category,
sourceId,
updateExisting,
} = body;
if (!classname || !name) return apiError("Missing classname or name", 400);
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
const result = await importSingleFurni({
id: id ?? 0,
classname,
name,
description: description ?? "",
type: type ?? "flooritem",
revision: revision ?? 0,
category: category ?? "unknown",
updateExisting: updateExisting === true,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
});
if (!result.ok) return apiError(result.error ?? "Import failed", 409);
// Refresh game server caches
try {
await rcon.updateCatalog();
await rcon.updateItems();
} catch (err) {
console.warn(
"[import-furni] RCON update failed:",
(err as Error).message,
);
result.warnings.push("RCON cache refresh failed");
}
// Force offer_id to mirror its catalog row id across the tree
const reconciled = await reconcileImportedOfferIds();
// Keep asset + furnidata folders readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
// Fix database consistency (catalog_name + have_offer + cost_credits) after import
const { catalogNameFixed, haveOfferFixed, costCreditsFixed } =
await fixDatabaseConsistencyAfterImport();
// Force every catalog offer_id to equal its own row id
const offerRebuild = await rebuildCatalogOfferIds();
// Repair any sprite_id drift so the client renders the right item
const spriteVerify = await verifyAndFixSpriteIds();
// Verify + fix interaction_modes_count (single consolidated pass)
const verification = await verifyAndFixInteractionModesCount();
// Patch localized furnidata files for this single furni (best effort).
// Same `translate` / `langs` contract as the batch path above.
let languagesPatch: Awaited<
ReturnType<typeof patchLocalizedFurniDataEntries>
> = [];
try {
if (body.translate !== false) {
const langs = Array.isArray(body.langs)
? (body.langs as unknown[]).filter(
(l): l is string => typeof l === "string",
)
: undefined;
languagesPatch = await patchLocalizedFurniDataEntries(
[{ classname, name, description: description ?? "" }],
body.translate === true,
langs,
);
}
} catch (err) {
console.warn(
"[import-furni] Localized patch failed:",
(err as Error).message,
);
}
logAudit({
userId: ctx.session.user.id,
action: "furni_import",
target: "ItemsBase",
targetId: result.itemId ?? 0,
after: {
const runSingle = async () => {
// ── Single import ────────────────────────────────────────────────
const {
id,
classname,
name,
spriteId: id ?? 0,
type: type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
spriteCount: result.spriteCount,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
},
});
description,
type,
revision,
category,
sourceId,
updateExisting,
} = body;
return apiOk({
id: result.itemId,
catalogItemId: result.catalogItemId,
offerIdsFixed: reconciled.fixed,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing,
furniDataFixedConflicts: furniReconcile.fixedConflicts,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
spriteChecked: spriteVerify.checked,
spriteFixed: spriteVerify.fixed,
interactionFixed: verification.fixed,
catalogNameFixed,
haveOfferFixed,
costCreditsFixed,
languages: languagesPatch,
spriteCount: result.spriteCount,
warnings: result.warnings,
});
if (!classname || !name)
return apiError("Missing classname or name", 400);
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
const result = await importSingleFurni({
id: id ?? 0,
classname,
name,
description: description ?? "",
type: type ?? "flooritem",
revision: revision ?? 0,
category: category ?? "unknown",
updateExisting: updateExisting === true,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
});
if (!result.ok) return apiError(result.error ?? "Import failed", 409);
// Refresh game server caches
try {
await rcon.updateCatalog();
await rcon.updateItems();
} catch (err) {
console.warn(
"[import-furni] RCON update failed:",
(err as Error).message,
);
result.warnings.push("RCON cache refresh failed");
}
// Force offer_id to mirror its catalog row id across the tree
const reconciled = await reconcileImportedOfferIds();
// Keep asset + furnidata folders readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
// Fix database consistency (catalog_name + have_offer + cost_credits) after import
const { catalogNameFixed, haveOfferFixed, costCreditsFixed } =
await fixDatabaseConsistencyAfterImport();
// Force every catalog offer_id to equal its own row id
const offerRebuild = await rebuildCatalogOfferIds();
// Repair any sprite_id drift so the client renders the right item
const spriteVerify = await verifyAndFixSpriteIds();
// Verify + fix interaction_modes_count (single consolidated pass)
const verification = await verifyAndFixInteractionModesCount();
// Patch localized furnidata files for this single furni (best effort).
// Same `translate` / `langs` contract as the batch path above.
let languagesPatch: Awaited<
ReturnType<typeof patchLocalizedFurniDataEntries>
> = [];
try {
if (body.translate !== false) {
const langs = Array.isArray(body.langs)
? (body.langs as unknown[]).filter(
(l): l is string => typeof l === "string",
)
: undefined;
languagesPatch = await patchLocalizedFurniDataEntries(
[{ classname, name, description: description ?? "" }],
body.translate === true,
langs,
);
}
} catch (err) {
console.warn(
"[import-furni] Localized patch failed:",
(err as Error).message,
);
}
logAudit({
userId: ctx.session.user.id,
action: "furni_import",
target: "ItemsBase",
targetId: result.itemId ?? 0,
after: {
classname,
name,
spriteId: id ?? 0,
type: type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
spriteCount: result.spriteCount,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
},
});
return apiOk({
id: result.itemId,
catalogItemId: result.catalogItemId,
offerIdsFixed: reconciled.fixed,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing,
furniDataFixedConflicts: furniReconcile.fixedConflicts,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
spriteChecked: spriteVerify.checked,
spriteFixed: spriteVerify.fixed,
interactionFixed: verification.fixed,
catalogNameFixed,
haveOfferFixed,
costCreditsFixed,
languages: languagesPatch,
spriteCount: result.spriteCount,
warnings: result.warnings,
});
};
return request.headers.get("accept")?.includes("text/event-stream")
? streamImportResponse(runSingle)
: runSingle();
},
);
+22 -7
View File
@@ -60,6 +60,7 @@ import {
type AutoCatalogPreview,
previewAutoCatalog,
} from "@/lib/furni/auto-catalog";
import { readImportResponse } from "@/lib/furni/import-response";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
@@ -690,7 +691,10 @@ export function StudioClient({
try {
const res = await adminFetch("/api/admin/import/furni", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
repairExisting: true,
id: item.id,
@@ -706,8 +710,8 @@ export function StudioClient({
translate && translateLangs.length > 0 ? translateLangs : undefined,
}),
});
const data = await res.json();
if (res.ok) {
const { status, data } = await readImportResponse(res);
if (status >= 200 && status < 300) {
setItems((prev) =>
prev.map((i) =>
i.classname === item.classname
@@ -727,15 +731,26 @@ export function StudioClient({
`verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`,
);
}
if (data.furniDataMissing > 0) {
if (
typeof data.furniDataMissing === "number" &&
data.furniDataMissing > 0
) {
parts.push(`${data.furniDataMissing} furnidata missing`);
}
toast.success(parts.join(" · "));
} else {
toast.error(data.error || `Import failed for ${item.classname}`);
toast.error(
typeof data.error === "string"
? data.error
: `Import failed for ${item.classname}`,
);
}
} catch {
toast.error("Network error");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Import connection failed. Check the furniture status before retrying.",
);
} finally {
setImportingId(null);
}
+52
View File
@@ -0,0 +1,52 @@
import { readSseStream } from "@/lib/sse-client";
interface ImportResponse {
status: number;
data: Record<string, unknown>;
}
export async function readImportResponse(
response: Response,
): Promise<ImportResponse> {
if (
response.ok &&
response.headers.get("content-type")?.includes("text/event-stream")
) {
if (!response.body)
throw Error(
"Import response is empty. Check the furniture status before retrying.",
);
let result: ImportResponse | undefined;
await readSseStream(response.body, (event) => {
if (
event.type === "result" &&
typeof event.status === "number" &&
event.data &&
typeof event.data === "object"
)
result = {
status: event.status,
data: event.data as Record<string, unknown>,
};
});
if (!result)
throw Error(
"Import connection ended before confirmation. Some changes may already be saved. Check the furniture status before retrying.",
);
return result;
}
const text = await response.text();
let data: Record<string, unknown>;
try {
data = JSON.parse(text);
} catch {
const reason = [502, 503, 504, 524].includes(response.status)
? "Server or proxy unavailable, or request timed out"
: "Server returned an unexpected response";
throw Error(
`${reason} (HTTP ${response.status}). Check the furniture status before retrying.`,
);
}
if (!data || typeof data !== "object")
throw Error(`Invalid import response (HTTP ${response.status}).`);
return { status: response.status, data };
}
@@ -0,0 +1,73 @@
import { afterEach, expect, it, vi } from "vitest";
import { readImportResponse } from "@/lib/furni/import-response";
import { streamImportResponse } from "./single-response";
vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() }));
afterEach(() => vi.useRealTimers());
it("sends headers and heartbeats before a slow operation finishes", async () => {
vi.useFakeTimers();
let finish!: (response: Response) => void;
const operation = vi.fn(
() =>
new Promise<Response>((resolve) => {
finish = resolve;
}),
);
const response = streamImportResponse(operation);
expect(response.headers.get("X-Accel-Buffering")).toBe("no");
if (!response.body) throw Error("Missing stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
expect(decoder.decode((await reader.read()).value)).toContain('"started"');
await vi.advanceTimersByTimeAsync(10000);
expect(decoder.decode((await reader.read()).value)).toContain('"processing"');
finish(Response.json({ ok: true, id: 42 }));
expect(decoder.decode((await reader.read()).value)).toContain('"id":42');
expect((await reader.read()).done).toBe(true);
expect(operation).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("preserves failure status and the importer error", async () => {
const result = await readImportResponse(
streamImportResponse(async () =>
Response.json({ error: "Missing source asset" }, { status: 409 }),
),
);
expect(result).toEqual({
status: 409,
data: { error: "Missing source asset" },
});
});
it("delivers caught server failures instead of abruptly closing", async () => {
const result = await readImportResponse(
streamImportResponse(async () => {
throw Error("private database details");
}),
);
expect(result.status).toBe(500);
expect(result.data.error).toContain("Some changes may already be saved");
expect(result.data.error).not.toContain("private database");
});
it("reports proxy timeout status without rendering proxy HTML", async () => {
await expect(
readImportResponse(
new Response("<html>gateway error</html>", { status: 524 }),
),
).rejects.toThrow("HTTP 524");
});
it("does not treat a truncated stream as successful", async () => {
await expect(
readImportResponse(
new Response('data: {"type":"progress"}\n\n', {
headers: { "Content-Type": "text/event-stream" },
}),
),
).rejects.toThrow("before confirmation");
});
it("preserves JSON authentication failures", async () => {
expect(
await readImportResponse(
Response.json({ error: "Unauthorized" }, { status: 401 }),
),
).toEqual({ status: 401, data: { error: "Unauthorized" } });
});
@@ -0,0 +1,62 @@
import { logServerError } from "@/lib/server-log";
/** Return headers immediately and keep proxies alive while one import completes. */
export function streamImportResponse(
operation: () => Promise<Response>,
): Response {
const encoder = new TextEncoder();
let closed = false;
let timer: ReturnType<typeof setInterval> | undefined;
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (data: unknown) => {
if (!closed)
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
};
send({ type: "progress", stage: "started" });
timer = setInterval(
() => send({ type: "progress", stage: "processing" }),
10000,
);
void (async () => {
try {
const response = await operation();
send({
type: "result",
status: response.status,
data: await response.json(),
});
} catch (error) {
logServerError("furni.import_stream_failed", error);
send({
type: "result",
status: 500,
data: {
error:
"Import interrupted on the server. Some changes may already be saved. Check the furniture status before retrying.",
},
});
} finally {
clearInterval(timer);
if (!closed) {
closed = true;
controller.close();
}
}
})();
},
cancel() {
closed = true;
clearInterval(timer);
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}