~ 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
119 lines · 5.5 KB Raw History
// WebrtcHost's events/signal over exact-key atproto records, for mesh peers (atirc's daemon, and any peer
// whose computer.socialweb.peer record names a `webrtc` DID). No listRecords: every read is one getRecord.
//
//   computer.socialweb.peer/<subhandle>           in the peer's repo: {webrtc: <answerer DID>, ...}
//   computer.socialweb.ingress.listen/<subhandle> in the answerer's repo: {key: age1..., fingerprint}
//   computer.socialweb.ingress.offer/<answerer DID>.<tid>  written here: {ciphertext, subhandle, to}
//   computer.socialweb.ingress.answer/<our DID>.<tid>      the answerer's: {ciphertext}
//
// The receipt WebrtcHost carries is the peer record's strong ref {uri, cid}. `repo` is the page's PDS access:
//   { did, getRecord({repo, collection, rkey}) -> {value, cid} | null, putRecord({collection, rkey, record}),
//     deleteRecord({collection, rkey}) }
// `subhandle` is the page's own identity (the account handle or <nick>.<handle>).

export const PEER = "computer.socialweb.peer";
export const LISTEN = "computer.socialweb.ingress.listen";
export const OFFER = "computer.socialweb.ingress.offer";
export const ANSWER = "computer.socialweb.ingress.answer";
export const MESH_LABEL = "atirc/mesh/1";

const B32 = "234567abcdefghijklmnopqrstuvwxyz";
// An atproto TID: microseconds since the epoch and a random clock id, base32-sortable.
export function tid(now = Date.now()) {
	let n = (BigInt(now) * 1000n) << 10n | BigInt(Math.floor(Math.random() * 1024));
	let out = "";
	for (let i = 0; i < 13; i++, n >>= 5n) out = B32[Number(n & 31n)] + out;
	return out;
}

const parsePeer = (uri) => {
	const m = /^at:\/\/(did:[^/]+)\/computer\.socialweb\.peer\/([^/]+)$/.exec(uri ?? "");
	if (!m) throw new Error(`not a ${PEER} record uri: ${uri}`);
	return { did: m[1], subhandle: m[2] };
};

// The strong ref of a peer's binding, for WebrtcHost's receipt.
export async function peerReceipt(repo, did, subhandle) {
	const found = await repo.getRecord({ repo: did, collection: PEER, rkey: subhandle });
	if (!found) throw new Error(`${subhandle} has no ${PEER} record`);
	return { uri: `at://${did}/${PEER}/${subhandle}`, cid: found.cid };
}

export function recordSignals({ repo, subhandle }) {
	const sessions = new Map(); // peer uri -> { answerer, tid, offered }
	const answererOf = async (uri) => {
		// a market endpoint's listen record (kcp bidders): that DID answers, the rkey is its listen name
		const listen = /^at:\/\/(did:[^/]+)\/computer\.socialweb\.ingress\.listen\/([^/]+)$/.exec(uri ?? "");
		if (listen) return { answerer: listen[1], peer: listen[2] };
		const { did, subhandle: peer } = parsePeer(uri);
		const record = await repo.getRecord({ repo: did, collection: PEER, rkey: peer });
		const answerer = record?.value?.webrtc;
		if (typeof answerer !== "string" || !answerer.startsWith("did:")) throw new Error(`${peer} does not take WebRTC offers`);
		return { answerer, peer };
	};
	return {
		async events({ receipt, tag }) {
			const { answerer, peer } = await answererOf(receipt.uri);
			const listen = await repo.getRecord({ repo: answerer, collection: LISTEN, rkey: peer });
			const events = [];
			if (listen?.value?.key && listen?.value?.fingerprint) {
				events.push({ kind: "guest.publicKey", value: { kind: "age-x25519", tag, key: listen.value.key } });
				events.push({ kind: "vm.onNetwork", value: { kind: "webrtc", tag, address: listen.value.fingerprint } });
			}
			const session = sessions.get(receipt.uri);
			if (session) {
				const answer = await repo.getRecord({ repo: answerer, collection: ANSWER, rkey: `${repo.did}.${session.tid}` });
				if (answer?.value?.ciphertext) {
					events.push({ kind: "guest.signalWebRTC", value: { kind: "answer", tag, ciphertext: answer.value.ciphertext } });
					// Answered: the offer has done its job.
					repo.deleteRecord({ collection: OFFER, rkey: `${answerer}.${session.tid}` }).catch(() => undefined);
				}
			}
			return events;
		},
		async signal({ receipt, offer }) {
			const { answerer, peer } = await answererOf(receipt.uri);
			const session = { answerer, tid: tid() };
			sessions.set(receipt.uri, session);
			await repo.putRecord({
				collection: OFFER,
				rkey: `${answerer}.${session.tid}`,
				record: { $type: OFFER, ciphertext: offer, subhandle, to: peer, createdAt: new Date().toISOString() },
			});
		},
	};
}

// A mesh channel over WebrtcHost's datachannel (tag MESH_LABEL): the JSON lines atirc speaks over iroh.
// Returns { reply, send(text), messages (async iterator of {seq, text, ts}), close }.
export async function meshChannel(host, receipt, { channel, since = 0, seenEpoch = 0, epoch = Date.now() } = {}) {
	const ch = await host.call({ subscribe: true, params: { receipt, tag: MESH_LABEL } });
	const iterator = lines(ch);
	await ch.send(`${JSON.stringify({ hello: 1, kind: "channel", channel, since, seen_epoch: seenEpoch, epoch })}\n`);
	const first = await iterator.next();
	if (first.done) throw new Error("the peer closed the channel during hello");
	const reply = JSON.parse(first.value);
	if (reply.event !== "hello") throw new Error(`the peer refused: ${reply.message ?? first.value}`);
	let seq = 0;
	return {
		reply,
		send: (text) => ch.send(`${JSON.stringify({ seq: ++seq, text, ts: new Date().toISOString() })}\n`),
		messages: (async function* () {
			for await (const line of iterator) yield JSON.parse(line);
		})(),
		close: () => ch.close(),
	};
}

async function* lines(ch) {
	let buffer = "";
	for await (const chunk of ch) {
		buffer += chunk;
		let at;
		while ((at = buffer.indexOf("\n")) >= 0) {
			const line = buffer.slice(0, at);
			buffer = buffer.slice(at + 1);
			if (line.trim()) yield line;
		}
	}
}