Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 | 4x 4x 25x 25x 25x 14x 14x 12x 9x 9x 4x 5x 5x 3x 11x 11x 10x 8x 8x 8x 14x 14x 3x 11x 11x 11x 8x 3x 4x | /**
* Translation provider chain (SHY-0072): unofficial Google gtx FIRST,
* self-hosted LibreTranslate fallback (operator decision 2026-06-10 —
* "google first, libre fallback").
*
* gtx reality (architect-verified live 2026-06-10): the endpoint answers
* `text/plain` whose body is JSON shaped
* [[["<translated>","<source>",null,null,10]],null,"<src-lang>",...]
* so parsing is response.text() + JSON.parse with the translation at
* body[0][0][0]. It accepts ONE text per call — batch callers fan out
* with Promise.allSettled (each call individually time-bounded, so any
* batch resolves in ~TIMEOUT_MS, never N×TIMEOUT).
*
* A wrong-shape body is a SOFT failure that falls through the chain —
* this property is load-bearing: the legacy chat tests use a catch-all
* fetch mock returning LibreTranslate's shape, which must make gtx
* fall through and LibreTranslate serve, keeping those tests green
* unchanged through the endpoint merge.
*
* Failures never throw: callers get { ok:false, reason } and own the
* fail-silent/502 decision per their contract.
*/
const GTX_URL = 'https://translate.googleapis.com/translate_a/single';
const TIMEOUT_MS = 3000;
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) => {
const t = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms);
Eif (typeof t.unref === 'function') t.unref();
}),
]);
}
async function tryGtx(text, target, fetchImpl) {
const params = new URLSearchParams({ client: 'gtx', sl: 'auto', tl: target, dt: 't', q: text });
const resp = await withTimeout(fetchImpl(`${GTX_URL}?${params.toString()}`), TIMEOUT_MS, 'gtx');
if (!resp.ok) throw new Error(`gtx ${resp.status}`);
// Content-Type is text/plain — manual parse, never resp.json().
let body;
try {
body = JSON.parse(await resp.text());
} catch {
throw new Error('gtx malformed body (JSON.parse failure)');
}
const translated = body?.[0]?.[0]?.[0];
if (typeof translated !== 'string') throw new Error('gtx malformed shape');
return { translated, detectedSourceLang: typeof body?.[2] === 'string' ? body[2] : 'unknown' };
}
async function tryLibreTranslate(text, target, fetchImpl) {
const base = process.env.LIBRETRANSLATE_URL || 'http://localhost:5000';
const resp = await withTimeout(
fetchImpl(`${base}/translate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: text, source: 'auto', target }),
}),
TIMEOUT_MS,
'libretranslate',
);
if (!resp.ok) throw new Error(`libretranslate ${resp.status}`);
const data = await resp.json();
Iif (typeof data?.translatedText !== 'string') throw new Error('libretranslate malformed shape');
return {
translated: data.translatedText,
detectedSourceLang: data.detectedLanguage?.language || 'unknown',
};
}
/**
* Translate one text through the chain.
* @returns {Promise<{ok:true, translated:string, provider:string, detectedSourceLang:string}
* | {ok:false, reason:string}>}
*/
async function translateOne(text, target, { fetchImpl = globalThis.fetch } = {}) {
let gtxReason;
try {
const r = await tryGtx(text, target, fetchImpl);
return { ok: true, provider: 'gtx', ...r };
} catch (err) {
gtxReason = err.message;
}
try {
const r = await tryLibreTranslate(text, target, fetchImpl);
return { ok: true, provider: 'libretranslate', ...r };
} catch (err) {
return { ok: false, reason: `gtx: ${gtxReason}; libretranslate: ${err.message}` };
}
}
module.exports = { translateOne };
|