From 33b6d1520edb66c23cf01bc3b07e03f534c30bb3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 15:16:42 +0200 Subject: [PATCH] fix(catalog): stream single imports and preserve response errors --- src/app/api/admin/import/furni/route.ts | 277 +++++++++--------- src/components/admin/studio/studio-client.tsx | 29 +- src/lib/furni/import-response.ts | 52 ++++ .../import/core/single-response.test.ts | 73 +++++ .../services/import/core/single-response.ts | 62 ++++ 5 files changed, 351 insertions(+), 142 deletions(-) create mode 100644 src/lib/furni/import-response.ts create mode 100644 src/lib/services/import/core/single-response.test.ts create mode 100644 src/lib/services/import/core/single-response.ts diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index d7696487d4..d84cd58055 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -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> | 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 - > = []; - 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> | 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 + > = []; + 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(); }, ); diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 298916e094..01540b3261 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -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); } diff --git a/src/lib/furni/import-response.ts b/src/lib/furni/import-response.ts new file mode 100644 index 0000000000..6a1bb32055 --- /dev/null +++ b/src/lib/furni/import-response.ts @@ -0,0 +1,52 @@ +import { readSseStream } from "@/lib/sse-client"; + +interface ImportResponse { + status: number; + data: Record; +} +export async function readImportResponse( + response: Response, +): Promise { + 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, + }; + }); + 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; + 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 }; +} diff --git a/src/lib/services/import/core/single-response.test.ts b/src/lib/services/import/core/single-response.test.ts new file mode 100644 index 0000000000..5396a731a0 --- /dev/null +++ b/src/lib/services/import/core/single-response.test.ts @@ -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((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("gateway error", { 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" } }); +}); diff --git a/src/lib/services/import/core/single-response.ts b/src/lib/services/import/core/single-response.ts new file mode 100644 index 0000000000..528745165c --- /dev/null +++ b/src/lib/services/import/core/single-response.ts @@ -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 { + const encoder = new TextEncoder(); + let closed = false; + let timer: ReturnType | undefined; + const body = new ReadableStream({ + 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", + }, + }); +}