/** * Scheer Enterprise Translation & Transcription Tool — local proxy server. * * Why this file exists: the DeepL API blocks direct calls from browser * JavaScript (CORS), and your API key must never sit in public client code. * This tiny Node server (no external dependencies, uses Node 18+ built-in * fetch/FormData/Blob) does two things: * 1. Serves the single-page UI (index.html) * 2. Relays translate/document/usage requests to DeepL using the API key * configured here on the server (via DEEPL_API_KEY env var or a local * .env file). The key is never sent to or stored in the browser. * * Setup: copy .env.example to .env and fill in DEEPL_API_KEY, or export * DEEPL_API_KEY as an environment variable before starting. * Run: node server.js * Open: http://localhost:3000 */ const http = require('http'); const fs = require('fs'); const path = require('path'); // ---- Minimal .env loader (no dependency) ------------------------------- // Reads KEY=VALUE lines from a .env file next to this script, without // overriding variables already set in the real environment. (function loadDotEnv() { const envPath = path.join(__dirname, '.env'); if (!fs.existsSync(envPath)) return; const lines = fs.readFileSync(envPath, 'utf-8').split('\n'); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const idx = trimmed.indexOf('='); if (idx === -1) continue; const key = trimmed.slice(0, idx).trim(); let value = trimmed.slice(idx + 1).trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } if (!(key in process.env)) process.env[key] = value; } })(); const PORT = process.env.PORT || 3000; const DEEPL_API_KEY = process.env.DEEPL_API_KEY || ''; if (!DEEPL_API_KEY) { console.warn( '\n[warning] DEEPL_API_KEY is not set. Set it in a .env file (see .env.example) ' + 'or as an environment variable before running the server. All translation ' + 'requests will fail until it is configured.\n' ); } function baseUrlFor(apiKey) { return apiKey && apiKey.trim().endsWith(':fx') ? 'https://api-free.deepl.com' : 'https://api.deepl.com'; } // ---- Domain customization (glossaries) --------------------------------- // Each of the four Scheer entities gets two fixed-direction glossaries — // German -> English and English -> German — persisted locally in // domains.json (which glossary_id belongs to which domain/direction; the // actual glossary content lives on DeepL's servers). domains.json lives on // the server disk, not in any user's browser, so every visitor to the site // sees and edits the same shared glossaries. // The path is overridable via DOMAINS_FILE so a container deployment can point // it at a mounted volume (e.g. /app/data/domains.json) instead of the image's // own filesystem, which is recreated on every redeploy. const DOMAINS_FILE = process.env.DOMAINS_FILE || path.join(__dirname, 'domains.json'); const VALID_DOMAINS = ['scheer-group', 'scheer-ids', 'scheer-pas', 'scheer-imc']; const DOMAIN_LABELS = { 'scheer-group': 'Scheer Group', 'scheer-ids': 'Scheer IDS', 'scheer-pas': 'Scheer PAS', 'scheer-imc': 'Scheer IMC', }; const GLOSSARY_DIRECTIONS = { 'de-en': { source_lang: 'DE', target_lang: 'EN', label: 'German → English' }, 'en-de': { source_lang: 'EN', target_lang: 'DE', label: 'English → German' }, }; function emptyDomain(id) { return { label: DOMAIN_LABELS[id], glossaries: { 'de-en': null, 'en-de': null } }; } function loadDomains() { let data = {}; if (fs.existsSync(DOMAINS_FILE)) { try { data = JSON.parse(fs.readFileSync(DOMAINS_FILE, 'utf-8')); } catch (e) { data = {}; } } let changed = false; for (const id of VALID_DOMAINS) { if (!data[id]) { data[id] = emptyDomain(id); changed = true; continue; } const d = data[id]; // Drop retired style-rules/translation-memory fields from older versions. if ('style' in d || 'translationMemory' in d) { delete d.style; delete d.translationMemory; changed = true; } // Migrate the old single "glossary" field (one selectable language // pair) into the new fixed two-direction "glossaries" map. if ('glossary' in d) { if (!d.glossaries) d.glossaries = { 'de-en': null, 'en-de': null }; const g = d.glossary; if (g && g.source_lang && g.target_lang) { const src = String(g.source_lang).toUpperCase(); const tgt = String(g.target_lang).split('-')[0].toUpperCase(); if (src === 'DE' && tgt === 'EN') d.glossaries['de-en'] = g; else if (src === 'EN' && tgt === 'DE') d.glossaries['en-de'] = g; // Any other legacy direction is no longer representable and is dropped. } delete d.glossary; changed = true; } if (!d.glossaries) { d.glossaries = { 'de-en': null, 'en-de': null }; changed = true; } for (const dir of Object.keys(GLOSSARY_DIRECTIONS)) { if (!(dir in d.glossaries)) { d.glossaries[dir] = null; changed = true; } } } if (changed || !fs.existsSync(DOMAINS_FILE)) saveDomains(data); return data; } function saveDomains(data) { fs.mkdirSync(path.dirname(DOMAINS_FILE), { recursive: true }); fs.writeFileSync(DOMAINS_FILE, JSON.stringify(data, null, 2)); } // Decide whether one of a domain's two glossaries applies to a given // translation request (the request's language pair has to exactly match // the fixed direction the glossary was created for), and build the extra // DeepL params. function computeDomainParams(domains, domainId, sourceLang, targetLang) { const applied = { glossary: false }; const extra = {}; const d = domainId && domains[domainId]; if (!d || !d.glossaries) return { extra, applied }; const targetBase = String(targetLang || '').split('-')[0].toUpperCase(); const srcUpper = sourceLang ? String(sourceLang).toUpperCase() : ''; for (const [dir, dirCfg] of Object.entries(GLOSSARY_DIRECTIONS)) { const g = d.glossaries[dir]; if (g && g.id && srcUpper === dirCfg.source_lang && targetBase === dirCfg.target_lang) { extra.glossary_id = g.id; applied.glossary = true; break; } } return { extra, applied }; } async function handleGetDomains(req, res) { sendJson(res, 200, loadDomains()); } async function handleSaveGlossary(req, res, domainId, direction) { if (!VALID_DOMAINS.includes(domainId)) return sendJson(res, 400, { error: 'Unknown domain.' }); if (!GLOSSARY_DIRECTIONS[direction]) return sendJson(res, 400, { error: 'Unknown glossary direction.' }); if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' }); const { entries } = await parseJsonBody(req); if (!Array.isArray(entries) || entries.length === 0) { return sendJson(res, 400, { error: 'At least one glossary entry is required.' }); } const cleanEntries = entries .filter((e) => e && e.source != null && e.target != null) .map((e) => ({ source: String(e.source).trim(), target: String(e.target).trim() })) .filter((e) => e.source && e.target); if (cleanEntries.length === 0) return sendJson(res, 400, { error: 'No valid (source, target) entries provided.' }); const { source_lang, target_lang, label } = GLOSSARY_DIRECTIONS[direction]; const name = `${DOMAIN_LABELS[domainId]} ${label} Glossary`; const tsv = cleanEntries.map((e) => `${e.source}\t${e.target}`).join('\n'); const base = baseUrlFor(DEEPL_API_KEY); // Delete the old glossary for this domain/direction FIRST, before creating // the replacement. DeepL accounts have a cap on total glossary count // ("Too many glossaries" / 456 error), so freeing this slot up front // avoids briefly needing N+1 slots for what is logically still N // glossaries (one per domain per direction). const domains = loadDomains(); const existing = domains[domainId].glossaries[direction]; if (existing && existing.id) { const delResp = await fetch(`${base}/v2/glossaries/${existing.id}`, { method: 'DELETE', headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` }, }).catch(() => null); // A 404 here just means it was already gone (e.g. deleted manually on // DeepL's side) - fine to proceed. Other failures are logged but not // fatal; we still attempt the create below. if (delResp && !delResp.ok && delResp.status !== 404) { console.warn(`Warning: failed to delete old glossary ${existing.id} (status ${delResp.status}) before replacing it.`); } } const createResp = await fetch(`${base}/v2/glossaries`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, source_lang, target_lang, entries: tsv, entries_format: 'tsv' }), }); if (!createResp.ok) { // The old glossary is already gone at this point, so make sure that's // reflected in domains.json even though the new save failed, instead of // leaving a dangling reference to a now-deleted glossary ID. if (existing && existing.id) { domains[domainId].glossaries[direction] = null; saveDomains(domains); } const detail = await forwardDeepLError(createResp); // DeepL answers 456 "Too many glossaries" once the account's glossary // allowance is used up. On the API Free plan that allowance is a single // glossary for the whole account, so this fires as soon as a second // domain/direction is used - which reads like an app bug unless we say // which glossary is holding the only slot. if (createResp.status === 456 && /too many glossaries/i.test(detail)) { return sendJson(res, 456, { error: await describeGlossaryLimit(base, domainId, direction), }); } return sendJson(res, createResp.status, { error: detail }); } const created = await createResp.json(); domains[domainId].glossaries[direction] = { id: created.glossary_id, name: created.name, source_lang: created.source_lang, target_lang: created.target_lang, entry_count: created.entry_count, entries: cleanEntries, }; saveDomains(domains); sendJson(res, 200, domains[domainId].glossaries[direction]); } async function handleDeleteGlossary(req, res, domainId, direction) { if (!VALID_DOMAINS.includes(domainId)) return sendJson(res, 400, { error: 'Unknown domain.' }); if (!GLOSSARY_DIRECTIONS[direction]) return sendJson(res, 400, { error: 'Unknown glossary direction.' }); const domains = loadDomains(); const existing = domains[domainId].glossaries[direction]; if (existing && existing.id && DEEPL_API_KEY) { const base = baseUrlFor(DEEPL_API_KEY); await fetch(`${base}/v2/glossaries/${existing.id}`, { method: 'DELETE', headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` }, }).catch(() => {}); } domains[domainId].glossaries[direction] = null; saveDomains(domains); sendJson(res, 200, { ok: true }); } // ---- Account-level glossary management --------------------------------- // The endpoints above only reach glossaries that domains.json already knows // about. That is not the whole picture: glossaries can exist on the DeepL // account without being tracked here (created before this app, left over // from testing, or simply because domains.json was reset - a fresh // deployment volume starts empty). Those untracked glossaries still occupy // the account's glossary allowance, so on a small plan they can block every // save while being invisible and unreachable from the UI. // // These three endpoints work against the DeepL account directly, so an // admin can see what is really there, delete what is not needed, and pull an // untracked glossary back under a domain. // Which of the two supported directions a glossary's language pair maps to, // or null for a pair this app does not model (e.g. FR->EN). function directionForLangs(sourceLang, targetLang) { const s = String(sourceLang || '').toUpperCase(); const t = String(targetLang || '').split('-')[0].toUpperCase(); if (s === 'DE' && t === 'EN') return 'de-en'; if (s === 'EN' && t === 'DE') return 'en-de'; return null; } // glossary_id -> which domain/direction currently references it. function trackedGlossaryOwners(domains) { const owners = {}; for (const [domainId, d] of Object.entries(domains)) { for (const [direction, g] of Object.entries((d && d.glossaries) || {})) { if (g && g.id) { owners[g.id] = { domain: domainId, domainLabel: d.label, direction }; } } } return owners; } async function handleListAccountGlossaries(req, res) { if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' }); const base = baseUrlFor(DEEPL_API_KEY); const resp = await fetch(`${base}/v2/glossaries`, { headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` }, }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); const { glossaries } = await resp.json(); const owners = trackedGlossaryOwners(loadDomains()); sendJson(res, 200, { glossaries: (glossaries || []).map((g) => ({ glossary_id: g.glossary_id, name: g.name, source_lang: g.source_lang, target_lang: g.target_lang, entry_count: g.entry_count, creation_time: g.creation_time, ready: g.ready, // null when no domain references it - i.e. it is consuming a slot // without being editable through the normal domain settings. tracked: owners[g.glossary_id] || null, // null when the language pair is outside the de<->en pairs this app // models, in which case it can be deleted but not adopted. direction: directionForLangs(g.source_lang, g.target_lang), })), }); } async function handleDeleteAccountGlossary(req, res, glossaryId) { if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' }); const base = baseUrlFor(DEEPL_API_KEY); const resp = await fetch(`${base}/v2/glossaries/${encodeURIComponent(glossaryId)}`, { method: 'DELETE', headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` }, }); // 404 means it is already gone on DeepL's side; still worth clearing any // local reference to it below, so treat it as success. if (!resp.ok && resp.status !== 404) { return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); } const domains = loadDomains(); const owner = trackedGlossaryOwners(domains)[glossaryId]; if (owner) { domains[owner.domain].glossaries[owner.direction] = null; saveDomains(domains); } sendJson(res, 200, { ok: true, untracked: owner || null }); } // Pulls an existing DeepL glossary (including its entries) into a domain // slot, so it becomes editable through the normal domain settings instead of // sitting on the account unreachable. async function handleAdoptGlossary(req, res, glossaryId) { if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' }); const { domain: domainId } = await parseJsonBody(req); if (!VALID_DOMAINS.includes(domainId)) return sendJson(res, 400, { error: 'Unknown domain.' }); const base = baseUrlFor(DEEPL_API_KEY); const metaResp = await fetch(`${base}/v2/glossaries/${encodeURIComponent(glossaryId)}`, { headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` }, }); if (!metaResp.ok) return sendJson(res, metaResp.status, { error: await forwardDeepLError(metaResp) }); const meta = await metaResp.json(); const direction = directionForLangs(meta.source_lang, meta.target_lang); if (!direction) { return sendJson(res, 400, { error: `This glossary translates ${meta.source_lang} → ${meta.target_lang}, which is not one of the two directions this tool manages (German → English, English → German). You can delete it to free a slot, but not load it into a domain.`, }); } const entriesResp = await fetch(`${base}/v2/glossaries/${encodeURIComponent(glossaryId)}/entries`, { headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}`, Accept: 'text/tab-separated-values' }, }); if (!entriesResp.ok) return sendJson(res, entriesResp.status, { error: await forwardDeepLError(entriesResp) }); const entries = (await entriesResp.text()) .split('\n') .map((line) => line.split('\t')) .filter((parts) => parts.length >= 2 && parts[0].trim() && parts[1].trim()) .map((parts) => ({ source: parts[0].trim(), target: parts[1].trim() })); const domains = loadDomains(); // Refuse to silently overwrite a different glossary already in this slot - // that would orphan it on the account, which is the exact problem this // whole section exists to clean up. const occupying = domains[domainId].glossaries[direction]; if (occupying && occupying.id && occupying.id !== glossaryId) { return sendJson(res, 409, { error: `${DOMAIN_LABELS[domainId]} (${GLOSSARY_DIRECTIONS[direction].label}) already has a glossary. Delete that one first, then load this glossary in.`, }); } // A glossary must be referenced by at most one domain/direction. If some // other domain already points at it, this is a move: release the old slot // rather than leaving two domains aliasing one glossary, where editing via // one silently replaces what the other still claims to use. const previousOwner = trackedGlossaryOwners(domains)[glossaryId]; const moved = previousOwner && !(previousOwner.domain === domainId && previousOwner.direction === direction) ? previousOwner : null; if (moved) domains[moved.domain].glossaries[moved.direction] = null; domains[domainId].glossaries[direction] = { id: meta.glossary_id, name: meta.name, source_lang: meta.source_lang, target_lang: meta.target_lang, entry_count: entries.length, entries, }; saveDomains(domains); sendJson(res, 200, { domain: domainId, direction, movedFrom: moved ? { domain: moved.domain, domainLabel: moved.domainLabel, direction: moved.direction } : null, glossary: domains[domainId].glossaries[direction], }); } function sendJson(res, status, obj) { const body = JSON.stringify(obj); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), }); res.end(body); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (c) => { size += c.length; if (size > 60 * 1024 * 1024) { reject(new Error('Payload too large (max 60MB)')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } async function parseJsonBody(req) { const raw = await readBody(req); if (!raw.length) return {}; try { return JSON.parse(raw.toString('utf-8')); } catch (e) { const err = new Error('Invalid JSON body'); err.statusCode = 400; throw err; } } // Turns DeepL's bare "Too many glossaries" into something the person in the // browser can act on: which glossaries already occupy the account's slots, // and what the two ways out are. Falls back to a generic message if the // account can't be listed for some reason. async function describeGlossaryLimit(base, domainId, direction) { const wanted = `${DOMAIN_LABELS[domainId]} (${GLOSSARY_DIRECTIONS[direction].label})`; let inUse = []; try { const resp = await fetch(`${base}/v2/glossaries`, { headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` }, }); if (resp.ok) inUse = (await resp.json()).glossaries || []; } catch (_) { /* fall through to the generic wording below */ } const listed = inUse.length ? ` Currently in use: ${inUse.map((g) => `"${g.name}"`).join(', ')}.` : ''; return ( `Your DeepL plan's glossary limit is already reached, so the glossary for ` + `${wanted} could not be created.${listed} ` + `Delete a glossary you no longer need to free up a slot, or upgrade the ` + `DeepL API plan (the Free plan allows only one glossary per account, ` + `which is not enough for one glossary per domain and direction).` ); } async function forwardDeepLError(resp) { let detail = ''; try { detail = await resp.text(); } catch (_) { /* ignore */ } return `DeepL API error (${resp.status} ${resp.statusText}): ${detail || 'no detail'}`; } // ---- Route handlers --------------------------------------------------- async function handleTranslate(req, res) { const { texts, target_lang, source_lang, domain } = await parseJsonBody(req); const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); if (!Array.isArray(texts) || texts.length === 0) { return sendJson(res, 400, { error: 'No text provided to translate.' }); } if (!target_lang) return sendJson(res, 400, { error: 'Missing target_lang.' }); const domains = loadDomains(); const { extra, applied } = computeDomainParams(domains, domain, source_lang, target_lang); const base = baseUrlFor(apiKey); const params = new URLSearchParams(); for (const t of texts) params.append('text', t); params.append('target_lang', target_lang); if (source_lang) params.append('source_lang', source_lang); params.append('preserve_formatting', '1'); if (extra.glossary_id) params.append('glossary_id', extra.glossary_id); const resp = await fetch(`${base}/v2/translate`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${apiKey}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: params, }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); const data = await resp.json(); data.applied = applied; sendJson(res, 200, data); } async function handleUsage(req, res) { await parseJsonBody(req); // drain any request body (unused — key comes from server env) const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); const base = baseUrlFor(apiKey); const resp = await fetch(`${base}/v2/usage`, { headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); sendJson(res, 200, await resp.json()); } async function handleDocumentUpload(req, res) { const { filename, fileBase64, target_lang, source_lang, domain } = await parseJsonBody(req); const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); if (!fileBase64 || !filename) return sendJson(res, 400, { error: 'Missing file data.' }); if (!target_lang) return sendJson(res, 400, { error: 'Missing target_lang.' }); const domains = loadDomains(); const { extra, applied } = computeDomainParams(domains, domain, source_lang, target_lang); const base = baseUrlFor(apiKey); const buf = Buffer.from(fileBase64, 'base64'); const blob = new Blob([buf]); const form = new FormData(); form.append('file', blob, filename); form.append('target_lang', target_lang); if (source_lang) form.append('source_lang', source_lang); if (extra.glossary_id) form.append('glossary_id', extra.glossary_id); const resp = await fetch(`${base}/v2/document`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, body: form, }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); const data = await resp.json(); data.applied = applied; sendJson(res, 200, data); } async function handleDocumentStatus(req, res) { const { document_id, document_key } = await parseJsonBody(req); const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); if (!document_id || !document_key) { return sendJson(res, 400, { error: 'Missing document_id or document_key.' }); } const base = baseUrlFor(apiKey); const resp = await fetch(`${base}/v2/document/${encodeURIComponent(document_id)}`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ document_key }), }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); sendJson(res, 200, await resp.json()); } async function handleDocumentResult(req, res) { const { document_id, document_key } = await parseJsonBody(req); const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); if (!document_id || !document_key) { return sendJson(res, 400, { error: 'Missing document_id or document_key.' }); } const base = baseUrlFor(apiKey); const resp = await fetch(`${base}/v2/document/${encodeURIComponent(document_id)}/result`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ document_key }), }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); const arrayBuf = await resp.arrayBuffer(); sendJson(res, 200, { fileBase64: Buffer.from(arrayBuf).toString('base64') }); } // ---- Static file + router --------------------------------------------- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css' }; function serveStatic(req, res) { const urlPath = req.url.split('?')[0]; const file = urlPath === '/' ? 'index.html' : urlPath.replace(/^\//, ''); const filePath = path.join(__dirname, file); if (!filePath.startsWith(__dirname)) return res.writeHead(403).end('Forbidden'); fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } const ext = path.extname(filePath); res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', // Prevent browsers/proxies from caching a stale copy of the app shell — // an outdated cached index.html calling routes that no longer exist on // a newer server.js is a common source of confusing 404s after updates. 'Cache-Control': 'no-cache, no-store, must-revalidate', }); res.end(data); }); } // Liveness probe for container platforms (Docker HEALTHCHECK, Dokploy). // Deliberately does not call DeepL: it answers "is this process serving?", // not "is the upstream API up", so a DeepL outage doesn't restart the app. function handleHealth(req, res) { sendJson(res, 200, { status: 'ok', apiKeyConfigured: Boolean(DEEPL_API_KEY) }); } const routes = { 'GET /health': handleHealth, 'POST /api/translate': handleTranslate, 'POST /api/usage': handleUsage, 'POST /api/document/upload': handleDocumentUpload, 'POST /api/document/status': handleDocumentStatus, 'POST /api/document/result': handleDocumentResult, 'GET /api/domains': handleGetDomains, 'GET /api/glossaries': handleListAccountGlossaries, }; // Routes with :domain and :direction path segments // (e.g. /api/domains/scheer-group/glossary/de-en). const DYNAMIC_ROUTES = [ { method: 'POST', regex: /^\/api\/domains\/([a-z-]+)\/glossary\/(de-en|en-de)$/, handler: handleSaveGlossary }, { method: 'DELETE', regex: /^\/api\/domains\/([a-z-]+)\/glossary\/(de-en|en-de)$/, handler: handleDeleteGlossary }, // Account-level glossary management, keyed by DeepL's glossary_id (a UUID). { method: 'DELETE', regex: /^\/api\/glossaries\/([0-9a-fA-F-]{36})$/, handler: handleDeleteAccountGlossary }, { method: 'POST', regex: /^\/api\/glossaries\/([0-9a-fA-F-]{36})\/adopt$/, handler: handleAdoptGlossary }, ]; const server = http.createServer(async (req, res) => { const urlPath = req.url.split('?')[0]; for (const route of DYNAMIC_ROUTES) { if (route.method !== req.method) continue; const m = urlPath.match(route.regex); if (m) { try { await route.handler(req, res, ...m.slice(1)); } catch (e) { sendJson(res, e.statusCode || 500, { error: e.message || 'Internal server error' }); } return; } } const key = `${req.method} ${urlPath}`; const handler = routes[key]; if (handler) { try { await handler(req, res); } catch (e) { sendJson(res, e.statusCode || 500, { error: e.message || 'Internal server error' }); } return; } if (req.method === 'GET') return serveStatic(req, res); // Old (pre-direction) glossary route shape, e.g. POST/DELETE // /api/domains/scheer-group/glossary (no /:direction suffix). This can't // be handled — we don't know which of the two directional lists was // intended — but a bare 404 here looks like a server bug rather than what // it usually is: a browser tab with an old cached copy of index.html // still calling the old API shape. Point the user at the actual fix. const legacyGlossaryMatch = urlPath.match(/^\/api\/domains\/([a-z-]+)\/glossary$/); if (legacyGlossaryMatch && (req.method === 'POST' || req.method === 'DELETE')) { return sendJson(res, 400, { error: 'This endpoint is outdated (missing the glossary direction, e.g. /de-en or /en-de). ' + 'This usually means your browser is running a cached, older version of the page. ' + 'Please hard-refresh (Ctrl+F5 or Cmd+Shift+R) and try saving the terms again.', }); } sendJson(res, 404, { error: 'Not found' }); }); server.listen(PORT, () => { console.log(`Scheer Translation Tool running at http://localhost:${PORT}`); });