~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-webrtc

WebRTC transport for atlibs: com.fedfork.exec.webrtc.connect in the browser, and webrtc-peer, its guest side

0
0
main
152 lines · 5.2 KB Raw History
// An HTTP/1.1 client over one RTCDataChannel: the channel is the connection (the guest agent bridges a
// tag to its unix socket, e.g. shell to its MCP server; webrtc-peer bridges a tag to a TCP port). One request
// at a time; keep-alive unless the response says close or is delimited by the close. No deps.

const HEAD_MAX = 65536;
// A response body over this is refused (the guest's tools cap their output well under it).
export const BODY_MAX = 1 << 22;
// The datachannel's message size every browser and pion take.
const CHUNK = 16384;
const enc = new TextEncoder();
const dec = new TextDecoder();

// Bytes from the channel, read as lines and exact counts.
class Bytes {
	#parts = [];
	#size = 0;
	#wake;
	ended = null;

	push(chunk) {
		this.#parts.push(chunk);
		this.#size += chunk.length;
		this.#wake?.();
	}

	end(error) {
		this.ended ??= error;
		this.#wake?.();
	}

	#flat() {
		if (this.#parts.length !== 1) {
			const all = new Uint8Array(this.#size);
			let at = 0;
			for (const p of this.#parts) all.set(p, (at += p.length) - p.length);
			this.#parts = [all];
		}
		return this.#parts[0];
	}

	async #more() {
		if (this.ended) throw this.ended;
		await new Promise((resolve) => (this.#wake = resolve));
		this.#wake = undefined;
	}

	async take(n) {
		while (this.#size < n) await this.#more();
		const all = this.#flat();
		this.#parts = [all.subarray(n)];
		this.#size -= n;
		return all.subarray(0, n);
	}

	async line(max = HEAD_MAX) {
		for (;;) {
			const all = this.#flat();
			for (let i = 0; i + 1 < all.length; i++) if (all[i] === 13 && all[i + 1] === 10) return dec.decode((await this.take(i + 2)).subarray(0, i));
			if (this.#size > max) throw new Error("the HTTP response head is too long");
			await this.#more();
		}
	}

	// Everything until the channel closes (a response with neither length nor chunks).
	async rest(max) {
		while (!this.ended) {
			if (this.#size > max) throw new Error(`the HTTP response body is over ${max} bytes`);
			await new Promise((resolve) => (this.#wake = resolve));
		}
		return this.take(this.#size);
	}
}

const tooBig = (n) => {
	if (n > BODY_MAX) throw new Error(`the HTTP response body is over ${BODY_MAX} bytes`);
	return n;
};

async function readBody(bytes, status, headers) {
	if (status === 204 || status === 304) return [new Uint8Array(0), true];
	if (/chunked/i.test(headers["transfer-encoding"] ?? "")) {
		const parts = [];
		let size = 0;
		for (;;) {
			const n = parseInt((await bytes.line()).split(";")[0], 16);
			if (!(n >= 0)) throw new Error("a bad HTTP chunk size");
			if (n === 0) break;
			tooBig((size += n));
			parts.push(await bytes.take(n));
			await bytes.take(2);
		}
		while ((await bytes.line()) !== ""); // trailers
		const body = new Uint8Array(size);
		parts.reduce((at, p) => (body.set(p, at), at + p.length), 0);
		return [body, true];
	}
	if (headers["content-length"] !== undefined) {
		const n = Number(headers["content-length"]);
		if (!Number.isSafeInteger(n) || n < 0) throw new Error("a bad HTTP content-length");
		return [await bytes.take(tooBig(n)), true];
	}
	return [await bytes.rest(BODY_MAX), false];
}

/**
 * The client on an open datachannel (binaryType arraybuffer). It takes the channel's onmessage and onclose.
 * request({method, path, headers, body}) -> {status, headers (lowercase names), body: Uint8Array};
 * reusable: false after a response that ends the connection (or once the channel closed).
 */
export function httpChannel(dc) {
	const bytes = new Bytes();
	let busy = false;
	let keep = true;
	dc.binaryType = "arraybuffer";
	dc.onmessage = ({ data }) => bytes.push(typeof data === "string" ? enc.encode(data) : new Uint8Array(data));
	dc.onclose = () => bytes.end(new Error("the datachannel closed"));
	return {
		get reusable() {
			return keep && !busy && !bytes.ended && dc.readyState === "open";
		},
		close: () => ((keep = false), dc.close()),
		async request({ method = "POST", path = "/", headers = {}, body = "" }) {
			if (busy) throw new Error("one HTTP request at a time on a datachannel");
			busy = true;
			try {
				const payload = typeof body === "string" ? enc.encode(body) : body;
				const head = [`${method} ${path} HTTP/1.1`, "host: guest", ...Object.entries({ ...headers, "content-length": payload.length }).map(([k, v]) => `${k}: ${v}`), "", ""].join("\r\n");
				const out = new Uint8Array(enc.encode(head).length + payload.length);
				out.set(enc.encode(head));
				out.set(payload, out.length - payload.length);
				for (let at = 0; at < out.length; at += CHUNK) dc.send(out.subarray(at, at + CHUNK));
				const [version, code] = (await bytes.line()).split(" ");
				const status = Number(code);
				if (!/^HTTP\/1\.[01]$/.test(version) || !(status >= 100 && status < 600)) throw new Error(`not an HTTP response: ${version} ${code}`);
				const response = {};
				for (let line; (line = await bytes.line()) !== ""; ) {
					const i = line.indexOf(":");
					if (i > 0) response[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
				}
				const [data, framed] = await readBody(bytes, status, response);
				keep &&= framed && version === "HTTP/1.1" && !/close/i.test(response.connection ?? "");
				return { status, headers: response, body: data };
			} catch (error) {
				keep = false;
				throw error;
			} finally {
				busy = false;
				if (!keep) dc.close();
			}
		},
	};
}