feat: RCON API transport, full furnidata translation with LibreTranslate fallback, remove organize feature, bump per-page limit to 500
CI / check (push) Failing after 7s
CI / release (push) Skipped
CI / deploy (push) Skipped

- Add EMULATOR_MODE=api option with HTTP transport mirroring RCON payload
- buildLocalizedFurniDataFiles now falls back to LibreTranslate for customs (incl. pl/ru/ar/ja)
- Remove Organiseer import button from studio and Reorganize Catalog from import page
- Delete organize API route and PUT handler; remove organizeCatalogAll from catalog-repair.ts
- Increase imported-furni list perPage from 50 to 500
This commit is contained in:
openhands committed 2026-08-22 14:28:06 +02:00
1 parent 5668493485
commit 85b5792d40
8 files changed
+146 -768

No files matched your search

+93 -9
View File
@@ -153,17 +153,101 @@ export class RconClient {
}
}
/**
* HTTP transport for emulators that expose an API instead of RCON's raw-JSON
* TCP protocol. It POSTs the exact same `{"key":...,"data":...}` payload the
* TCP transport writes, so the RconClient command shapes stay identical.
*
* Mirrors the TCP transport's best-effort semantics: a 2xx response is treated
* as delivered (`true`); any non-2xx, network error, or timeout is logged and
* treated as not delivered (`false`).
*/
export function httpTransport(opts: {
url: string;
timeoutMs: number;
apiKey?: string;
apiKeyHeader?: string;
maxRetries: number;
}): RconTransport {
const sendOnce = (payload: RconPayload): Promise<boolean> =>
new Promise((resolve) => {
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (opts.apiKey) {
const header = opts.apiKeyHeader ?? "Authorization";
// Accept either a raw token or a full scheme (e.g. "Bearer xyz").
headers[header] = /\s/.test(opts.apiKey)
? opts.apiKey
: `Bearer ${opts.apiKey}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
fetch(opts.url, {
method: "POST",
headers,
body,
signal: controller.signal,
})
.then((res) => {
clearTimeout(timer);
resolve(res.ok);
})
.catch(() => {
clearTimeout(timer);
resolve(false);
});
});
return async (payload) => {
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
if (await sendOnce(payload)) return true;
if (attempt < opts.maxRetries - 1) {
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
}
}
logger.error("Emulator API delivery failed after retries", {
module: "emulator-api",
key: payload.key,
url: opts.url,
});
return false;
};
}
/**
* Returns the configured emulator transport based on `EMULATOR_MODE`:
* - "rcon" (default): raw-JSON TCP transport.
* - "api": HTTP transport posting the RCON payload to `EMULATOR_API_URL`.
*
* Throws at construction time when api mode is selected without a URL so the
* misconfiguration surfaces immediately instead of on first command.
*/
export function createEmulatorTransport(): RconTransport {
if (env.EMULATOR_MODE === "api") {
if (!env.EMULATOR_API_URL) {
throw new Error("EMULATOR_MODE=api requires EMULATOR_API_URL to be set");
}
return httpTransport({
url: env.EMULATOR_API_URL,
timeoutMs: env.RCON_TIMEOUT_MS,
apiKey: env.EMULATOR_API_KEY,
apiKeyHeader: env.EMULATOR_API_KEY_HEADER,
maxRetries: env.RCON_MAX_RETRIES,
});
}
return tcpTransport({
host: env.RCON_HOST,
port: env.RCON_PORT,
timeoutMs: env.RCON_TIMEOUT_MS,
maxRetries: env.RCON_MAX_RETRIES,
});
}
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
export const rcon =
globalForRcon.rcon ??
new RconClient(
tcpTransport({
host: env.RCON_HOST,
port: env.RCON_PORT,
timeoutMs: env.RCON_TIMEOUT_MS,
maxRetries: env.RCON_MAX_RETRIES,
}),
);
globalForRcon.rcon ?? new RconClient(createEmulatorTransport());
if (env.NODE_ENV !== "production") globalForRcon.rcon = rcon;