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 { await new Promise((resolve) => window.setTimeout(resolve, ms)); } async function fetchJson( input: RequestInfo | URL, init: RequestInit = {}, attempt = 1, maxAttempts = MAX_ATTEMPTS, ): Promise { 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(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(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 { const payload = await fetchJson(`${translationApiBaseUrl}/translate/languages`, { method: "GET", signal, }, 1, 1); return readLanguageList(payload); } export async function translateText( request: TranslateRequest, signal?: AbortSignal, ): Promise { 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, }; }