diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte index c8b2c814cd..7a45498042 100644 --- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte +++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte @@ -8,7 +8,8 @@ SettingsChatImportExportTab, SettingsChatMobileHeader, SettingsChatToolsTab, - SettingsFooter + SettingsFooter, + SettingsRemoteAccess } from '$lib/components/app/settings'; import { Button } from '$lib/components/ui/button'; import { @@ -152,6 +153,8 @@ {:else if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT} + {:else if currentSection.title === SETTINGS_SECTION_TITLES.REMOTE_ACCESS} + {:else if currentSection.fields}
+ import { fade } from 'svelte/transition'; + import { Wifi, WifiOff, Copy, Check, Users, AlertCircle, Loader2, RefreshCw } from '@lucide/svelte'; + import { Button } from '$lib/components/ui/button'; + import { Input } from '$lib/components/ui/input'; + import { Badge } from '$lib/components/ui/badge'; + import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import { SettingsGroup } from '$lib/components/app/settings'; + import { webrtcStore } from '$lib/stores/webrtc.svelte'; + + // -- host state + let codeCopied = $state(false); + let showRegenerateDialog = $state(false); + let regenerating = $state(false); + + // -- client state + let joinInput = $state(''); + let joinError = $state(''); + let joining = $state(false); + + async function handleStartHost() { + await webrtcStore.startHost(); + } + + function handleStopHost() { + webrtcStore.stopHost(); + } + + function copyCode() { + navigator.clipboard.writeText(webrtcStore.shareCode).then(() => { + codeCopied = true; + setTimeout(() => (codeCopied = false), 2000); + }); + } + + async function handleRegenerateConfirm() { + showRegenerateDialog = false; + regenerating = true; + try { + await webrtcStore.regenerateCodes(); + } finally { + regenerating = false; + } + } + + async function handleJoin() { + joinError = ''; + const code = joinInput.trim().replace(/\s/g, ''); + if (code.length < 40) { + joinError = 'Code must be 40 characters'; + return; + } + joining = true; + try { + await webrtcStore.joinAsClient(code); + } catch (e) { + joinError = e instanceof Error ? e.message : String(e); + } finally { + joining = false; + } + } + + function handleLeave() { + webrtcStore.leaveAsClient(); + joinInput = ''; + joinError = ''; + } + + // Display the share code broken into 8-char blocks for readability + function formatCode(code: string): string { + return code.match(/.{1,8}/g)?.join(' ') ?? code; + } + + +
+ + + + +
+

+ Generate a code and share it so remote devices can connect to this instance. +

+ + + {#if webrtcStore.hasHostCodes} +
+

+ Share code +

+

+ {formatCode(webrtcStore.shareCode)} +

+

+ Share this 40-character code with remote devices. It includes both the room ID and the + passcode. +

+
+ +
+ + + +
+ {/if} + + + {#if webrtcStore.mode === 'host'} +
+ {#if webrtcStore.status === 'connecting'} + + + Connecting to trackers... + + {:else if webrtcStore.status === 'connected'} + + + Active + + + + {webrtcStore.peerCount} + {webrtcStore.peerCount === 1 ? 'client' : 'clients'} connected + + {:else if webrtcStore.status === 'error'} + + + Error + + {webrtcStore.errorMessage} + {/if} +
+ {/if} + + + {#if webrtcStore.mode === 'off' || webrtcStore.mode === 'client'} + + {:else} + + {/if} + + {#if webrtcStore.mode === 'client'} +

+ Disable join mode first before enabling host mode. +

+ {/if} +
+
+ + + + + +
+

+ Connect to a remote llama.cpp instance. All requests will be routed through the + peer-to-peer tunnel. +

+ + {#if webrtcStore.mode === 'client'} +
+
+ {#if webrtcStore.status === 'connecting'} + + + Connecting... + + {:else if webrtcStore.status === 'connected'} + + + Connected to host + + {:else if webrtcStore.status === 'error'} + + + Disconnected + + {webrtcStore.errorMessage} + {/if} +
+ + +
+ {:else} +
+
+ + + {#if joinError} +

{joinError}

+ {/if} +
+ + + + {#if webrtcStore.mode === 'host'} +

+ Disable host mode first before joining a remote server. +

+ {/if} +
+ {/if} +
+
+ +

+ Uses WebRTC with Google STUN servers for NAT traversal. Signaling via public WebTorrent + trackers. No data is routed through any relay server. +

+
+ + + + + + Regenerate access code? + + This will create a new room and passcode. Any devices using the current code will be + disconnected and will need to be updated with the new code. + + + + Cancel + Regenerate + + + diff --git a/tools/ui/src/lib/components/app/settings/index.ts b/tools/ui/src/lib/components/app/settings/index.ts index 63f9651df6..658da35dff 100644 --- a/tools/ui/src/lib/components/app/settings/index.ts +++ b/tools/ui/src/lib/components/app/settings/index.ts @@ -74,3 +74,10 @@ export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields * server favicons and permission management controls. */ export { default as SettingsChatToolsTab } from './SettingsChat/SettingsChatToolsTab.svelte'; + +/** + * Remote Access configuration panel. + * Host mode: generates a share code for remote clients. + * Client mode: accepts a share code and routes all same-origin requests through a WebRTC tunnel. + */ +export { default as SettingsRemoteAccess } from './SettingsRemoteAccess.svelte'; diff --git a/tools/ui/src/lib/constants/routes.constants.ts b/tools/ui/src/lib/constants/routes.constants.ts index 84b0f5300b..b0d6f92c40 100644 --- a/tools/ui/src/lib/constants/routes.constants.ts +++ b/tools/ui/src/lib/constants/routes.constants.ts @@ -18,6 +18,7 @@ export const SETTINGS_SECTION_SLUGS = { GENERAL: 'general', IMPORT_EXPORT: 'import-export', PENALTIES: 'penalties', + REMOTE_ACCESS: 'remote-access', SAMPLING: 'sampling', TOOLS: 'tools' } as const; diff --git a/tools/ui/src/lib/constants/settings-registry.constants.ts b/tools/ui/src/lib/constants/settings-registry.constants.ts index bf43a26e86..5ed8310955 100644 --- a/tools/ui/src/lib/constants/settings-registry.constants.ts +++ b/tools/ui/src/lib/constants/settings-registry.constants.ts @@ -13,6 +13,7 @@ import { Monitor, Moon, PencilRuler, + Radio, Sliders, Sun } from '@lucide/svelte'; @@ -36,6 +37,7 @@ export const SETTINGS_SECTION_TITLES = { GENERAL: 'General', IMPORT_EXPORT: 'Import/Export', PENALTIES: 'Penalties', + REMOTE_ACCESS: 'Remote Access', SAMPLING: 'Sampling', TOOLS: 'Tools' } as const; @@ -46,6 +48,11 @@ const STANDALONE_SECTIONS: { title: SettingsSectionTitle; slug: string; icon: Co icon: Database, slug: SETTINGS_SECTION_SLUGS.IMPORT_EXPORT, title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT + }, + { + icon: Radio, + slug: SETTINGS_SECTION_SLUGS.REMOTE_ACCESS, + title: SETTINGS_SECTION_TITLES.REMOTE_ACCESS } ]; const COLOR_MODE_OPTIONS: Array<{ value: string; label: string; icon: Component }> = [ diff --git a/tools/ui/src/lib/stores/webrtc.svelte.ts b/tools/ui/src/lib/stores/webrtc.svelte.ts new file mode 100644 index 0000000000..295a7867cc --- /dev/null +++ b/tools/ui/src/lib/stores/webrtc.svelte.ts @@ -0,0 +1,287 @@ +import { browser } from '$app/environment'; +import { ClientTunnel, HostTunnel, generatePassCode, generateRoomCode } from '$lib/utils/webrtc-tunnel'; + +// Stores the generated host codes; persists until explicitly regenerated. +const HOST_CODES_KEY = 'llama_webrtc_host_codes'; +// Stores the active session (mode + codes) for auto-reconnect on reload. +const SESSION_KEY = 'llama_webrtc_session'; + +type HostCodes = { roomCode: string; passCode: string }; +type SessionData = { mode: 'host' | 'client'; roomCode: string; passCode: string }; +type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error'; + +class WebRTCStore { + mode = $state<'off' | 'host' | 'client'>('off'); + status = $state('idle'); + peerCount = $state(0); + errorMessage = $state(''); + + // Reflect the saved host codes; populated on init even when mode is 'off'. + private _roomCode = $state(''); + private _passCode = $state(''); + + private hostTunnel: HostTunnel | null = null; + private clientTunnel: ClientTunnel | null = null; + // Requests that arrive while mode='client' but tunnel not yet open are held here. + private connectionWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = []; + // The original window.fetch saved before the interceptor is installed. + private originalFetch: typeof window.fetch | null = null; + + constructor() { + if (browser) { + // Load persisted host codes so the UI can show them before host is enabled. + const saved = this.readHostCodes(); + if (saved) { + this._roomCode = saved.roomCode; + this._passCode = saved.passCode; + } + this.restoreSession(); + } + } + + get roomCode(): string { + return this._roomCode; + } + + get passCode(): string { + return this._passCode; + } + + // Full 40-char code shared with remote clients. + get shareCode(): string { + return this._roomCode + this._passCode; + } + + get isConnected(): boolean { + return this.status === 'connected'; + } + + get hasHostCodes(): boolean { + return this._roomCode !== '' && this._passCode !== ''; + } + + // ------------------------------------------------------------------------- + // Host + // ------------------------------------------------------------------------- + + async startHost(): Promise { + if (this.mode !== 'off') return; + + // Reuse the persisted codes; generate once if none exist yet. + let roomCode = this._roomCode; + let passCode = this._passCode; + + if (!roomCode || !passCode) { + roomCode = generateRoomCode(); + passCode = generatePassCode(); + this._roomCode = roomCode; + this._passCode = passCode; + this.writeHostCodes({ roomCode, passCode }); + } + + await this.activateHost(roomCode, passCode); + } + + /** Generate a fresh room + pass code. Restarts the tunnel if currently active. */ + async regenerateCodes(): Promise { + const roomCode = generateRoomCode(); + const passCode = generatePassCode(); + this._roomCode = roomCode; + this._passCode = passCode; + this.writeHostCodes({ roomCode, passCode }); + + if (this.mode === 'host') { + this.hostTunnel?.stop(); + this.hostTunnel = null; + await this.activateHost(roomCode, passCode); + } + } + + private async activateHost(roomCode: string, passCode: string): Promise { + this.mode = 'host'; + this.status = 'connecting'; + this.errorMessage = ''; + this.peerCount = 0; + + const tunnel = new HostTunnel(roomCode, passCode, { + onPeerCountChange: (count) => { + this.peerCount = count; + } + }); + + try { + await tunnel.start(); + this.hostTunnel = tunnel; + this.status = 'connected'; + this.writeSession({ mode: 'host', roomCode, passCode }); + } catch (e) { + this.hostTunnel = null; + this.status = 'error'; + this.errorMessage = e instanceof Error ? e.message : String(e); + } + } + + stopHost(): void { + this.hostTunnel?.stop(); + this.hostTunnel = null; + this.mode = 'off'; + this.status = 'idle'; + this.peerCount = 0; + // Codes are intentionally kept: _roomCode/_passCode and HOST_CODES_KEY + // remain so the user can re-enable without a new code. + this.clearSession(); + } + + // ------------------------------------------------------------------------- + // Client + // ------------------------------------------------------------------------- + + async joinAsClient(shareCode: string): Promise { + if (shareCode.length < 40) throw new Error('Invalid code: must be 40 characters'); + + const roomCode = shareCode.slice(0, 8); + const passCode = shareCode.slice(8); + await this.activateClient(roomCode, passCode); + } + + private async activateClient(roomCode: string, passCode: string): Promise { + this.mode = 'client'; + this.status = 'connecting'; + this.errorMessage = ''; + // Install the fetch interceptor synchronously (before any await) so that + // requests fired by layout effects on the same tick are already captured. + this.installInterceptor(); + + const tunnel = new ClientTunnel(roomCode, passCode, { + onConnected: () => { + this.status = 'connected'; + }, + onDisconnected: () => { + this.status = 'error'; + this.errorMessage = 'Disconnected from host'; + } + }); + + try { + await tunnel.connect(); + this.clientTunnel = tunnel; + this.writeSession({ mode: 'client', roomCode, passCode }); + // Release any requests that were queued while connecting. + const waiters = this.connectionWaiters.splice(0); + for (const w of waiters) w.resolve(); + } catch (e) { + this.clientTunnel = null; + this.mode = 'off'; + this.status = 'error'; + this.errorMessage = e instanceof Error ? e.message : String(e); + this.uninstallInterceptor(); + // Reject queued requests. + const waiters = this.connectionWaiters.splice(0); + const err = e instanceof Error ? e : new Error(String(e)); + for (const w of waiters) w.reject(err); + throw e; + } + } + + leaveAsClient(): void { + this.uninstallInterceptor(); + this.clientTunnel?.disconnect(); + this.clientTunnel = null; + this.mode = 'off'; + this.status = 'idle'; + this.clearSession(); + } + + // ------------------------------------------------------------------------- + // Fetch interceptor (installed synchronously when client mode activates) + // ------------------------------------------------------------------------- + + private installInterceptor(): void { + if (this.originalFetch) return; // already installed + this.originalFetch = window.fetch.bind(window); + const store = this; + window.fetch = function (input: RequestInfo | URL, init?: RequestInit) { + try { + const url = + input instanceof Request + ? input.url + : input instanceof URL + ? input.href + : String(input); + const parsed = new URL(url, window.location.href); + if (parsed.origin === window.location.origin) { + return store.tunnelFetch(input, init); + } + } catch { + // not a parseable URL — fall through + } + return store.originalFetch!(input, init); + }; + } + + private uninstallInterceptor(): void { + if (!this.originalFetch) return; + window.fetch = this.originalFetch; + this.originalFetch = null; + } + + // ------------------------------------------------------------------------- + // Fetch proxy + // ------------------------------------------------------------------------- + + tunnelFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + // If the tunnel is open, forward immediately. + if (this.clientTunnel?.isConnected) { + return this.clientTunnel.fetch(input, init); + } + // If we are still connecting, queue the request until the tunnel opens. + if (this.mode === 'client' && this.status === 'connecting') { + return new Promise((resolve, reject) => { + this.connectionWaiters.push({ resolve, reject }); + }).then(() => this.clientTunnel!.fetch(input, init)); + } + throw new Error('tunnel not connected'); + } + + // ------------------------------------------------------------------------- + // Persistence helpers + // ------------------------------------------------------------------------- + + private readHostCodes(): HostCodes | null { + try { + const raw = localStorage.getItem(HOST_CODES_KEY); + return raw ? (JSON.parse(raw) as HostCodes) : null; + } catch { + return null; + } + } + + private writeHostCodes(codes: HostCodes): void { + localStorage.setItem(HOST_CODES_KEY, JSON.stringify(codes)); + } + + private restoreSession(): void { + try { + const raw = localStorage.getItem(SESSION_KEY); + if (!raw) return; + const session = JSON.parse(raw) as SessionData; + if (session.mode === 'host') { + void this.activateHost(session.roomCode, session.passCode); + } else if (session.mode === 'client') { + void this.activateClient(session.roomCode, session.passCode); + } + } catch { + // ignore corrupt storage + } + } + + private writeSession(data: SessionData): void { + localStorage.setItem(SESSION_KEY, JSON.stringify(data)); + } + + private clearSession(): void { + localStorage.removeItem(SESSION_KEY); + } +} + +export const webrtcStore = new WebRTCStore(); diff --git a/tools/ui/src/lib/utils/webrtc-tunnel.ts b/tools/ui/src/lib/utils/webrtc-tunnel.ts new file mode 100644 index 0000000000..bbe26719f9 --- /dev/null +++ b/tools/ui/src/lib/utils/webrtc-tunnel.ts @@ -0,0 +1,695 @@ +/** + * WebRTC tunnel for remote llama.cpp access. + * + * Signaling uses the WebTorrent tracker WebSocket protocol (no external deps). + * The room code is used as the info_hash rendezvous key; the pass code + * authenticates the client on the data channel after the WebRTC handshake. + * + * Host: announces periodically, accepts incoming offers, relays HTTP requests + * made by connected clients back to its own local server. + * Client: sends an offer, authenticates via pass code, then all same-origin + * fetch calls are transparently forwarded through the data channel. + */ + +const STUN_CONFIG: RTCConfiguration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] +}; + +const TRACKER_URLS = [ + 'wss://tracker.openwebtorrent.com', + 'wss://tracker.btorrent.xyz' +]; + +const ICE_GATHER_TIMEOUT_MS = 10_000; +const ANNOUNCE_INTERVAL_MS = 30_000; +const CONNECT_TIMEOUT_MS = 30_000; +const TRACKER_CONNECT_TIMEOUT_MS = 10_000; + +// Characters that are unambiguous to read aloud or type +const CODE_CHARS = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; + +function randomStr(len: number): string { + const bytes = new Uint8Array(len); + crypto.getRandomValues(bytes); + let result = ''; + for (const b of bytes) result += CODE_CHARS[b % CODE_CHARS.length]; + return result; +} + +export function generateRoomCode(): string { + return randomStr(8); +} + +export function generatePassCode(): string { + return randomStr(32); +} + +// info_hash must be exactly 20 chars for WebTorrent trackers +function roomToInfoHash(roomCode: string): string { + return roomCode.padEnd(20, '0').slice(0, 20); +} + +function waitForIceComplete(pc: RTCPeerConnection): Promise { + return new Promise((resolve, reject) => { + if (pc.iceGatheringState === 'complete') { + resolve(); + return; + } + const timer = setTimeout( + () => reject(new Error('ICE gathering timed out')), + ICE_GATHER_TIMEOUT_MS + ); + pc.addEventListener('icegatheringstatechange', () => { + if (pc.iceGatheringState === 'complete') { + clearTimeout(timer); + resolve(); + } + }); + }); +} + +// --------------------------------------------------------------------------- +// Tracker signaling (WebTorrent WS tracker protocol) +// --------------------------------------------------------------------------- + +type TrackerMsg = Record; + +class Tracker { + private ws: WebSocket | null = null; + private readonly infoHash: string; + private readonly peerId: string; + + onOffer?: (fromPeerId: string, offerId: string, offer: RTCSessionDescriptionInit) => void; + onAnswer?: (offerId: string, answer: RTCSessionDescriptionInit) => void; + onClose?: () => void; + + constructor(infoHash: string, peerId: string) { + this.infoHash = infoHash; + this.peerId = peerId; + } + + connect(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + this.ws = ws; + const timer = setTimeout( + () => reject(new Error('tracker connect timeout')), + TRACKER_CONNECT_TIMEOUT_MS + ); + + ws.onopen = () => { + clearTimeout(timer); + resolve(); + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error('tracker WebSocket error')); + }; + ws.onclose = () => { + this.onClose?.(); + }; + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string) as TrackerMsg; + if (msg.offer && msg.peer_id && msg.offer_id) { + this.onOffer?.( + msg.peer_id as string, + msg.offer_id as string, + msg.offer as RTCSessionDescriptionInit + ); + } else if (msg.answer && msg.offer_id) { + this.onAnswer?.(msg.offer_id as string, msg.answer as RTCSessionDescriptionInit); + } + } catch { + // ignore malformed tracker messages + } + }; + }); + } + + private send(msg: TrackerMsg): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } + + announce(opts: { + numwant?: number; + offers?: Array<{ offer_id: string; offer: RTCSessionDescriptionInit }>; + } = {}): void { + const msg: TrackerMsg = { + action: 'announce', + info_hash: this.infoHash, + peer_id: this.peerId, + numwant: opts.numwant ?? 0 + }; + if (opts.offers) msg.offers = opts.offers; + this.send(msg); + } + + sendAnswer(toPeerId: string, offerId: string, answer: RTCSessionDescriptionInit): void { + this.send({ + action: 'announce', + info_hash: this.infoHash, + peer_id: this.peerId, + to_peer_id: toPeerId, + answer, + offer_id: offerId + }); + } + + close(): void { + this.ws?.close(); + this.ws = null; + } +} + +// --------------------------------------------------------------------------- +// Tunnel message types (JSON, sent over RTCDataChannel) +// --------------------------------------------------------------------------- + +interface ReqMsg { + type: 'req'; + id: string; + method: string; + path: string; + headers: Record; + body: string | null; // base64 or null +} +interface ResStartMsg { + type: 'res_start'; + id: string; + status: number; + headers: Record; +} +interface ResChunkMsg { + type: 'res_chunk'; + id: string; + data: string; // base64 +} +interface ResEndMsg { + type: 'res_end'; + id: string; +} +interface ResErrMsg { + type: 'res_err'; + id: string; + message: string; +} +interface CancelMsg { + type: 'cancel'; + id: string; +} +interface AuthMsg { + type: 'auth'; + pass: string; +} +interface AuthOkMsg { + type: 'auth_ok'; +} +interface AuthFailMsg { + type: 'auth_fail'; +} + +type TunnelMsg = + | ReqMsg + | ResStartMsg + | ResChunkMsg + | ResEndMsg + | ResErrMsg + | CancelMsg + | AuthMsg + | AuthOkMsg + | AuthFailMsg; + +// Max bytes per res_chunk message (keeps data channel messages well below limits) +const CHUNK_BYTES = 8192; + +function uint8ToBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +function base64ToUint8(b64: string): Uint8Array { + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +} + +// --------------------------------------------------------------------------- +// HostTunnel +// --------------------------------------------------------------------------- + +export type HostCallbacks = { + onPeerCountChange?: (count: number) => void; +}; + +export class HostTunnel { + private readonly passCode: string; + private readonly infoHash: string; + private readonly peerId: string; + private readonly callbacks: HostCallbacks; + + private trackers: Tracker[] = []; + private peers = new Map(); + // AbortControllers for in-flight host-side fetch() calls, keyed by request id. + private activeRequests = new Map(); + private announceTimer: ReturnType | null = null; + private stopped = false; + + constructor(roomCode: string, passCode: string, callbacks: HostCallbacks = {}) { + this.passCode = passCode; + this.infoHash = roomToInfoHash(roomCode); + this.peerId = randomStr(20); + this.callbacks = callbacks; + } + + get peerCount(): number { + return this.peers.size; + } + + async start(): Promise { + await this.connectTrackers(); + this.announceTimer = setInterval(() => { + for (const t of this.trackers) t.announce({ numwant: 0 }); + }, ANNOUNCE_INTERVAL_MS); + } + + private async connectTrackers(): Promise { + for (const url of TRACKER_URLS) { + try { + await this.connectOneTracker(url); + } catch { + // try next + } + } + } + + private async connectOneTracker(url: string): Promise { + const tracker = new Tracker(this.infoHash, this.peerId); + tracker.onOffer = (fromPeerId, offerId, offer) => { + void this.handleOffer(tracker, fromPeerId, offerId, offer); + }; + tracker.onClose = () => { + this.trackers = this.trackers.filter((t) => t !== tracker); + if (!this.stopped) { + setTimeout(() => void this.connectOneTracker(url), 5000); + } + }; + await tracker.connect(url); + tracker.announce({ numwant: 0 }); + this.trackers.push(tracker); + } + + private async handleOffer( + tracker: Tracker, + fromPeerId: string, + offerId: string, + offer: RTCSessionDescriptionInit + ): Promise { + try { + const pc = new RTCPeerConnection(STUN_CONFIG); + await pc.setRemoteDescription(new RTCSessionDescription(offer)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + await waitForIceComplete(pc); + tracker.sendAnswer(fromPeerId, offerId, pc.localDescription!); + + pc.ondatachannel = (event) => this.setupChannel(pc, fromPeerId, event.channel); + } catch { + // ignore failed handshakes + } + } + + private setupChannel(pc: RTCPeerConnection, peerId: string, channel: RTCDataChannel): void { + let authenticated = false; + + channel.onclose = () => { + if (this.peers.has(peerId)) { + this.peers.delete(peerId); + this.callbacks.onPeerCountChange?.(this.peers.size); + } + pc.close(); + }; + + channel.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string) as TunnelMsg; + + if (!authenticated) { + if (msg.type === 'auth') { + if (msg.pass === this.passCode) { + authenticated = true; + channel.send(JSON.stringify({ type: 'auth_ok' } satisfies AuthOkMsg)); + this.peers.set(peerId, { pc, channel }); + this.callbacks.onPeerCountChange?.(this.peers.size); + } else { + channel.send(JSON.stringify({ type: 'auth_fail' } satisfies AuthFailMsg)); + channel.close(); + } + } + return; + } + + if (msg.type === 'req') { + void this.handleRequest(channel, msg); + } else if (msg.type === 'cancel') { + this.activeRequests.get(msg.id)?.abort(); + } + } catch { + // ignore malformed messages + } + }; + } + + private async handleRequest(channel: RTCDataChannel, msg: ReqMsg): Promise { + const { id, method, path, headers, body } = msg; + const t0 = performance.now(); + const ac = new AbortController(); + this.activeRequests.set(id, ac); + + try { + const init: RequestInit = { method, headers, signal: ac.signal }; + if (body !== null) init.body = base64ToUint8(body).buffer as ArrayBuffer; + + const response = await fetch(path, init); + + const resHeaders: Record = {}; + response.headers.forEach((v, k) => { + resHeaders[k] = v; + }); + + console.log(`[rtc] ${method} ${path} -> ${response.status} (${Math.round(performance.now() - t0)}ms)`); + + channel.send( + JSON.stringify({ + type: 'res_start', + id, + status: response.status, + headers: resHeaders + } satisfies ResStartMsg) + ); + + const reader = response.body?.getReader(); + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + for (let i = 0; i < value.length; i += CHUNK_BYTES) { + const slice = value.subarray(i, i + CHUNK_BYTES); + channel.send( + JSON.stringify({ + type: 'res_chunk', + id, + data: uint8ToBase64(slice) + } satisfies ResChunkMsg) + ); + } + } + } + + channel.send(JSON.stringify({ type: 'res_end', id } satisfies ResEndMsg)); + } catch (e) { + // AbortError means the client cancelled — no need to send an error back. + if (!(e instanceof DOMException && e.name === 'AbortError')) { + const errMsg = e instanceof Error ? e.message : String(e); + console.error(`[rtc] ${method} ${path} -> error: ${errMsg}`); + channel.send( + JSON.stringify({ type: 'res_err', id, message: errMsg } satisfies ResErrMsg) + ); + } + } finally { + this.activeRequests.delete(id); + } + } + + stop(): void { + this.stopped = true; + if (this.announceTimer) clearInterval(this.announceTimer); + for (const t of this.trackers) t.close(); + for (const { pc, channel } of this.peers.values()) { + channel.close(); + pc.close(); + } + for (const ac of this.activeRequests.values()) ac.abort(); + this.trackers = []; + this.peers.clear(); + this.activeRequests.clear(); + } +} + +// --------------------------------------------------------------------------- +// ClientTunnel +// --------------------------------------------------------------------------- + +export type ClientCallbacks = { + onConnected?: () => void; + onDisconnected?: () => void; +}; + +type PendingReq = { + onStart: (status: number, headers: Record) => void; + onChunk: (data: string) => void; + onEnd: () => void; + onError: (message: string) => void; +}; + +export class ClientTunnel { + private readonly passCode: string; + private readonly infoHash: string; + private readonly peerId: string; + private readonly callbacks: ClientCallbacks; + + private pc: RTCPeerConnection | null = null; + private channel: RTCDataChannel | null = null; + private tracker: Tracker | null = null; + private pending = new Map(); + + constructor(roomCode: string, passCode: string, callbacks: ClientCallbacks = {}) { + this.passCode = passCode; + this.infoHash = roomToInfoHash(roomCode); + this.peerId = randomStr(20); + this.callbacks = callbacks; + } + + get isConnected(): boolean { + return this.channel?.readyState === 'open'; + } + + async connect(): Promise { + let lastError: Error = new Error('no trackers available'); + for (const url of TRACKER_URLS) { + try { + await this.connectViaTracker(url); + return; + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)); + this.cleanupConnection(); + } + } + throw lastError; + } + + private async connectViaTracker(trackerUrl: string): Promise { + const offerId = randomStr(20); + const pc = new RTCPeerConnection(STUN_CONFIG); + this.pc = pc; + + const channel = pc.createDataChannel('tunnel', { ordered: true }); + this.channel = channel; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await waitForIceComplete(pc); + + const tracker = new Tracker(this.infoHash, this.peerId); + this.tracker = tracker; + await tracker.connect(trackerUrl); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('connection timed out waiting for host')); + }, CONNECT_TIMEOUT_MS); + + tracker.onAnswer = async (_offerId, answer) => { + if (_offerId !== offerId) return; + try { + await pc.setRemoteDescription(new RTCSessionDescription(answer)); + } catch (e) { + clearTimeout(timer); + reject(e); + } + }; + + channel.onopen = () => { + channel.send(JSON.stringify({ type: 'auth', pass: this.passCode } satisfies AuthMsg)); + }; + + channel.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string) as TunnelMsg; + if (msg.type === 'auth_ok') { + clearTimeout(timer); + this.callbacks.onConnected?.(); + resolve(); + } else if (msg.type === 'auth_fail') { + clearTimeout(timer); + reject(new Error('authentication failed: invalid passcode')); + } else { + this.routeResponseMsg(msg); + } + } catch { + // ignore + } + }; + + channel.onclose = () => { + this.callbacks.onDisconnected?.(); + this.rejectAllPending('connection closed'); + }; + + channel.onerror = () => { + clearTimeout(timer); + reject(new Error('data channel error')); + }; + + tracker.announce({ + numwant: 1, + offers: [{ offer_id: offerId, offer: pc.localDescription! }] + }); + }); + } + + private routeResponseMsg(msg: TunnelMsg): void { + if ( + msg.type !== 'res_start' && + msg.type !== 'res_chunk' && + msg.type !== 'res_end' && + msg.type !== 'res_err' + ) + return; + const req = this.pending.get(msg.id); + if (!req) return; + + if (msg.type === 'res_start') { + req.onStart(msg.status, msg.headers); + } else if (msg.type === 'res_chunk') { + req.onChunk(msg.data); + } else if (msg.type === 'res_end') { + req.onEnd(); + this.pending.delete(msg.id); + } else if (msg.type === 'res_err') { + req.onError(msg.message); + this.pending.delete(msg.id); + } + } + + private rejectAllPending(reason: string): void { + for (const req of this.pending.values()) req.onError(reason); + this.pending.clear(); + } + + private cleanupConnection(): void { + this.channel?.close(); + this.pc?.close(); + this.tracker?.close(); + this.channel = null; + this.pc = null; + this.tracker = null; + } + + async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + if (!this.channel || this.channel.readyState !== 'open') { + throw new Error('tunnel not connected'); + } + + const request = new Request(input, init); + const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined); + const id = randomStr(16); + + if (signal?.aborted) { + return Promise.reject(new DOMException('Aborted', 'AbortError')); + } + + // Extract path+query so the host fetches relative to its own origin + const reqUrl = new URL(request.url); + const path = reqUrl.pathname + reqUrl.search; + + const headers: Record = {}; + request.headers.forEach((v, k) => { + headers[k] = v; + }); + + let bodyB64: string | null = null; + const bodyBytes = await request.arrayBuffer(); + if (bodyBytes.byteLength > 0) { + bodyB64 = uint8ToBase64(new Uint8Array(bodyBytes)); + } + + return new Promise((resolve, reject) => { + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(ctrl) { + streamController = ctrl; + } + }); + + const abortHandler = () => { + this.pending.delete(id); + try { + streamController.error(new DOMException('Aborted', 'AbortError')); + } catch { + // stream may already be closed + } + reject(new DOMException('Aborted', 'AbortError')); + // Tell the host to stop the in-flight fetch + if (this.channel?.readyState === 'open') { + this.channel.send(JSON.stringify({ type: 'cancel', id } satisfies CancelMsg)); + } + }; + + signal?.addEventListener('abort', abortHandler, { once: true }); + + this.pending.set(id, { + onStart: (status, resHeaders) => { + resolve(new Response(stream, { status, headers: resHeaders })); + }, + onChunk: (data) => { + streamController.enqueue(base64ToUint8(data)); + }, + onEnd: () => { + signal?.removeEventListener('abort', abortHandler); + streamController.close(); + }, + onError: (message) => { + signal?.removeEventListener('abort', abortHandler); + try { + streamController.error(new Error(message)); + } catch { + // stream may already be closed + } + reject(new Error(message)); + this.pending.delete(id); + } + }); + + this.channel!.send( + JSON.stringify({ + type: 'req', + id, + method: request.method, + path, + headers, + body: bodyB64 + } satisfies ReqMsg) + ); + }); + } + + disconnect(): void { + this.rejectAllPending('disconnected'); + this.cleanupConnection(); + } +} diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte index f938c7edf4..af7cadb28d 100644 --- a/tools/ui/src/routes/+layout.svelte +++ b/tools/ui/src/routes/+layout.svelte @@ -249,6 +249,7 @@ $effect(() => { checkApiKey(); }); +