perf: offload FurnitureData translation build to a worker thread

The translation build's CPU-heavy work (deep clone of the 100k+ entry
master, the two full scans and JSON.stringify per language) now runs in a
worker thread, so "Alles vertalen" no longer blocks the main event loop.
Network/DB parts (official Habbo fetch, LibreTranslate, file writes) stay
on the main thread. The pure helpers were extracted to furni-data-i18n-core
(no server-only deps) so the worker can import them. Falls back to the same
helpers on the main thread when no worker is available or in tests.
This commit is contained in:
openhands committed 2026-08-24 19:10:33 +02:00
1 parent 8d1b09f151
commit f94246e067
4 files changed
+451 -124

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
import { Worker } from "node:worker_threads";
import type { Translation } from "./furni-data-i18n-core";
/**
* Runs the CPU-heavy FurnitureData localization (deep clone + 100k-entry scans +
* JSON.stringify) in a single worker thread so the main Node event loop stays
* free during a "translate everything" build. Network/DB work (fetching official
* Habbo translations, calling LibreTranslate, writing files) stays on the main
* thread; only the synchronous CPU is offloaded.
*
* Degrades gracefully: in the test environment (or if the worker can't be
* created) getTranslationWorker() returns null and callers run the same pure
* helpers on the main thread instead.
*/
interface InitReq {
id: number;
type: "init";
masterJson: string;
hotelMaps: Record<string, Map<string, Translation>>;
}
interface PrepareReq {
id: number;
type: "prepare";
lang: string;
hotel: string;
}
interface FinalizeReq {
id: number;
type: "finalize";
lang: string;
translations: Map<string, string>;
}
type Req = InitReq | PrepareReq | FinalizeReq;
type ReqWithoutId =
| Omit<InitReq, "id">
| Omit<PrepareReq, "id">
| Omit<FinalizeReq, "id">;
type Res =
| {
id: number;
ok: true;
pendingTexts?: string[];
json?: string;
translatedRoom?: number;
translatedWall?: number;
total?: number;
}
| { id: number; ok: false; error: string };
let worker: Worker | null = null;
let broken = false;
let nextId = 1;
const pending = new Map<
number,
{ resolve: (r: Res) => void; reject: (e: Error) => void }
>();
function isTestEnv(): boolean {
return process.env.NODE_ENV === "test" || process.env.VITEST !== undefined;
}
function spawn(): Worker | null {
try {
const w = new Worker(new URL("./translation-worker.ts", import.meta.url));
w.on("message", (res: Res) => {
const p = pending.get(res.id);
if (!p) return;
pending.delete(res.id);
if (res.ok) p.resolve(res);
else p.reject(new Error(res.error));
});
w.on("error", (err: Error) => {
broken = true;
for (const [, p] of pending) p.reject(err);
pending.clear();
});
return w;
} catch {
broken = true;
return null;
}
}
function call(req: ReqWithoutId): Promise<Res> {
if (!worker) return Promise.reject(new Error("no translation worker"));
return new Promise<Res>((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker!.postMessage({ ...req, id } as Req);
});
}
export interface TranslationWorker {
init(
masterJson: string,
hotelMaps: Record<string, Map<string, Translation>>,
): Promise<void>;
prepare(lang: string, hotel: string): Promise<string[]>;
finalize(
lang: string,
translations: Map<string, string>,
): Promise<{
json: string;
translatedRoom: number;
translatedWall: number;
total: number;
}>;
}
export function getTranslationWorker(): TranslationWorker | null {
if (isTestEnv() || broken) return null;
if (!worker) worker = spawn();
if (!worker) return null;
return {
async init(masterJson, hotelMaps) {
const res = await call({ type: "init", masterJson, hotelMaps });
if (!res.ok) throw new Error(res.error);
},
async prepare(lang, hotel) {
const res = await call({ type: "prepare", lang, hotel });
if (!res.ok || !res.pendingTexts)
throw new Error(res.ok ? "missing pendingTexts" : res.error);
return res.pendingTexts;
},
async finalize(lang, translations) {
const res = await call({ type: "finalize", lang, translations });
if (!res.ok || res.json === undefined)
throw new Error(res.ok ? "missing json" : res.error);
return {
json: res.json,
translatedRoom: res.translatedRoom ?? 0,
translatedWall: res.translatedWall ?? 0,
total: res.total ?? 0,
};
},
};
}