Compare commits

..

1 Commits

Author SHA1 Message Date
Oğuzhan Akkaya 10bf611e53 llama : check LoRA tensor data is within file bounds (#27056)
* llama : check LoRA tensor data is within file bounds

* Update src/llama-adapter.cpp

Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co>

---------

Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co>
2026-08-16 09:38:01 +03:00
8 changed files with 6 additions and 1345 deletions
+5 -2
View File
@@ -396,8 +396,11 @@ static void llama_adapter_lora_init_impl(llama_model & model, const char * path_
llama_file gguf_file(path_lora, "rb");
std::vector<uint8_t> read_buf;
auto set_tensor = [&](ggml_tensor * orig, ggml_tensor * dev) {
size_t offs = gguf_get_data_offset(ctx_gguf.get()) + gguf_get_tensor_offset(ctx_gguf.get(), gguf_find_tensor(ctx_gguf.get(), orig->name));
size_t size = ggml_nbytes(orig);
const size_t offs = gguf_get_data_offset(ctx_gguf.get()) + gguf_get_tensor_offset(ctx_gguf.get(), gguf_find_tensor(ctx_gguf.get(), orig->name));
const size_t size = ggml_nbytes(orig);
if (offs + size < offs || offs + size > gguf_file.size()) {
throw std::runtime_error(format("LoRA tensor '%s' data is not within the file bounds, file is corrupted or incomplete", orig->name));
}
read_buf.resize(size);
gguf_file.seek(offs, SEEK_SET);
gguf_file.read_raw(read_buf.data(), size);
@@ -8,8 +8,7 @@
SettingsChatImportExportTab,
SettingsChatMobileHeader,
SettingsChatToolsTab,
SettingsFooter,
SettingsRemoteAccess
SettingsFooter
} from '$lib/components/app/settings';
import { Button } from '$lib/components/ui/button';
import {
@@ -153,8 +152,6 @@
<SettingsChatToolsTab />
{:else if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
<SettingsChatImportExportTab />
{:else if currentSection.title === SETTINGS_SECTION_TITLES.REMOTE_ACCESS}
<SettingsRemoteAccess />
{:else if currentSection.fields}
<div class="space-y-6">
<SettingsChatFields
@@ -1,291 +0,0 @@
<script lang="ts">
import {
AlertCircle,
Check,
Copy,
Loader2,
RefreshCw,
Users,
Wifi,
WifiOff
} from '@lucide/svelte';
import { SettingsGroup } from '$lib/components/app/settings';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { webrtcStore } from '$lib/stores/webrtc.svelte';
import { fade } from 'svelte/transition';
// -- 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;
}
</script>
<div class="space-y-12" in:fade={{ duration: 150 }}>
<!-- ------------------------------------------------------------------ -->
<!-- HOST -->
<!-- ------------------------------------------------------------------ -->
<SettingsGroup title="Host">
<div class="space-y-4">
<p class="text-sm text-muted-foreground">
Generate a code and share it so remote devices can connect to this instance.
</p>
<!-- Share code block: shown whenever codes exist, even when host is inactive -->
{#if webrtcStore.hasHostCodes}
<div class="rounded-lg border border-border bg-muted/40 p-4">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Share code
</p>
<p class="break-all font-mono text-sm tracking-widest select-all">
{formatCode(webrtcStore.shareCode)}
</p>
<p class="mt-2 text-xs text-muted-foreground">
Share this 40-character code with remote devices. It includes both the room ID and the
passcode.
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onclick={copyCode} class="gap-1.5">
{#if codeCopied}
<Check class="h-3.5 w-3.5" />
Copied
{:else}
<Copy class="h-3.5 w-3.5" />
Copy code
{/if}
</Button>
<Button
variant="outline"
size="sm"
onclick={() => (showRegenerateDialog = true)}
disabled={regenerating}
class="gap-1.5"
>
{#if regenerating}
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Regenerating...
{:else}
<RefreshCw class="h-3.5 w-3.5" />
Regenerate code
{/if}
</Button>
</div>
{/if}
<!-- Status row (only when host is active) -->
{#if webrtcStore.mode === 'host'}
<div class="flex items-center gap-3">
{#if webrtcStore.status === 'connecting'}
<Badge variant="secondary" class="gap-1.5">
<Loader2 class="h-3 w-3 animate-spin" />
Connecting to trackers...
</Badge>
{:else if webrtcStore.status === 'connected'}
<Badge variant="default" class="gap-1.5">
<Wifi class="h-3 w-3" />
Active
</Badge>
<span class="flex items-center gap-1 text-sm text-muted-foreground">
<Users class="h-3.5 w-3.5" />
{webrtcStore.peerCount}
{webrtcStore.peerCount === 1 ? 'client' : 'clients'} connected
</span>
{:else if webrtcStore.status === 'error'}
<Badge variant="destructive" class="gap-1.5">
<AlertCircle class="h-3 w-3" />
Error
</Badge>
<span class="text-sm text-destructive">{webrtcStore.errorMessage}</span>
{/if}
</div>
{/if}
<!-- Enable / Disable button -->
{#if webrtcStore.mode === 'off' || webrtcStore.mode === 'client'}
<Button
variant="outline"
onclick={handleStartHost}
disabled={webrtcStore.mode === 'client'}
>
<Wifi class="h-4 w-4" />
Enable remote access
</Button>
{:else}
<Button variant="outline" onclick={handleStopHost}>
<WifiOff class="h-4 w-4" />
Disable remote access
</Button>
{/if}
{#if webrtcStore.mode === 'client'}
<p class="text-xs text-muted-foreground">
Disable join mode first before enabling host mode.
</p>
{/if}
</div>
</SettingsGroup>
<!-- ------------------------------------------------------------------ -->
<!-- CLIENT / JOIN -->
<!-- ------------------------------------------------------------------ -->
<SettingsGroup title="Join">
<div class="space-y-4">
<p class="text-sm text-muted-foreground">
Connect to a remote llama.cpp instance. All requests will be routed through the peer-to-peer
tunnel.
</p>
{#if webrtcStore.mode === 'client'}
<div class="space-y-3">
<div class="flex items-center gap-3">
{#if webrtcStore.status === 'connecting'}
<Badge variant="secondary" class="gap-1.5">
<Loader2 class="h-3 w-3 animate-spin" />
Connecting...
</Badge>
{:else if webrtcStore.status === 'connected'}
<Badge variant="default" class="gap-1.5">
<Wifi class="h-3 w-3" />
Connected to host
</Badge>
{:else if webrtcStore.status === 'error'}
<Badge variant="destructive" class="gap-1.5">
<AlertCircle class="h-3 w-3" />
Disconnected
</Badge>
<span class="text-sm text-destructive">{webrtcStore.errorMessage}</span>
{/if}
</div>
<Button variant="outline" onclick={handleLeave}>
<WifiOff class="h-4 w-4" />
Leave
</Button>
</div>
{:else}
<div class="space-y-3">
<div class="space-y-1.5">
<label for="join-code" class="text-sm font-medium">Access code</label>
<Input
id="join-code"
placeholder="Paste the 40-character code from the host"
bind:value={joinInput}
disabled={joining || webrtcStore.mode === 'host'}
class="font-mono"
/>
{#if joinError}
<p class="text-sm text-destructive">{joinError}</p>
{/if}
</div>
<Button
onclick={handleJoin}
disabled={joining || joinInput.trim().length < 40 || webrtcStore.mode === 'host'}
>
{#if joining}
<Loader2 class="h-4 w-4 animate-spin" />
Connecting...
{:else}
<Wifi class="h-4 w-4" />
Connect
{/if}
</Button>
{#if webrtcStore.mode === 'host'}
<p class="text-xs text-muted-foreground">
Disable host mode first before joining a remote server.
</p>
{/if}
</div>
{/if}
</div>
</SettingsGroup>
<p class="text-xs text-muted-foreground">
Uses WebRTC with Google STUN servers for NAT traversal. Signaling via public WebTorrent
trackers. No data is routed through any relay server.
</p>
</div>
<!-- Regenerate code confirmation dialog -->
<AlertDialog.Root bind:open={showRegenerateDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Regenerate access code?</AlertDialog.Title>
<AlertDialog.Description>
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.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={handleRegenerateConfirm}>Regenerate</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -74,10 +74,3 @@ 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';
@@ -18,7 +18,6 @@ export const SETTINGS_SECTION_SLUGS = {
GENERAL: 'general',
IMPORT_EXPORT: 'import-export',
PENALTIES: 'penalties',
REMOTE_ACCESS: 'remote-access',
SAMPLING: 'sampling',
TOOLS: 'tools'
} as const;
@@ -13,7 +13,6 @@ import {
Monitor,
Moon,
PencilRuler,
Radio,
Sliders,
Sun
} from '@lucide/svelte';
@@ -37,7 +36,6 @@ export const SETTINGS_SECTION_TITLES = {
GENERAL: 'General',
IMPORT_EXPORT: 'Import/Export',
PENALTIES: 'Penalties',
REMOTE_ACCESS: 'Remote Access',
SAMPLING: 'Sampling',
TOOLS: 'Tools'
} as const;
@@ -48,11 +46,6 @@ 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 }> = [
-304
View File
@@ -1,304 +0,0 @@
import { browser } from '$app/environment';
import {
ClientTunnel,
generatePassCode,
generateRoomCode,
HostTunnel
} 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<ConnectionStatus>('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<void> {
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({ passCode, roomCode });
}
await this.activateHost(roomCode, passCode);
}
/** Generate a fresh room + pass code. Restarts the tunnel if currently active. */
async regenerateCodes(): Promise<void> {
const roomCode = generateRoomCode();
const passCode = generatePassCode();
this._roomCode = roomCode;
this._passCode = passCode;
this.writeHostCodes({ passCode, roomCode });
if (this.mode === 'host') {
this.hostTunnel?.stop();
this.hostTunnel = null;
await this.activateHost(roomCode, passCode);
}
}
private async activateHost(roomCode: string, passCode: string): Promise<void> {
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', passCode, roomCode });
} 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<void> {
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<void> {
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', passCode, roomCode });
// 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);
window.fetch = (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 this.tunnelFetch(input, init);
}
} catch {
// not a parseable URL — fall through
}
return this.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<Response> {
// 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<void>((resolve, reject) => {
this.connectionWaiters.push({ reject, resolve });
}).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();
-729
View File
@@ -1,729 +0,0 @@
/**
* 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<void> {
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<string, unknown>;
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<void> {
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,
numwant: opts.numwant ?? 0,
peer_id: this.peerId
};
if (opts.offers) msg.offers = opts.offers;
this.send(msg);
}
sendAnswer(toPeerId: string, offerId: string, answer: RTCSessionDescriptionInit): void {
this.send({
action: 'announce',
answer,
info_hash: this.infoHash,
offer_id: offerId,
peer_id: this.peerId,
to_peer_id: toPeerId
});
}
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<string, string>;
body: string | null; // base64 or null
}
interface ResStartMsg {
type: 'res_start';
id: string;
status: number;
headers: Record<string, string>;
}
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<string, { pc: RTCPeerConnection; channel: RTCDataChannel }>();
// AbortControllers for in-flight host-side fetch() calls, keyed by request id.
private activeRequests = new Map<string, AbortController>();
private announceTimer: ReturnType<typeof setInterval> | 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<void> {
await this.connectTrackers();
this.announceTimer = setInterval(() => {
for (const t of this.trackers) t.announce({ numwant: 0 });
}, ANNOUNCE_INTERVAL_MS);
}
private async connectTrackers(): Promise<void> {
for (const url of TRACKER_URLS) {
try {
await this.connectOneTracker(url);
} catch {
// try next
}
}
}
private async connectOneTracker(url: string): Promise<void> {
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<void> {
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, { channel, pc });
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<void> {
const { body, headers, id, method, path } = msg;
const t0 = performance.now();
const ac = new AbortController();
this.activeRequests.set(id, ac);
try {
const init: RequestInit = { headers, method, signal: ac.signal };
if (body !== null) init.body = base64ToUint8(body).buffer as ArrayBuffer;
const response = await fetch(path, init);
const resHeaders: Record<string, string> = {};
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({
headers: resHeaders,
id,
status: response.status,
type: 'res_start'
} 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({
data: uint8ToBase64(slice),
id,
type: 'res_chunk'
} satisfies ResChunkMsg)
);
}
}
}
channel.send(JSON.stringify({ id, type: 'res_end' } 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({ id, message: errMsg, type: 'res_err' } 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 { channel, pc } 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<string, string>) => 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<string, PendingReq>();
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<void> {
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<void> {
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({ pass: this.passCode, type: 'auth' } 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: pc.localDescription!, offer_id: offerId }]
});
});
}
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<Response> {
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<string, string> = {};
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<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
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({ id, type: 'cancel' } satisfies CancelMsg));
}
};
signal?.addEventListener('abort', abortHandler, { once: true });
this.pending.set(id, {
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);
},
onStart: (status, resHeaders) => {
resolve(new Response(stream, { headers: resHeaders, status }));
}
});
this.channel!.send(
JSON.stringify({
body: bodyB64,
headers,
id,
method: request.method,
path,
type: 'req'
} satisfies ReqMsg)
);
});
}
disconnect(): void {
this.rejectAllPending('disconnected');
this.cleanupConnection();
}
}