dosllm / bridge.js
HelloSun's picture
Add 小貓中文: Chinese questions and answers on the DOS screen (Big5, Zhuyin); v86 patch of XiaoMao; updated dev package
1cd0943 verified
Raw History Blame Contribute Delete
8.57 kB
/*
* 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;
})();