fix(catalog): stream single imports and preserve response errors
This commit is contained in:
1 parent
f0dcbee162
commit
33b6d1520e
5 files changed
+351
-142
No files matched your search
@@ -39,6 +39,7 @@ import {
|
|||||||
} from "@/lib/services/furni-import";
|
} from "@/lib/services/furni-import";
|
||||||
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { listFurniture } from "@/lib/services/habbofurni";
|
import { listFurniture } from "@/lib/services/habbofurni";
|
||||||
|
import { streamImportResponse } from "@/lib/services/import/core/single-response";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||||
|
|
||||||
@@ -682,144 +683,150 @@ export const POST = withAdmin(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Single import ────────────────────────────────────────────────
|
const runSingle = async () => {
|
||||||
const {
|
// ── Single import ────────────────────────────────────────────────
|
||||||
id,
|
const {
|
||||||
classname,
|
id,
|
||||||
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: {
|
|
||||||
classname,
|
classname,
|
||||||
name,
|
name,
|
||||||
spriteId: id ?? 0,
|
description,
|
||||||
type: type === "wallitem" ? "i" : "s",
|
type,
|
||||||
catalogItemId: result.catalogItemId,
|
revision,
|
||||||
dimensions: result.dimensions,
|
category,
|
||||||
spriteCount: result.spriteCount,
|
sourceId,
|
||||||
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
updateExisting,
|
||||||
},
|
} = body;
|
||||||
});
|
|
||||||
|
|
||||||
return apiOk({
|
if (!classname || !name)
|
||||||
id: result.itemId,
|
return apiError("Missing classname or name", 400);
|
||||||
catalogItemId: result.catalogItemId,
|
|
||||||
offerIdsFixed: reconciled.fixed,
|
let source: Awaited<ReturnType<typeof getSource>> | null = null;
|
||||||
ownershipFixed: ownership.fixed,
|
if (sourceId) source = await getSource(sourceId);
|
||||||
nitrosSynced: assetsSynced.copiedNitros,
|
|
||||||
iconsSynced: assetsSynced.copiedIcons,
|
const result = await importSingleFurni({
|
||||||
furniDataFixedIds: furniReconcile.fixedIds,
|
id: id ?? 0,
|
||||||
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
|
classname,
|
||||||
furniDataMissing: furniReconcile.missing,
|
name,
|
||||||
furniDataFixedConflicts: furniReconcile.fixedConflicts,
|
description: description ?? "",
|
||||||
furniDataConflicts: furniReconcile.conflicts,
|
type: type ?? "flooritem",
|
||||||
offerRebuildChecked: offerRebuild.checked,
|
revision: revision ?? 0,
|
||||||
offerRebuildFixed: offerRebuild.fixed,
|
category: category ?? "unknown",
|
||||||
spriteChecked: spriteVerify.checked,
|
updateExisting: updateExisting === true,
|
||||||
spriteFixed: spriteVerify.fixed,
|
repairExisting: body.repairExisting === true,
|
||||||
interactionFixed: verification.fixed,
|
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
|
||||||
catalogNameFixed,
|
nitroBaseUrl: source?.nitroBaseUrl || undefined,
|
||||||
haveOfferFixed,
|
iconBaseUrl: source?.iconBaseUrl || undefined,
|
||||||
costCreditsFixed,
|
});
|
||||||
languages: languagesPatch,
|
|
||||||
spriteCount: result.spriteCount,
|
if (!result.ok) return apiError(result.error ?? "Import failed", 409);
|
||||||
warnings: result.warnings,
|
|
||||||
});
|
// 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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
type AutoCatalogPreview,
|
type AutoCatalogPreview,
|
||||||
previewAutoCatalog,
|
previewAutoCatalog,
|
||||||
} from "@/lib/furni/auto-catalog";
|
} from "@/lib/furni/auto-catalog";
|
||||||
|
import { readImportResponse } from "@/lib/furni/import-response";
|
||||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { readSseStream } from "@/lib/sse-client";
|
import { readSseStream } from "@/lib/sse-client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -690,7 +691,10 @@ export function StudioClient({
|
|||||||
try {
|
try {
|
||||||
const res = await adminFetch("/api/admin/import/furni", {
|
const res = await adminFetch("/api/admin/import/furni", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
repairExisting: true,
|
repairExisting: true,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -706,8 +710,8 @@ export function StudioClient({
|
|||||||
translate && translateLangs.length > 0 ? translateLangs : undefined,
|
translate && translateLangs.length > 0 ? translateLangs : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const { status, data } = await readImportResponse(res);
|
||||||
if (res.ok) {
|
if (status >= 200 && status < 300) {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((i) =>
|
prev.map((i) =>
|
||||||
i.classname === item.classname
|
i.classname === item.classname
|
||||||
@@ -727,15 +731,26 @@ export function StudioClient({
|
|||||||
`verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`,
|
`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`);
|
parts.push(`${data.furniDataMissing} furnidata missing`);
|
||||||
}
|
}
|
||||||
toast.success(parts.join(" · "));
|
toast.success(parts.join(" · "));
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || `Import failed for ${item.classname}`);
|
toast.error(
|
||||||
|
typeof data.error === "string"
|
||||||
|
? data.error
|
||||||
|
: `Import failed for ${item.classname}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Network error");
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Import connection failed. Check the furniture status before retrying.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setImportingId(null);
|
setImportingId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user