perf: offload SWF→Nitro conversion to a worker-thread pool
CI / check (push) Successful in 34s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m0s

The synchronous convertSwfToNitro / extractIconFromSwf calls (pure CPU,
no DB/network) now run in a small worker pool so the main Node event
loop stays free during imports — the dominant import-time CPU spike is
moved off the request thread. The pool degrades gracefully to a
synchronous fallback if workers can't be created, and is skipped in the
test environment.
This commit is contained in:
openhands committed 2026-08-24 19:02:19 +02:00
1 parent 7116f49e2b
commit 8d1b09f151
6 files changed
+239 -13

No files matched your search

+14 -4
View File
@@ -24,10 +24,20 @@ vi.mock("@/lib/services/import/core/download", () => ({
validateSwfBytes: vi.fn(), validateSwfBytes: vi.fn(),
})); }));
vi.mock("@/lib/services/swf-to-nitro", () => ({ vi.mock("@/lib/services/swf-to-nitro", () => {
convertSwfToNitro: vi.fn(), const convertSwfToNitro = vi.fn();
extractIconFromSwf: vi.fn(), const extractIconFromSwf = vi.fn();
})); return {
convertSwfToNitro,
extractIconFromSwf,
convertSwfToNitroAsync: vi.fn(async (buffer: Buffer, classname: string) =>
convertSwfToNitro(buffer, classname),
),
extractIconFromSwfAsync: vi.fn(async (buffer: Buffer, classname: string) =>
extractIconFromSwf(buffer, classname),
),
};
});
import { import {
autoPriceFurni, autoPriceFurni,
+7 -4
View File
@@ -34,8 +34,8 @@ import {
} from "@/lib/services/import/core/download"; } from "@/lib/services/import/core/download";
import { import {
type ConversionResult, type ConversionResult,
convertSwfToNitro, convertSwfToNitroAsync,
extractIconFromSwf, extractIconFromSwfAsync,
parseNitroBundle, parseNitroBundle,
} from "@/lib/services/swf-to-nitro"; } from "@/lib/services/swf-to-nitro";
import type { ImportSingleResult } from "@/types/furni"; import type { ImportSingleResult } from "@/types/furni";
@@ -766,7 +766,7 @@ export async function importSingleFurni(params: {
if (!iconOk && (swfOk || existsSync(/*turbopackIgnore: true*/ swfPath))) { if (!iconOk && (swfOk || existsSync(/*turbopackIgnore: true*/ swfPath))) {
try { try {
const swfBuf = await fs.readFile(/*turbopackIgnore: true*/ swfPath); const swfBuf = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const iconPng = extractIconFromSwf(swfBuf, baseClassname); const iconPng = await extractIconFromSwfAsync(swfBuf, baseClassname);
if (iconPng) { if (iconPng) {
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, iconPng); await fs.writeFile(/*turbopackIgnore: true*/ iconPath, iconPng);
iconOk = true; iconOk = true;
@@ -795,7 +795,10 @@ export async function importSingleFurni(params: {
try { try {
if (existsSync(/*turbopackIgnore: true*/ swfPath)) { if (existsSync(/*turbopackIgnore: true*/ swfPath)) {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath); const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
conversionResult = convertSwfToNitro(swfBuffer, baseClassname); conversionResult = await convertSwfToNitroAsync(
swfBuffer,
baseClassname,
);
await fs.writeFile( await fs.writeFile(
/*turbopackIgnore: true*/ nitroPath, /*turbopackIgnore: true*/ nitroPath,
conversionResult.bundle, conversionResult.bundle,
+18 -5
View File
@@ -43,8 +43,8 @@ vi.mock("@/lib/services/import/core/download", async () => {
}; };
}); });
vi.mock("@/lib/services/swf-to-nitro", () => ({ vi.mock("@/lib/services/swf-to-nitro", () => {
convertSwfToNitro: vi.fn(() => ({ const convertSwfToNitro = vi.fn((_buffer: Buffer, _classname: string) => ({
bundle: Buffer.alloc(256, 0x42), bundle: Buffer.alloc(256, 0x42),
classname: "n/a", classname: "n/a",
dimensions: { x: 1, y: 1, z: 1 }, dimensions: { x: 1, y: 1, z: 1 },
@@ -61,10 +61,23 @@ vi.mock("@/lib/services/swf-to-nitro", () => ({
defaultdir: 0, defaultdir: 0,
partcolors: { color: [] }, partcolors: { color: [] },
}, },
})),
extractIconFromSwf: vi.fn(() => Buffer.from("fake-icon")),
parseNitroBundle: vi.fn(() => ({ json: null })),
})); }));
const extractIconFromSwf = vi.fn((_buffer: Buffer, _classname: string) =>
Buffer.from("fake-icon"),
);
const parseNitroBundle = vi.fn(() => ({ json: null }));
return {
convertSwfToNitro,
extractIconFromSwf,
parseNitroBundle,
convertSwfToNitroAsync: vi.fn(async (buffer: Buffer, classname: string) =>
convertSwfToNitro(buffer, classname),
),
extractIconFromSwfAsync: vi.fn(async (buffer: Buffer, classname: string) =>
extractIconFromSwf(buffer, classname),
),
};
});
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({ vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
lookupOfficialHabboFurni: vi.fn(async () => null), lookupOfficialHabboFurni: vi.fn(async () => null),
File diff suppressed because it is too large. Load diff
+55
View File
@@ -0,0 +1,55 @@
import { parentPort } from "node:worker_threads";
import { convertSwfToNitro, extractIconFromSwf } from "./index";
export interface ConvertRequest {
id: number;
type: "convert";
buffer: Buffer;
classname: string;
}
export interface IconRequest {
id: number;
type: "icon";
buffer: Buffer;
classname: string;
}
export type WorkerRequest = ConvertRequest | IconRequest;
export type WorkerResponse =
| {
id: number;
ok: true;
result?: import("./index").ConversionResult;
icon?: Buffer | null;
}
| { id: number; ok: false; error: string };
if (parentPort) {
parentPort.on("message", (msg: WorkerRequest) => {
try {
if (msg.type === "convert") {
const result = convertSwfToNitro(msg.buffer, msg.classname);
parentPort!.postMessage({
id: msg.id,
ok: true,
result,
} satisfies WorkerResponse);
} else {
const icon = extractIconFromSwf(msg.buffer, msg.classname);
parentPort!.postMessage({
id: msg.id,
ok: true,
icon: icon ?? null,
} satisfies WorkerResponse);
}
} catch (err) {
parentPort!.postMessage({
id: msg.id,
ok: false,
error: (err as Error).message,
} satisfies WorkerResponse);
}
});
}
+5
View File
@@ -50,6 +50,11 @@ export type {
ConversionResult, ConversionResult,
FurniMetadata, FurniMetadata,
} from "@/types/furni"; } from "@/types/furni";
// Worker-pool wrappers — run the CPU-heavy conversion off the main thread.
export {
convertSwfToNitroAsync,
extractIconFromSwfAsync,
} from "./conversion-pool";
// Re-export public API from submodules // Re-export public API from submodules
export { createNitroBundle, parseNitroBundle } from "./nitro-builder"; export { createNitroBundle, parseNitroBundle } from "./nitro-builder";
export { computeDefaultDir, extractPartColors } from "./xml-processor"; export { computeDefaultDir, extractPartColors } from "./xml-processor";