mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-16 09:45:30 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10bf611e53 |
@@ -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 }> = [
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user