Files
Investidor-V2/resources/js/translation/service.ts
T
2026-11-03 15:31:25 +02:00

163 lines
4.5 KiB
TypeScript

import type {
TranslateRequest,
TranslateResponse,
TranslationLanguage,
} from "./types";
import { readLanguageList } from "./utils";
const translationApiBaseUrl = "/api";
const REQUEST_TIMEOUT_MS = 65_000;
const MAX_ATTEMPTS = 3;
export class TranslationRequestError extends Error {
status: number | null;
retryable: boolean;
constructor(message: string, options?: { status?: number | null; retryable?: boolean }) {
super(message);
this.name = "TranslationRequestError";
this.status = options?.status ?? null;
this.retryable = options?.retryable ?? false;
}
}
function isRetryableStatus(status: number): boolean {
return [429, 500, 502, 503, 504].includes(status);
}
async function wait(ms: number): Promise<void> {
await new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function fetchJson<T>(
input: RequestInfo | URL,
init: RequestInit = {},
attempt = 1,
maxAttempts = MAX_ATTEMPTS,
): Promise<T> {
const parentSignal = init.signal;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const forwardAbort = () => controller.abort();
parentSignal?.addEventListener("abort", forwardAbort, { once: true });
try {
const response = await fetch(input, {
...init,
signal: controller.signal,
headers: {
Accept: "application/json",
...(init.headers ?? {}),
},
});
if (!response.ok) {
const retryable = isRetryableStatus(response.status);
if (retryable && attempt < maxAttempts) {
await wait(300 * attempt);
return fetchJson<T>(input, init, attempt + 1, maxAttempts);
}
let message = `Translation request failed with status ${response.status}`;
try {
const payload = (await response.json()) as { message?: string };
if (payload?.message) {
message = payload.message;
}
} catch {
// Ignore JSON parse issues on error responses.
}
throw new TranslationRequestError(message, {
status: response.status,
retryable,
});
}
return (await response.json()) as T;
} catch (error) {
const aborted = controller.signal.aborted;
const isDomAbort = error instanceof DOMException && error.name === "AbortError";
const parentAborted = parentSignal?.aborted ?? false;
const timedOut = aborted && !parentAborted;
const retryable = timedOut || error instanceof TypeError;
if (retryable && attempt < maxAttempts) {
await wait(300 * attempt);
return fetchJson<T>(input, init, attempt + 1, maxAttempts);
}
if (error instanceof TranslationRequestError) {
throw error;
}
if (parentAborted || isDomAbort) {
throw error;
}
if (timedOut) {
throw new TranslationRequestError("Translation request timed out", {
status: null,
retryable,
});
}
throw new TranslationRequestError("Translation request failed", {
status: null,
retryable,
});
} finally {
window.clearTimeout(timeoutId);
parentSignal?.removeEventListener("abort", forwardAbort);
}
}
export async function getLanguages(signal?: AbortSignal): Promise<TranslationLanguage[]> {
const payload = await fetchJson<unknown>(`${translationApiBaseUrl}/translate/languages`, {
method: "GET",
signal,
}, 1, 1);
return readLanguageList(payload);
}
export async function translateText(
request: TranslateRequest,
signal?: AbortSignal,
): Promise<TranslateResponse> {
const payload = await fetchJson<{
original_text?: string;
translated_text?: string;
source_lang?: string;
target_lang?: string;
}>(`${translationApiBaseUrl}/translate`, {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text: request.text,
source_lang: request.sourceLanguage,
target_lang: request.targetLanguage,
is_html: request.isHtml ?? false,
}),
}, 1, 1);
if (typeof payload.translated_text !== "string" || payload.translated_text.trim() === "") {
throw new TranslationRequestError("Translation response is missing translated_text", {
status: 200,
retryable: false,
});
}
return {
originalText: payload.original_text ?? request.text,
translatedText: payload.translated_text,
sourceLanguage: payload.source_lang ?? request.sourceLanguage,
targetLanguage: payload.target_lang ?? request.targetLanguage,
};
}