163 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
}
|