/* * HTTP -> HTTPS bridge for a v86 DOS guest, restricted to Gradio Spaces. * * DOS (mTCP) can only speak plain TCP: no TLS. v86's "wisp" network backend terminates the guest's TCP * streams; this file plays the WISP *server* inside the page. For every TCP stream the guest opens to * port 80 it collects one HTTP request, performs the same request with fetch() over HTTPS, and writes the * HTTP response back to the guest. Hugging Face Spaces answer CORS requests, so this works from a static page. * * Chinese: XiaoMao Chinese shows Big5 on the DOS screen, Gradio speaks UTF-8/JSON. When the DOS request body * contains Big5 bytes the bridge decodes them (TextDecoder('big5')) before the HTTPS request, and the JSON reply * (\uXXXX escapes) is converted to Big5 bytes (see big5.js) before it is handed to DOS. * * Safety: only hosts matching *.hf.space and only paths starting with /gradio_api/ are relayed (GET/POST, * request body <= 8 KB). It is not an open proxy. No credentials are ever added. * * Install with GradioBridge.install(); then give v86 `relay_url: GradioBridge.relayUrl('dos')`. */ (function () { 'use strict'; const RealWebSocket = window.WebSocket; const FAKE_HOST = 'dosllm-bridge.invalid'; // fake WISP endpoint: never resolved, only recognised by this file const HOST_RE = /^[a-z0-9][a-z0-9-]*\.hf\.space$/i; const MAX_BODY = 8192; const TIMEOUT_MS = 150000; const WINDOW = 128; const bridge = { onEvent: null, requests: 0 }; const emit = (machine, text) => { if (typeof bridge.onEvent === 'function') bridge.onEvent(machine, text); }; const u32 = (n) => [n & 255, (n >> 8) & 255, (n >> 16) & 255, (n >>> 24) & 255]; const frame = (type, id, payload) => { const body = payload || []; const out = new Uint8Array(5 + body.length); out[0] = type; out.set(u32(id), 1); out.set(body, 5); return out; }; const concat = (parts) => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; }; const indexOfCrlf2 = (b) => { for (let i = 0; i + 3 < b.length; i++) if (b[i] === 13 && b[i + 1] === 10 && b[i + 2] === 13 && b[i + 3] === 10) return i; return -1; }; class FakeWispServer { constructor(url) { this.machine = (/[?&]m=([^&]+)/.exec(url) || [])[1] || 'dos'; this.binaryType = 'arraybuffer'; this.readyState = 0; this.streams = new Map(); this.onopen = this.onmessage = this.onclose = this.onerror = null; setTimeout(() => { this.readyState = 1; if (this.onopen) this.onopen({ type: 'open' }); this.reply(frame(3, 0, u32(WINDOW))); }, 0); } reply(bytes) { if (this.readyState !== 1 || !this.onmessage) return; this.onmessage({ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }); } send(data) { const f = new Uint8Array(data.buffer ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : data); const type = f[0], id = new DataView(f.buffer).getUint32(1, true); if (type === 1) this.connect(id, f); else if (type === 2) this.data(id, f.subarray(5)); else if (type === 4) this.streams.delete(id); } connect(id, f) { const port = f[6] | (f[7] << 8); if (port !== 80) { emit(this.machine, `TCP port ${port} refused (this bridge only relays plain HTTP on port 80)`); this.reply(frame(4, id, [0x44])); return; } this.streams.set(id, { chunks: [], handled: false }); } data(id, bytes) { const s = this.streams.get(id); if (!s || s.handled) return; s.chunks.push(bytes.slice()); this.reply(frame(3, id, u32(WINDOW))); const all = concat(s.chunks); const end = indexOfCrlf2(all); if (end < 0) return; const head = new TextDecoder().decode(all.subarray(0, end)); const len = Math.min(parseInt((/content-length:\s*(\d+)/i.exec(head) || [])[1] || '0', 10), MAX_BODY + 1); if (all.length < end + 4 + len) return; // body not complete yet s.handled = true; this.relay(id, head, all.subarray(end + 4, end + 4 + len)); } async respond(id, status, text, type, bodyBytes) { const body = bodyBytes || new TextEncoder().encode(text); const head = new TextEncoder().encode(`HTTP/1.0 ${status}\r\nContent-Type: ${type || 'text/plain'}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`); const all = concat([head, body]); for (let o = 0; o < all.length; o += 1024) this.reply(frame(2, id, all.subarray(o, o + 1024))); this.reply(frame(4, id, [0x02])); // close: guest sees FIN after the last data this.streams.delete(id); } async relay(id, head, body) { const m = /^(GET|POST) (\S+) HTTP\/1\.[01]/.exec(head); const host = ((/^host:\s*(\S+)/im.exec(head) || [])[1] || '').toLowerCase(); if (!m || !HOST_RE.test(host) || !m[2].startsWith('/gradio_api/') || body.length > MAX_BODY) { emit(this.machine, `refused ${m ? m[1] + ' ' + host + m[2] : 'malformed request'} (only *.hf.space /gradio_api/ is relayed)`); return this.respond(id, '403 Forbidden', 'blocked by bridge'); } const url = `https://${host}${m[2]}`; bridge.requests += 1; const t0 = performance.now(); emit(this.machine, `DOS -> ${m[1]} ${url}${m[1] === 'POST' ? ` (${body.length} bytes)` : ''}`); const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS); try { const init = { method: m[1], signal: ctl.signal, headers: {} }; if (m[1] === 'POST') { const big5 = window.Big5 && window.Big5.available && body.some((b) => b >= 0x80); init.body = big5 ? window.Big5.decode(body) : new TextDecoder().decode(body); if (big5) emit(this.machine, `request body has Big5 bytes: decoded to UTF-8 ("${init.body.replace(/^.*?\[\"/, '').slice(0, 60)}...")`); init.headers['Content-Type'] = 'application/json'; } const resp = await fetch(url, init); const bytes = new Uint8Array(await resp.arrayBuffer()); const secs = ((performance.now() - t0) / 1000).toFixed(1); emit(this.machine, `HTTPS ${resp.status} ${resp.headers.get('content-type') || ''} ${bytes.length} bytes in ${secs}s`); if (/text\/event-stream/.test(resp.headers.get('content-type') || '')) { const text = new TextDecoder().decode(bytes); const frames = (text.match(/^event: generating/gm) || []).length; const done = /event: complete\ndata: (.*)/.exec(text); let answer = ''; try { answer = JSON.parse(done[1])[0][0]; } catch (e) { /* not a chat reply */ } emit(this.machine, `Gradio stream: ${frames} generating frames, ${done ? 'complete' : 'NO complete event'}`); if (answer) emit(this.machine, `Gradio answer: ${answer}`); bridge.lastAnswer = answer; } let out = bytes; const ct = resp.headers.get('content-type') || ''; if (window.Big5 && window.Big5.available && /json|event-stream/.test(ct)) { out = window.Big5.jsonToBig5Bytes(new TextDecoder().decode(bytes)); // UTF-8/JSON -> Big5 bytes for the DOS screen if (out.some((b) => b >= 0x80)) emit(this.machine, `reply contains Chinese: converted to Big5 for DOS (${bytes.length} -> ${out.length} bytes)`); } await this.respond(id, `${resp.status} ${resp.statusText || 'OK'}`, '', ct || 'text/plain', out); } catch (e) { emit(this.machine, `HTTPS request failed: ${e && e.message ? e.message : e}`); await this.respond(id, '502 Bad Gateway', 'bridge: upstream request failed'); } finally { clearTimeout(timer); } } close() { this.readyState = 3; this.streams.clear(); if (this.onclose) this.onclose({ type: 'close' }); } } bridge.install = function (opts) { Object.assign(bridge, opts || {}); if (window.WebSocket.__gradioBridge) return; const patched = new Proxy(RealWebSocket, { construct(target, args) { if (String(args[0]).indexOf(FAKE_HOST) >= 0) return new FakeWispServer(String(args[0])); return Reflect.construct(target, args); }, }); patched.__gradioBridge = true; window.WebSocket = patched; }; bridge.relayUrl = (machine) => `wisp://${FAKE_HOST}/?m=${encodeURIComponent(machine || 'dos')}`; window.GradioBridge = bridge; })();