Merge pull request #1501 from debpalash/fix/remote-backend-recovery-1496

fix: recover from unreachable remote backends
This commit is contained in:
Palash Debnath
2026-08-11 19:22:57 +00:00
committed by GitHub
30 changed files with 668 additions and 24 deletions
+1
View File
@@ -54,6 +54,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Leaving a screen while its waveform is still loading no longer opens a bug-report prompt for a normal cancelled request. (#1498)
- An unreachable remote backend now opens a retryable recovery screen instead of sending the app into local model setup, with clear TLS, CORS, network, HTTP, and wrong-port guidance — thanks @debpalash! (#1501)
- Linux production test launches now stop their own extracted AppImage before resetting SQLite and logs. (#1494)
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
+35 -1
View File
@@ -46,6 +46,7 @@ import FloatingPill from './components/FloatingPill';
import GlobalAudioPlayer from './components/GlobalAudioPlayer';
import BackendCrashNotice from './components/BackendCrashNotice';
import BackendStartFailureNotice from './components/BackendStartFailureNotice';
import RemoteBackendRecovery from './components/RemoteBackendRecovery';
import AnalyticsConsentBanner from './components/AnalyticsConsentBanner';
import LanguageSwitchPrompt from './components/LanguageSwitchPrompt';
import { initAnalyticsFromConsent } from './utils/analytics';
@@ -72,6 +73,7 @@ import { toastErrorWithReport } from './utils/errorToast';
import { listenDictationNotice, showDictationNotice } from './utils/dictationNotice';
import { addBreadcrumb } from './utils/breadcrumbs';
import { appShellClasses } from './utils/appShellClasses';
import { configuredRemoteBackend, probeRemoteBackend } from './utils/remoteBackendProbe';
import { applyUiScale } from './utils/uiScaleEngine';
import { recordValueMoment } from './utils/donationMoments';
import {
@@ -567,6 +569,7 @@ function App() {
const setActiveProject = useAppStore((s) => s.setActiveProject);
const sidebarTab = useAppStore((s) => s.sidebarTab);
const setSidebarTab = useAppStore((s) => s.setSidebarTab);
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
// Snap sidebar to a valid tab when view changes
useEffect(() => {
@@ -591,6 +594,17 @@ function App() {
// the studio in front of a user who actually needs the wizard.
const [setupNeeded, setSetupNeeded] = useState(false);
const [setupChecked, setSetupChecked] = useState(false);
const [remoteFailure, setRemoteFailure] = useState(null);
const [remoteProbeAttempt, setRemoteProbeAttempt] = useState(0);
const retryRemoteBackend = useCallback(() => {
setRemoteFailure(null);
setSetupChecked(false);
setRemoteProbeAttempt((attempt) => attempt + 1);
}, []);
const openRemoteBackendSettings = useCallback(() => {
setRemoteFailure(null);
openSettingsTab('sharing');
}, [openSettingsTab]);
useEffect(() => {
// Gate the probe on the bootstrap being 'ready' before that there is
// no backend to answer. Probing from mount burned the 30-attempt ceiling
@@ -601,6 +615,15 @@ function App() {
if (bootstrapStage !== 'ready') return undefined;
let cancelled = false;
(async () => {
const remote = configuredRemoteBackend();
if (remote) {
const result = await probeRemoteBackend(remote.url, remote.key);
if (cancelled) return;
setRemoteFailure(result.ok ? null : result);
setSetupNeeded(false);
setSetupChecked(true);
return;
}
const { setupStatus } = await import('./api/setup');
// ~30 attempts × ~1s 30s ceiling; enough for a cold sidecar on slow disks.
for (let attempt = 0; attempt < 30 && !cancelled; attempt++) {
@@ -619,7 +642,7 @@ function App() {
return () => {
cancelled = true;
};
}, [bootstrapStage]);
}, [bootstrapStage, remoteProbeAttempt]);
// First sound
// Onboarding should end with the product doing the thing: the moment the
@@ -1194,6 +1217,17 @@ function App() {
</div>
);
}
if (remoteFailure) {
return (
<div className="app-bootstrap-scale" style={{ '--ui-scale': uiScale }}>
<RemoteBackendRecovery
failure={remoteFailure}
onRetry={retryRemoteBackend}
onOpenSettings={openRemoteBackendSettings}
/>
</div>
);
}
if (setupNeeded && bootstrapStage === 'ready') {
// Render outside the `app-container` grid so the wizard spans the full
// viewport instead of getting squeezed into whatever grid cell the
@@ -0,0 +1,47 @@
import { ServerOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../ui';
import { disableRemoteBackend } from '../utils/remoteBackendProbe';
const DETAIL_KEYS = {
tls: 'settings.remote_backend_error_tls',
cors: 'settings.remote_backend_error_cors',
network: 'settings.remote_backend_error_network',
timeout: 'settings.remote_backend_error_timeout',
http: 'settings.remote_backend_error_http',
wrong_port: 'settings.remote_backend_error_wrong_port',
};
export default function RemoteBackendRecovery({
failure,
onRetry,
onOpenSettings,
reload = () => location.reload(),
}) {
const { t } = useTranslation();
const detail = t(DETAIL_KEYS[failure.kind], { status: failure.status });
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-4">
<section className="w-full max-w-lg rounded-xl bg-bg-elev-1 p-6 shadow-xl" role="alert">
<ServerOff size={28} aria-hidden="true" className="mb-3 text-danger" />
<h1 className="m-0 text-lg font-semibold">{t('settings.remote_backend_recovery_title')}</h1>
<p className="text-sm text-fg-muted">{detail}</p>
<code className="block break-all text-xs text-fg-subtle">{failure.target}</code>
<div className="mt-5 flex flex-wrap gap-2">
<Button variant="primary" onClick={onRetry}>
{t('bootstrap.retry')}
</Button>
<Button variant="subtle" onClick={() => disableRemoteBackend(reload)}>
{t('settings.remote_backend_use_local')}
</Button>
<Button variant="ghost" onClick={onOpenSettings}>
{t('nav.settings')}
</Button>
</div>
<p className="mb-0 mt-4 text-xs text-fg-subtle">
{t('settings.remote_backend_recovery_hint')}
</p>
</section>
</main>
);
}
@@ -0,0 +1,45 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import RemoteBackendRecovery from './RemoteBackendRecovery';
describe('RemoteBackendRecovery', () => {
it('explains the classified failure and supports retry', () => {
const onRetry = vi.fn();
const onOpenSettings = vi.fn();
render(
<RemoteBackendRecovery
failure={{
ok: false,
kind: 'wrong_port',
target: 'https://gpu-box:7443',
}}
onRetry={onRetry}
onOpenSettings={onOpenSettings}
/>,
);
expect(screen.getByRole('alert')).toHaveTextContent('7443');
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: 'Settings' }));
expect(onOpenSettings).toHaveBeenCalledOnce();
});
it('clears the remote URL and API key before reloading locally', () => {
localStorage.setItem('ov_backend_url', 'https://gpu-box:3900');
localStorage.setItem('ov_api_key', 'secret');
const reload = vi.fn();
render(
<RemoteBackendRecovery
failure={{ ok: false, kind: 'tls', target: 'https://gpu-box:3900' }}
onRetry={vi.fn()}
onOpenSettings={vi.fn()}
reload={reload}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Use local backend' }));
expect(localStorage.getItem('ov_backend_url')).toBeNull();
expect(localStorage.getItem('ov_api_key')).toBeNull();
expect(reload).toHaveBeenCalledOnce();
});
});
@@ -20,6 +20,7 @@ import toast from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { LS_BACKEND_URL, LS_API_KEY, API } from '../../api/client';
import { askConfirm } from '../../utils/dialog';
import { disableRemoteBackend, probeRemoteBackend } from '../../utils/remoteBackendProbe';
import { SettingsSection, SettingRow, InfoHint, SettingsInput } from './primitives';
import { Button, Badge } from '../../ui';
@@ -30,7 +31,13 @@ export function isValidBackendUrl(value) {
if (!value) return false;
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
return (
(u.protocol === 'http:' || u.protocol === 'https:') &&
!u.username &&
!u.password &&
!u.search &&
!u.hash
);
} catch {
return false;
}
@@ -42,6 +49,7 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
const [key, setKey] = useState(() => localStorage.getItem(LS_API_KEY) || '');
const [probe, setProbe] = useState(null); // {ok, detail, target}
const [testing, setTesting] = useState(false);
const hasSavedRemote = Boolean(localStorage.getItem(LS_BACKEND_URL));
const normalized = url.trim().replace(/\/+$/, '');
@@ -50,23 +58,7 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
setProbe(null);
const target = normalized || API;
try {
const res = await fetch(`${target}/health`, {
headers: key.trim() ? { Authorization: `Bearer ${key.trim()}` } : {},
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body?.detail || `HTTP ${res.status}`);
setProbe({
ok: true,
detail: `${body.version || '?'} on ${body.device || '?'}`,
target,
});
} catch (e) {
setProbe({
ok: false,
detail:
e?.message || t('settings.remote_backend_unreachable', { defaultValue: 'unreachable' }),
target,
});
setProbe(await probeRemoteBackend(target, key.trim()));
} finally {
setTesting(false);
}
@@ -172,6 +164,16 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
<Button variant="subtle" size="sm" onClick={onSave} data-testid="remote-backend-save">
{t('settings.remote_backend_save', { defaultValue: 'Save & reload' })}
</Button>
{hasSavedRemote && (
<Button
variant="subtle"
size="sm"
onClick={() => disableRemoteBackend(reload)}
data-testid="remote-backend-disable"
>
{t('settings.remote_backend_use_local')}
</Button>
)}
{probe && (
<Badge tone={probe.ok ? 'success' : 'danger'} dot role="status">
{probe.ok
@@ -180,7 +182,9 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
defaultValue: 'OK — {{detail}}',
})
: t('settings.remote_backend_probe_fail', {
detail: probe.detail,
detail: t(`settings.remote_backend_error_${probe.kind}`, {
status: probe.status,
}),
defaultValue: 'Failed — {{detail}}',
})}
</Badge>
@@ -19,6 +19,9 @@ vi.mock('../../utils/dialog', () => ({ askConfirm }));
import toast from 'react-hot-toast';
import RemoteBackendPanel, { isValidBackendUrl } from './RemoteBackendPanel';
const healthResponse = (version) =>
new Response(JSON.stringify({ status: 'ok', version, device: 'cuda' }), { status: 200 });
describe('isValidBackendUrl', () => {
it('accepts absolute http(s) URLs only', () => {
expect(isValidBackendUrl('http://gpu-box:3900')).toBe(true);
@@ -28,6 +31,8 @@ describe('isValidBackendUrl', () => {
expect(isValidBackendUrl('gpu-box:3900')).toBe(false);
expect(isValidBackendUrl('not a url')).toBe(false);
expect(isValidBackendUrl('ftp://gpu-box')).toBe(false);
expect(isValidBackendUrl('https://user:secret@gpu-box:3900')).toBe(false);
expect(isValidBackendUrl('https://gpu-box:3900?key=secret')).toBe(false);
expect(isValidBackendUrl('')).toBe(false);
});
});
@@ -79,10 +84,7 @@ describe('RemoteBackendPanel', () => {
});
it('skips the confirmation when the exact URL passed a connection test', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: '0.3.15', device: 'cuda' }),
});
global.fetch = vi.fn().mockResolvedValue(healthResponse('0.3.15'));
render(<RemoteBackendPanel reload={reload} />);
setUrl('http://gpu-box:3900');
@@ -95,6 +97,32 @@ describe('RemoteBackendPanel', () => {
expect(localStorage.getItem('ov_backend_url')).toBe('http://gpu-box:3900');
});
it('classifies a wrong 7443 service and succeeds when retried with the HTTP API', async () => {
global.fetch = vi
.fn()
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValueOnce(healthResponse('0.4.2'));
render(<RemoteBackendPanel reload={reload} />);
setUrl('https://gpu-box:7443');
fireEvent.click(screen.getByTestId('remote-backend-test'));
await screen.findByText(/7443/);
setUrl('http://gpu-box:3900');
fireEvent.click(screen.getByTestId('remote-backend-test'));
await screen.findByText('OK — 0.4.2 on cuda');
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('offers an explicit disable action that clears the remote URL and key', () => {
localStorage.setItem('ov_backend_url', 'http://old-box:3900');
localStorage.setItem('ov_api_key', 'secret');
render(<RemoteBackendPanel reload={reload} />);
fireEvent.click(screen.getByTestId('remote-backend-disable'));
expect(localStorage.getItem('ov_backend_url')).toBeNull();
expect(localStorage.getItem('ov_api_key')).toBeNull();
expect(reload).toHaveBeenCalledOnce();
});
it('clears both settings and reloads without confirmation when the URL is emptied', async () => {
localStorage.setItem('ov_backend_url', 'http://old-box:3900');
localStorage.setItem('ov_api_key', 'k');
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "مساحات العمل"
},
"settings": {
"remote_backend_recovery_title": "تعذّر الوصول إلى الواجهة الخلفية البعيدة",
"remote_backend_recovery_hint": "يمكنك تغيير الواجهة الخلفية البعيدة لاحقًا من الإعدادات ← المشاركة.",
"remote_backend_use_local": "استخدام الواجهة الخلفية المحلية",
"remote_backend_error_tls": "تعذّر التحقق من الاتصال الآمن. تحقّق من الشهادة واستخدم منفذ HTTP API الخاص بـ VoiceStudio.",
"remote_backend_error_cors": "حظرت الواجهة الخلفية البعيدة هذا التطبيق. اسمح بمصدر التطبيق في إعدادات CORS.",
"remote_backend_error_network": "تعذّر الوصول إلى الواجهة الخلفية البعيدة. تحقّق من العنوان والشبكة وجدار الحماية.",
"remote_backend_error_timeout": "لم تستجب الواجهة الخلفية البعيدة في الوقت المحدد.",
"remote_backend_error_http": "أعادت الواجهة الخلفية البعيدة حالة HTTP {{status}}.",
"remote_backend_error_wrong_port": "لا تبدو هذه واجهة VoiceStudio HTTP API. المنفذ 7443 مخصص عادةً للتحكم بالعامل البعيد؛ جرّب المنفذ 3900.",
"openapi": "واجهة برمجة تطبيقات VoiceStudio",
"general": "عام",
"theme": "المظهر",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Arbeitsbereiche"
},
"settings": {
"remote_backend_recovery_title": "Remote-Backend nicht erreichbar",
"remote_backend_recovery_hint": "Du kannst das Remote-Backend später unter Einstellungen → Freigabe ändern.",
"remote_backend_use_local": "Lokales Backend verwenden",
"remote_backend_error_tls": "Die sichere Verbindung konnte nicht überprüft werden. Prüfe das Zertifikat und verwende den HTTP-API-Port von VoiceStudio.",
"remote_backend_error_cors": "Das Remote-Backend hat diese App blockiert. Erlaube den Ursprung der App in den CORS-Einstellungen.",
"remote_backend_error_network": "Das Remote-Backend ist nicht erreichbar. Prüfe Adresse, Netzwerk und Firewall.",
"remote_backend_error_timeout": "Das Remote-Backend hat nicht rechtzeitig geantwortet.",
"remote_backend_error_http": "Das Remote-Backend hat HTTP {{status}} zurückgegeben.",
"remote_backend_error_wrong_port": "Dies scheint nicht die VoiceStudio-HTTP-API zu sein. Port 7443 ist normalerweise der Steuerport des Remote-Workers; versuche Port 3900.",
"openapi": "VoiceStudio-API",
"general": "Allgemein",
"theme": "Theme",
+9
View File
@@ -466,6 +466,15 @@
"remote_backend_invalid_url": "Enter a valid URL starting with http:// or https:// (e.g. http://gpu-box:3900).",
"remote_backend_confirm_unverified": "This backend URL hasn't passed a connection test. Save it and reload anyway? If it's wrong, the app can't reach any backend until you change it back here.",
"remote_backend_confirm_title": "Use unverified backend?",
"remote_backend_recovery_title": "Can't reach the remote backend",
"remote_backend_recovery_hint": "You can change the remote backend later in Settings → Sharing.",
"remote_backend_use_local": "Use local backend",
"remote_backend_error_tls": "The secure connection could not be verified. Check the certificate and use the VoiceStudio HTTP API port.",
"remote_backend_error_cors": "The remote backend blocked this app. Allow this app's origin in the backend CORS settings.",
"remote_backend_error_network": "The remote backend could not be reached. Check its address, network, and firewall.",
"remote_backend_error_timeout": "The remote backend did not respond in time.",
"remote_backend_error_http": "The remote backend returned HTTP {{status}}.",
"remote_backend_error_wrong_port": "This does not look like the VoiceStudio HTTP API. Port 7443 is usually the remote-worker control port; try port 3900.",
"ffmpeg": "FFmpeg",
"ffmpeg_found": "Found",
"ffmpeg_missing": "Not found",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Espacios de trabajo"
},
"settings": {
"remote_backend_recovery_title": "No se puede acceder al backend remoto",
"remote_backend_recovery_hint": "Puedes cambiar el backend remoto más tarde en Ajustes → Compartir.",
"remote_backend_use_local": "Usar backend local",
"remote_backend_error_tls": "No se pudo verificar la conexión segura. Revisa el certificado y usa el puerto de la API HTTP de VoiceStudio.",
"remote_backend_error_cors": "El backend remoto bloqueó esta aplicación. Permite el origen de la aplicación en la configuración CORS.",
"remote_backend_error_network": "No se pudo acceder al backend remoto. Revisa la dirección, la red y el cortafuegos.",
"remote_backend_error_timeout": "El backend remoto no respondió a tiempo.",
"remote_backend_error_http": "El backend remoto devolvió HTTP {{status}}.",
"remote_backend_error_wrong_port": "Esto no parece la API HTTP de VoiceStudio. El puerto 7443 suele ser el puerto de control del trabajador remoto; prueba el 3900.",
"openapi": "API de VoiceStudio",
"general": "General",
"theme": "Tema",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Espaces de travail"
},
"settings": {
"remote_backend_recovery_title": "Impossible de joindre le backend distant",
"remote_backend_recovery_hint": "Vous pourrez modifier le backend distant plus tard dans Réglages → Partage.",
"remote_backend_use_local": "Utiliser le backend local",
"remote_backend_error_tls": "La connexion sécurisée na pas pu être vérifiée. Vérifiez le certificat et utilisez le port de lAPI HTTP VoiceStudio.",
"remote_backend_error_cors": "Le backend distant a bloqué cette application. Autorisez lorigine de lapplication dans les réglages CORS.",
"remote_backend_error_network": "Le backend distant est inaccessible. Vérifiez son adresse, le réseau et le pare-feu.",
"remote_backend_error_timeout": "Le backend distant na pas répondu à temps.",
"remote_backend_error_http": "Le backend distant a renvoyé HTTP {{status}}.",
"remote_backend_error_wrong_port": "Ce service ne semble pas être lAPI HTTP VoiceStudio. Le port 7443 sert généralement au contrôle du worker distant ; essayez le port 3900.",
"openapi": "API VoiceStudio",
"general": "Général",
"theme": "Thème",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "वर्कस्पेस"
},
"settings": {
"remote_backend_recovery_title": "रिमोट बैकएंड से संपर्क नहीं हो सका",
"remote_backend_recovery_hint": "आप बाद में सेटिंग्स → शेयरिंग में रिमोट बैकएंड बदल सकते हैं।",
"remote_backend_use_local": "लोकल बैकएंड इस्तेमाल करें",
"remote_backend_error_tls": "सुरक्षित कनेक्शन सत्यापित नहीं हो सका। प्रमाणपत्र जाँचें और VoiceStudio HTTP API पोर्ट इस्तेमाल करें।",
"remote_backend_error_cors": "रिमोट बैकएंड ने इस ऐप को रोक दिया। CORS सेटिंग्स में ऐप के ओरिजिन को अनुमति दें।",
"remote_backend_error_network": "रिमोट बैकएंड तक पहुँचा नहीं जा सका। पता, नेटवर्क और फ़ायरवॉल जाँचें।",
"remote_backend_error_timeout": "रिमोट बैकएंड ने समय पर जवाब नहीं दिया।",
"remote_backend_error_http": "रिमोट बैकएंड ने HTTP {{status}} लौटाया।",
"remote_backend_error_wrong_port": "यह VoiceStudio HTTP API नहीं लगता। पोर्ट 7443 आम तौर पर रिमोट वर्कर नियंत्रण पोर्ट है; पोर्ट 3900 आज़माएँ।",
"openapi": "VoiceStudio API",
"general": "सामान्य",
"theme": "थीम",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Ruang kerja"
},
"settings": {
"remote_backend_recovery_title": "Backend jarak jauh tidak dapat dijangkau",
"remote_backend_recovery_hint": "Anda dapat mengubah backend jarak jauh nanti di Pengaturan → Berbagi.",
"remote_backend_use_local": "Gunakan backend lokal",
"remote_backend_error_tls": "Koneksi aman tidak dapat diverifikasi. Periksa sertifikat dan gunakan port API HTTP VoiceStudio.",
"remote_backend_error_cors": "Backend jarak jauh memblokir aplikasi ini. Izinkan origin aplikasi di pengaturan CORS.",
"remote_backend_error_network": "Backend jarak jauh tidak dapat dijangkau. Periksa alamat, jaringan, dan firewall.",
"remote_backend_error_timeout": "Backend jarak jauh tidak merespons tepat waktu.",
"remote_backend_error_http": "Backend jarak jauh mengembalikan HTTP {{status}}.",
"remote_backend_error_wrong_port": "Layanan ini tampaknya bukan API HTTP VoiceStudio. Port 7443 biasanya merupakan port kontrol pekerja jarak jauh; coba port 3900.",
"openapi": "API VoiceStudio",
"general": "Umum",
"theme": "Tema",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Aree di lavoro"
},
"settings": {
"remote_backend_recovery_title": "Impossibile raggiungere il backend remoto",
"remote_backend_recovery_hint": "Puoi modificare il backend remoto in seguito in Impostazioni → Condivisione.",
"remote_backend_use_local": "Usa il backend locale",
"remote_backend_error_tls": "Impossibile verificare la connessione sicura. Controlla il certificato e usa la porta dellAPI HTTP di VoiceStudio.",
"remote_backend_error_cors": "Il backend remoto ha bloccato questa app. Consenti lorigine dellapp nelle impostazioni CORS.",
"remote_backend_error_network": "Impossibile raggiungere il backend remoto. Controlla indirizzo, rete e firewall.",
"remote_backend_error_timeout": "Il backend remoto non ha risposto in tempo.",
"remote_backend_error_http": "Il backend remoto ha restituito HTTP {{status}}.",
"remote_backend_error_wrong_port": "Questo servizio non sembra lAPI HTTP di VoiceStudio. La porta 7443 è solitamente la porta di controllo del worker remoto; prova la porta 3900.",
"openapi": "API VoiceStudio",
"general": "Generale",
"theme": "Tema",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "ワークスペース"
},
"settings": {
"remote_backend_recovery_title": "リモートバックエンドに接続できません",
"remote_backend_recovery_hint": "リモートバックエンドは後で「設定」→「共有」から変更できます。",
"remote_backend_use_local": "ローカルバックエンドを使用",
"remote_backend_error_tls": "安全な接続を検証できませんでした。証明書を確認し、VoiceStudio HTTP API のポートを使用してください。",
"remote_backend_error_cors": "リモートバックエンドがこのアプリをブロックしました。CORS 設定でアプリのオリジンを許可してください。",
"remote_backend_error_network": "リモートバックエンドに到達できません。アドレス、ネットワーク、ファイアウォールを確認してください。",
"remote_backend_error_timeout": "リモートバックエンドが時間内に応答しませんでした。",
"remote_backend_error_http": "リモートバックエンドが HTTP {{status}} を返しました。",
"remote_backend_error_wrong_port": "VoiceStudio HTTP API ではないようです。ポート 7443 は通常リモートワーカーの制御用です。ポート 3900 をお試しください。",
"openapi": "VoiceStudio API",
"general": "一般",
"theme": "テーマ",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "작업 공간"
},
"settings": {
"remote_backend_recovery_title": "원격 백엔드에 연결할 수 없음",
"remote_backend_recovery_hint": "나중에 설정 → 공유에서 원격 백엔드를 변경할 수 있습니다.",
"remote_backend_use_local": "로컬 백엔드 사용",
"remote_backend_error_tls": "보안 연결을 확인할 수 없습니다. 인증서를 확인하고 VoiceStudio HTTP API 포트를 사용하세요.",
"remote_backend_error_cors": "원격 백엔드가 이 앱을 차단했습니다. CORS 설정에서 앱의 출처를 허용하세요.",
"remote_backend_error_network": "원격 백엔드에 연결할 수 없습니다. 주소, 네트워크 및 방화벽을 확인하세요.",
"remote_backend_error_timeout": "원격 백엔드가 제시간에 응답하지 않았습니다.",
"remote_backend_error_http": "원격 백엔드가 HTTP {{status}}을(를) 반환했습니다.",
"remote_backend_error_wrong_port": "VoiceStudio HTTP API가 아닌 것 같습니다. 포트 7443은 일반적으로 원격 워커 제어 포트입니다. 포트 3900을 사용해 보세요.",
"openapi": "VoiceStudio API",
"general": "일반",
"theme": "테마",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Werkruimtes"
},
"settings": {
"remote_backend_recovery_title": "Kan de externe backend niet bereiken",
"remote_backend_recovery_hint": "Je kunt de externe backend later wijzigen via Instellingen → Delen.",
"remote_backend_use_local": "Lokale backend gebruiken",
"remote_backend_error_tls": "De beveiligde verbinding kon niet worden geverifieerd. Controleer het certificaat en gebruik de HTTP-API-poort van VoiceStudio.",
"remote_backend_error_cors": "De externe backend heeft deze app geblokkeerd. Sta de oorsprong van de app toe in de CORS-instellingen.",
"remote_backend_error_network": "De externe backend is niet bereikbaar. Controleer het adres, netwerk en de firewall.",
"remote_backend_error_timeout": "De externe backend reageerde niet op tijd.",
"remote_backend_error_http": "De externe backend gaf HTTP {{status}} terug.",
"remote_backend_error_wrong_port": "Dit lijkt niet de VoiceStudio HTTP-API. Poort 7443 is meestal de besturingspoort van de externe worker; probeer poort 3900.",
"openapi": "VoiceStudio-API",
"general": "Algemeen",
"theme": "Thema",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Obszary robocze"
},
"settings": {
"remote_backend_recovery_title": "Nie można połączyć się ze zdalnym backendem",
"remote_backend_recovery_hint": "Zdalny backend możesz później zmienić w Ustawienia → Udostępnianie.",
"remote_backend_use_local": "Użyj lokalnego backendu",
"remote_backend_error_tls": "Nie udało się zweryfikować bezpiecznego połączenia. Sprawdź certyfikat i użyj portu HTTP API VoiceStudio.",
"remote_backend_error_cors": "Zdalny backend zablokował tę aplikację. Zezwól na źródło aplikacji w ustawieniach CORS.",
"remote_backend_error_network": "Nie można połączyć się ze zdalnym backendem. Sprawdź adres, sieć i zaporę.",
"remote_backend_error_timeout": "Zdalny backend nie odpowiedział na czas.",
"remote_backend_error_http": "Zdalny backend zwrócił HTTP {{status}}.",
"remote_backend_error_wrong_port": "To nie wygląda na HTTP API VoiceStudio. Port 7443 zwykle służy do sterowania zdalnym workerem; spróbuj portu 3900.",
"openapi": "API VoiceStudio",
"general": "Ogólne",
"theme": "Motyw",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Áreas de trabalho"
},
"settings": {
"remote_backend_recovery_title": "Não foi possível acessar o backend remoto",
"remote_backend_recovery_hint": "Você pode alterar o backend remoto depois em Configurações → Compartilhamento.",
"remote_backend_use_local": "Usar backend local",
"remote_backend_error_tls": "Não foi possível verificar a conexão segura. Confira o certificado e use a porta da API HTTP do VoiceStudio.",
"remote_backend_error_cors": "O backend remoto bloqueou este aplicativo. Permita a origem do aplicativo nas configurações de CORS.",
"remote_backend_error_network": "Não foi possível acessar o backend remoto. Confira o endereço, a rede e o firewall.",
"remote_backend_error_timeout": "O backend remoto não respondeu a tempo.",
"remote_backend_error_http": "O backend remoto retornou HTTP {{status}}.",
"remote_backend_error_wrong_port": "Este serviço não parece ser a API HTTP do VoiceStudio. A porta 7443 geralmente controla o worker remoto; tente a porta 3900.",
"openapi": "API do VoiceStudio",
"general": "Geral",
"theme": "Tema",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Рабочие пространства"
},
"settings": {
"remote_backend_recovery_title": "Не удалось подключиться к удалённому бэкенду",
"remote_backend_recovery_hint": "Позже удалённый бэкенд можно изменить в разделе «Настройки → Общий доступ».",
"remote_backend_use_local": "Использовать локальный бэкенд",
"remote_backend_error_tls": "Не удалось проверить защищённое соединение. Проверьте сертификат и используйте порт HTTP API VoiceStudio.",
"remote_backend_error_cors": "Удалённый бэкенд заблокировал приложение. Разрешите источник приложения в настройках CORS.",
"remote_backend_error_network": "Удалённый бэкенд недоступен. Проверьте адрес, сеть и брандмауэр.",
"remote_backend_error_timeout": "Удалённый бэкенд не ответил вовремя.",
"remote_backend_error_http": "Удалённый бэкенд вернул HTTP {{status}}.",
"remote_backend_error_wrong_port": "Похоже, это не HTTP API VoiceStudio. Порт 7443 обычно служит для управления удалённым worker; попробуйте порт 3900.",
"openapi": "API VoiceStudio",
"general": "Общие",
"theme": "Тема",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Arbetsytor"
},
"settings": {
"remote_backend_recovery_title": "Det går inte att nå fjärrbackend",
"remote_backend_recovery_hint": "Du kan ändra fjärrbackend senare under Inställningar → Delning.",
"remote_backend_use_local": "Använd lokal backend",
"remote_backend_error_tls": "Den säkra anslutningen kunde inte verifieras. Kontrollera certifikatet och använd VoiceStudios HTTP API-port.",
"remote_backend_error_cors": "Fjärrbackend blockerade appen. Tillåt appens ursprung i CORS-inställningarna.",
"remote_backend_error_network": "Fjärrbackend kunde inte nås. Kontrollera adressen, nätverket och brandväggen.",
"remote_backend_error_timeout": "Fjärrbackend svarade inte i tid.",
"remote_backend_error_http": "Fjärrbackend returnerade HTTP {{status}}.",
"remote_backend_error_wrong_port": "Det här verkar inte vara VoiceStudios HTTP API. Port 7443 är vanligtvis kontrollporten för fjärrworkern; prova port 3900.",
"openapi": "VoiceStudio API",
"general": "Allmänt",
"theme": "Tema",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "พื้นที่ทำงาน"
},
"settings": {
"remote_backend_recovery_title": "ไม่สามารถเชื่อมต่อแบ็กเอนด์ระยะไกลได้",
"remote_backend_recovery_hint": "คุณเปลี่ยนแบ็กเอนด์ระยะไกลภายหลังได้ที่ การตั้งค่า → การแชร์",
"remote_backend_use_local": "ใช้แบ็กเอนด์ในเครื่อง",
"remote_backend_error_tls": "ไม่สามารถยืนยันการเชื่อมต่อที่ปลอดภัยได้ โปรดตรวจสอบใบรับรองและใช้พอร์ต HTTP API ของ VoiceStudio",
"remote_backend_error_cors": "แบ็กเอนด์ระยะไกลบล็อกแอปนี้ โปรดอนุญาตต้นทางของแอปในการตั้งค่า CORS",
"remote_backend_error_network": "ไม่สามารถเข้าถึงแบ็กเอนด์ระยะไกลได้ โปรดตรวจสอบที่อยู่ เครือข่าย และไฟร์วอลล์",
"remote_backend_error_timeout": "แบ็กเอนด์ระยะไกลไม่ตอบสนองภายในเวลาที่กำหนด",
"remote_backend_error_http": "แบ็กเอนด์ระยะไกลส่งคืน HTTP {{status}}",
"remote_backend_error_wrong_port": "บริการนี้ดูเหมือนไม่ใช่ HTTP API ของ VoiceStudio โดยปกติพอร์ต 7443 ใช้ควบคุมเวิร์กเกอร์ระยะไกล โปรดลองพอร์ต 3900",
"openapi": "API ของ VoiceStudio",
"general": "ทั่วไป",
"theme": "ธีม",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Çalışma alanları"
},
"settings": {
"remote_backend_recovery_title": "Uzak arka uca ulaşılamıyor",
"remote_backend_recovery_hint": "Uzak arka ucu daha sonra Ayarlar → Paylaşım bölümünden değiştirebilirsiniz.",
"remote_backend_use_local": "Yerel arka ucu kullan",
"remote_backend_error_tls": "Güvenli bağlantı doğrulanamadı. Sertifikayı kontrol edin ve VoiceStudio HTTP API bağlantı noktasını kullanın.",
"remote_backend_error_cors": "Uzak arka uç bu uygulamayı engelledi. CORS ayarlarında uygulamanın kaynağına izin verin.",
"remote_backend_error_network": "Uzak arka uca ulaşılamadı. Adresi, ağı ve güvenlik duvarını kontrol edin.",
"remote_backend_error_timeout": "Uzak arka uç zamanında yanıt vermedi.",
"remote_backend_error_http": "Uzak arka uç HTTP {{status}} döndürdü.",
"remote_backend_error_wrong_port": "Bu hizmet VoiceStudio HTTP API gibi görünmüyor. 7443 bağlantı noktası genellikle uzak worker denetimi içindir; 3900 bağlantı noktasını deneyin.",
"openapi": "VoiceStudio API",
"general": "Genel",
"theme": "Tema",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Робочі простори"
},
"settings": {
"remote_backend_recovery_title": "Не вдалося підключитися до віддаленого бекенду",
"remote_backend_recovery_hint": "Згодом віддалений бекенд можна змінити в Налаштування → Спільний доступ.",
"remote_backend_use_local": "Використовувати локальний бекенд",
"remote_backend_error_tls": "Не вдалося перевірити захищене з’єднання. Перевірте сертифікат і використовуйте порт HTTP API VoiceStudio.",
"remote_backend_error_cors": "Віддалений бекенд заблокував застосунок. Дозвольте джерело застосунку в налаштуваннях CORS.",
"remote_backend_error_network": "Віддалений бекенд недоступний. Перевірте адресу, мережу та брандмауер.",
"remote_backend_error_timeout": "Віддалений бекенд не відповів вчасно.",
"remote_backend_error_http": "Віддалений бекенд повернув HTTP {{status}}.",
"remote_backend_error_wrong_port": "Схоже, це не HTTP API VoiceStudio. Порт 7443 зазвичай керує віддаленим worker; спробуйте порт 3900.",
"openapi": "API VoiceStudio",
"general": "Загальні",
"theme": "Тема",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "Không gian làm việc"
},
"settings": {
"remote_backend_recovery_title": "Không thể kết nối với backend từ xa",
"remote_backend_recovery_hint": "Bạn có thể đổi backend từ xa sau trong Cài đặt → Chia sẻ.",
"remote_backend_use_local": "Dùng backend cục bộ",
"remote_backend_error_tls": "Không thể xác minh kết nối bảo mật. Hãy kiểm tra chứng chỉ và dùng cổng HTTP API của VoiceStudio.",
"remote_backend_error_cors": "Backend từ xa đã chặn ứng dụng này. Hãy cho phép nguồn của ứng dụng trong cài đặt CORS.",
"remote_backend_error_network": "Không thể truy cập backend từ xa. Hãy kiểm tra địa chỉ, mạng và tường lửa.",
"remote_backend_error_timeout": "Backend từ xa không phản hồi kịp thời.",
"remote_backend_error_http": "Backend từ xa trả về HTTP {{status}}.",
"remote_backend_error_wrong_port": "Dịch vụ này có vẻ không phải HTTP API của VoiceStudio. Cổng 7443 thường dùng để điều khiển worker từ xa; hãy thử cổng 3900.",
"openapi": "API VoiceStudio",
"general": "Chung",
"theme": "Giao diện",
+9
View File
@@ -250,6 +250,15 @@
"seed_reroll_hint": "滚动一个新的随机种子并保留它"
},
"settings": {
"remote_backend_recovery_title": "无法连接远程后端",
"remote_backend_recovery_hint": "你可以稍后在“设置 → 共享”中更改远程后端。",
"remote_backend_use_local": "使用本地后端",
"remote_backend_error_tls": "无法验证安全连接。请检查证书并使用 VoiceStudio HTTP API 端口。",
"remote_backend_error_cors": "远程后端阻止了此应用。请在 CORS 设置中允许此应用的来源。",
"remote_backend_error_network": "无法访问远程后端。请检查地址、网络和防火墙。",
"remote_backend_error_timeout": "远程后端未及时响应。",
"remote_backend_error_http": "远程后端返回了 HTTP {{status}}。",
"remote_backend_error_wrong_port": "这似乎不是 VoiceStudio HTTP API。端口 7443 通常用于远程工作进程控制;请尝试端口 3900。",
"openapi": "VoiceStudio API",
"title": "设置",
"general": "通用",
+9
View File
@@ -25,6 +25,15 @@
"workspaces": "工作區"
},
"settings": {
"remote_backend_recovery_title": "無法連線至遠端後端",
"remote_backend_recovery_hint": "你可以稍後在「設定 → 分享」中變更遠端後端。",
"remote_backend_use_local": "使用本機後端",
"remote_backend_error_tls": "無法驗證安全連線。請檢查憑證並使用 VoiceStudio HTTP API 連接埠。",
"remote_backend_error_cors": "遠端後端封鎖了此應用程式。請在 CORS 設定中允許此應用程式的來源。",
"remote_backend_error_network": "無法存取遠端後端。請檢查位址、網路和防火牆。",
"remote_backend_error_timeout": "遠端後端未及時回應。",
"remote_backend_error_http": "遠端後端回傳 HTTP {{status}}。",
"remote_backend_error_wrong_port": "這似乎不是 VoiceStudio HTTP API。連接埠 7443 通常用於遠端工作程序控制;請嘗試連接埠 3900。",
"openapi": "VoiceStudio API",
"general": "一般",
"theme": "佈景主題",
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import appSource from '../App.jsx?raw';
describe('App remote-backend startup gate', () => {
it('probes a configured remote before the local setup-status path', () => {
const remoteProbe = appSource.indexOf('const remote = configuredRemoteBackend()');
const localSetup = appSource.indexOf("import('./api/setup')", remoteProbe);
expect(remoteProbe).toBeGreaterThan(-1);
expect(localSetup).toBeGreaterThan(remoteProbe);
expect(appSource.slice(remoteProbe, localSetup)).toContain('probeRemoteBackend');
expect(appSource.slice(remoteProbe, localSetup)).toContain('return;');
});
it('renders recovery before it can route to the local SetupWizard', () => {
const recovery = appSource.indexOf('if (remoteFailure)');
const wizard = appSource.indexOf("if (setupNeeded && bootstrapStage === 'ready')", recovery);
expect(recovery).toBeGreaterThan(-1);
expect(wizard).toBeGreaterThan(recovery);
expect(appSource.slice(recovery, wizard)).toContain('RemoteBackendRecovery');
});
});
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
configuredRemoteBackend,
disableRemoteBackend,
probeRemoteBackend,
} from './remoteBackendProbe';
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
describe('remote backend probe', () => {
beforeEach(() => localStorage.clear());
afterEach(() => vi.useRealTimers());
it('reads a configured remote without exposing its key in the target', () => {
localStorage.setItem('ov_backend_url', 'https://gpu-box:3900/');
localStorage.setItem('ov_api_key', 'secret');
expect(configuredRemoteBackend()).toEqual({ url: 'https://gpu-box:3900', key: 'secret' });
});
it('probes the exact health endpoint with authentication', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(response({ status: 'ok', version: '0.4.2', device: 'cuda' }));
await expect(
probeRemoteBackend('https://gpu-box:3900/', 'secret', { fetchImpl }),
).resolves.toEqual({
ok: true,
detail: '0.4.2 on cuda',
target: 'https://gpu-box:3900',
});
expect(fetchImpl).toHaveBeenCalledWith(
'https://gpu-box:3900/health',
expect.objectContaining({ headers: { Authorization: 'Bearer secret' } }),
);
});
it.each([
['https://gpu-box:3900', new TypeError('certificate verify failed'), 'tls'],
['https://gpu-box:3900', new TypeError('Failed to fetch'), 'network'],
['https://gpu-box:3900', new TypeError('blocked by CORS policy'), 'cors'],
['http://gpu-box:3900', new TypeError('Failed to fetch'), 'network'],
['https://gpu-box:7443', new TypeError('Failed to fetch'), 'wrong_port'],
])('classifies %s transport failures as %s', async (url, error, kind) => {
const result = await probeRemoteBackend(url, '', {
fetchImpl: vi.fn().mockRejectedValue(error),
});
expect(result).toMatchObject({ ok: false, kind, target: url });
});
it('classifies HTTP failures and non-VoiceStudio services', async () => {
await expect(
probeRemoteBackend('https://gpu-box:3900', '', {
fetchImpl: vi.fn().mockResolvedValue(response({}, 503)),
}),
).resolves.toMatchObject({ ok: false, kind: 'http', status: 503 });
await expect(
probeRemoteBackend('https://gpu-box:7443', '', {
fetchImpl: vi.fn().mockResolvedValue(response({ status: 'ok' })),
}),
).resolves.toMatchObject({ ok: false, kind: 'wrong_port' });
});
it('rejects an oversized health response without consuming it', async () => {
const getReader = vi.fn();
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ 'content-length': '20000' }),
body: { getReader },
});
await expect(
probeRemoteBackend('https://gpu-box:3900', '', {
fetchImpl: fetchImpl as typeof fetch,
}),
).resolves.toMatchObject({ ok: false, kind: 'wrong_port' });
expect(getReader).not.toHaveBeenCalled();
});
it('cancels a chunked health response once it exceeds the byte limit', async () => {
const cancel = vi.fn().mockResolvedValue(undefined);
const releaseLock = vi.fn();
const chunks = [new Uint8Array(10_000), new Uint8Array(7_000)];
const read = vi
.fn()
.mockResolvedValueOnce({ done: false, value: chunks[0] })
.mockResolvedValueOnce({ done: false, value: chunks[1] });
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
body: { getReader: () => ({ read, cancel, releaseLock }) },
});
await expect(
probeRemoteBackend('https://gpu-box:3900', '', {
fetchImpl: fetchImpl as typeof fetch,
}),
).resolves.toMatchObject({ ok: false, kind: 'wrong_port' });
expect(cancel).toHaveBeenCalledOnce();
expect(read).toHaveBeenCalledTimes(2);
});
it('does not request or disclose credentials embedded in a legacy URL', async () => {
const fetchImpl = vi.fn();
await expect(
probeRemoteBackend('https://user:secret@gpu-box:3900', '', { fetchImpl }),
).resolves.toEqual({
ok: false,
kind: 'network',
target: 'https://gpu-box:3900',
});
expect(fetchImpl).not.toHaveBeenCalled();
});
it('aborts a probe at its bounded deadline', async () => {
vi.useFakeTimers();
const fetchImpl = vi.fn((_url, init) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new DOMException('aborted', 'AbortError')),
);
});
});
const pending = probeRemoteBackend('http://gpu-box:3900', '', {
fetchImpl: fetchImpl as typeof fetch,
timeoutMs: 25,
});
await vi.advanceTimersByTimeAsync(25);
await expect(pending).resolves.toMatchObject({ ok: false, kind: 'timeout' });
});
it('clears both credentials and reloads', () => {
localStorage.setItem('ov_backend_url', 'http://gpu-box:3900');
localStorage.setItem('ov_api_key', 'secret');
const reload = vi.fn();
disableRemoteBackend(reload);
expect(localStorage.getItem('ov_backend_url')).toBeNull();
expect(localStorage.getItem('ov_api_key')).toBeNull();
expect(reload).toHaveBeenCalledOnce();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { LS_API_KEY, LS_BACKEND_URL } from '../api/client.ts';
export type RemoteProbeKind = 'tls' | 'cors' | 'network' | 'timeout' | 'http' | 'wrong_port';
export type RemoteProbeResult =
| { ok: true; detail: string; target: string }
| { ok: false; kind: RemoteProbeKind; status?: number; target: string };
const MAX_HEALTH_BYTES = 16 * 1024;
export function configuredRemoteBackend(): { url: string; key: string } | null {
try {
const url = localStorage.getItem(LS_BACKEND_URL)?.trim().replace(/\/+$/, '') || '';
return url ? { url, key: localStorage.getItem(LS_API_KEY)?.trim() || '' } : null;
} catch {
return null;
}
}
export function disableRemoteBackend(reload: () => void): void {
try {
localStorage.removeItem(LS_BACKEND_URL);
localStorage.removeItem(LS_API_KEY);
} finally {
reload();
}
}
function transportKind(url: URL, error: unknown): RemoteProbeKind {
const text = String((error as Error)?.message || error).toLowerCase();
if ((error as Error)?.name === 'AbortError') return 'timeout';
if (url.port === '7443') return 'wrong_port';
if (/certificate|cert_|ssl|tls/.test(text)) return 'tls';
if (/cors|cross-origin/.test(text)) return 'cors';
// Browser fetch deliberately hides DNS, connection, CORS, and TLS details
// behind the same generic TypeError. Only report TLS when the error says so.
return 'network';
}
async function readBoundedBody(response: Response): Promise<string | null> {
const reader = response.body?.getReader();
if (!reader) return null;
const decoder = new TextDecoder();
const parts: string[] = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_HEALTH_BYTES) {
await reader.cancel();
return null;
}
parts.push(decoder.decode(value, { stream: true }));
}
parts.push(decoder.decode());
return parts.join('');
} finally {
reader.releaseLock();
}
}
export async function probeRemoteBackend(
rawUrl: string,
key = '',
{ fetchImpl = fetch, timeoutMs = 5000 }: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
): Promise<RemoteProbeResult> {
const rawTarget = rawUrl.trim().replace(/\/+$/, '');
let url: URL;
try {
url = new URL(rawTarget);
} catch {
return { ok: false, kind: 'network', target: rawTarget };
}
const hasEmbeddedCredentials = Boolean(url.username || url.password);
const hasQueryOrFragment = Boolean(url.search || url.hash);
url.username = '';
url.password = '';
url.search = '';
url.hash = '';
const target = url.toString().replace(/\/+$/, '');
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
hasEmbeddedCredentials ||
hasQueryOrFragment
) {
return { ok: false, kind: 'network', target };
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(`${target}/health`, {
headers: key ? { Authorization: `Bearer ${key}` } : {},
signal: controller.signal,
cache: 'no-store',
});
if (!response.ok) return { ok: false, kind: 'http', status: response.status, target };
const advertisedBytes = Number(response.headers.get('content-length'));
if (Number.isFinite(advertisedBytes) && advertisedBytes > MAX_HEALTH_BYTES) {
return { ok: false, kind: 'wrong_port', target };
}
const payload = await readBoundedBody(response);
if (payload === null) {
return { ok: false, kind: 'wrong_port', target };
}
let body: unknown = null;
try {
body = JSON.parse(payload);
} catch {
// A healthy VoiceStudio API always returns a small JSON object.
}
if (
!body ||
typeof body !== 'object' ||
(body as any).status !== 'ok' ||
typeof (body as any).version !== 'string' ||
typeof (body as any).device !== 'string'
) {
return { ok: false, kind: 'wrong_port', target };
}
return {
ok: true,
detail: `${(body as any).version} on ${(body as any).device}`,
target,
};
} catch (error) {
return { ok: false, kind: transportKind(url, error), target };
} finally {
clearTimeout(timer);
}
}