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:
1 parent
8d1b09f151
commit
f94246e067
4 files changed
+451
-124
No files matched your search
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user