/** * MeshBay Browser Transport — WebRTC DataChannel client. * * Connects to a MeshBay node via WebRTC DataChannel (P2P, E2E). * The hub is only used for signaling (SDP/ICE relay) — after connection, * all data flows directly between browser and node. * * Wire format: length-prefixed msgpack (4-byte big-endian + msgpack payload). * Same format as QUIC and TCP+TLS transports on the node side. * * Usage: * const transport = new MeshBayTransport(hubUrl, accessToken); * await transport.connect(nodeId, jwtToken, groupId); * const index = await transport.fetchIndex(); * const chunk = await transport.fetchChunk(fileId, 0); * transport.close(); */ class MeshBayTransport { constructor(hubUrl, accessToken) { this._hubUrl = hubUrl; this._accessToken = accessToken; this._pc = null; this._channel = null; this._pending = new Map(); this._seqId = 0; this._recvBuf = new Uint8Array(0); this._connected = false; this._onChat = null; } get connected() { return this._connected; } set onChat(fn) { this._onChat = fn; } async connect(nodeId, jwtToken, groupId) { this._pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); this._channel = this._pc.createDataChannel('mnp', { ordered: true }); this._channel.binaryType = 'arraybuffer'; let channelReject = null; const channelReady = new Promise((resolve, reject) => { channelReject = reject; const timeout = setTimeout(() => reject(new Error('DataChannel open timeout')), 30000); this._channel.onopen = () => { clearTimeout(timeout); this._connected = true; resolve(); }; }); this._channel.onmessage = (event) => this._onMessage(event.data); this._channel.onclose = (ev) => { console.warn('[MeshBay] DataChannel closed', this._channel?.readyState, ev); this._connected = false; if (channelReject) channelReject(new Error('DataChannel closed')); for (const [, p] of this._pending) p.reject(new Error('DataChannel closed')); this._pending.clear(); }; this._channel.onerror = (ev) => { console.error('[MeshBay] DataChannel error', ev); if (channelReject) channelReject(new Error('DataChannel error')); }; this._pc.onconnectionstatechange = () => { console.log('[MeshBay] PC state:', this._pc.connectionState); }; this._pc.oniceconnectionstatechange = () => { console.log('[MeshBay] ICE state:', this._pc.iceConnectionState); }; const offer = await this._pc.createOffer(); await this._pc.setLocalDescription(offer); await new Promise((resolve) => { if (this._pc.iceGatheringState === 'complete') return resolve(); this._pc.onicegatheringstatechange = () => { if (this._pc.iceGatheringState === 'complete') resolve(); }; }); const resp = await fetch(`${this._hubUrl}/v1/nodes/${nodeId}/webrtc/offer`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this._accessToken}`, }, body: JSON.stringify({ sdp: this._pc.localDescription.sdp, ice_candidates: [], }), }); if (!resp.ok) { const detail = await resp.json().catch(() => ({})); throw new Error(`Signaling failed: ${resp.status} ${detail.detail || ''}`); } const answer = await resp.json(); await this._pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); await channelReady; const ack = await this._sendAndWait({ type: 'handshake', v: '0.1', token: jwtToken, group_id: groupId || '', }); if (ack.type !== 'handshake_ack') { throw new Error('MNP handshake rejected: ' + (ack.detail || JSON.stringify(ack))); } return ack; } async fetchIndex() { const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } async fetchGEK() { const msg = await this._sendAndWait({ type: 'gek_req', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); return msg.gek_b64; } async fetchChunk(fileId, chunkIndex) { const msg = await this._sendAndWait({ type: 'file_req', v: '0.1', file_id: fileId, chunk_index: chunkIndex, }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } async fetchStreamSegment(fileId, segmentIndex, segmentDuration) { const msg = await this._sendAndWait({ type: 'stream_seg', v: '0.1', file_id: fileId, segment_index: segmentIndex, segment_duration: segmentDuration || 4, }); if (msg.type === 'error') throw new Error(msg.detail); return _b64decode(msg.data_b64); } async fetchChatHistory(since, limit) { const msg = await this._sendAndWait({ type: 'chat_hist', v: '0.1', since: since || 0, limit: limit || 100, }); if (msg.type === 'error') throw new Error(msg.detail); return msg.messages || []; } async sendChat(payload, iteration, threadId, senderName) { const msg = await this._sendAndWait({ type: 'chat_msg', v: '0.1', payload: payload, iteration: iteration || 0, thread_id: threadId || null, sender_name: senderName || null, }); return msg; } async uploadChunk(filename, chunkIndex, totalChunks, data) { const msg = await this._sendAndWait({ type: 'file_upload', v: '0.1', filename, chunk_index: chunkIndex, total_chunks: totalChunks, data: data, }); return msg; } close() { if (this._channel) this._channel.close(); if (this._pc) this._pc.close(); this._connected = false; for (const [, p] of this._pending) p.reject(new Error('Transport closed')); this._pending.clear(); } // ── Internal ────────────────────────────────────────────────────────────── _sendAndWait(obj) { return new Promise((resolve, reject) => { const id = this._seqId++; const timeout = setTimeout(() => { this._pending.delete(id); reject(new Error('Response timeout')); }, 30000); this._pending.set(id, { resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); this._send(obj); }); } _send(obj) { if (!this._channel || this._channel.readyState !== 'open') { throw new Error(`DataChannel not open (state: ${this._channel?.readyState})`); } const encoded = msgpack_encode(obj); const header = new Uint8Array(4); new DataView(header.buffer).setUint32(0, encoded.byteLength, false); const frame = new Uint8Array(4 + encoded.byteLength); frame.set(header); frame.set(encoded, 4); this._channel.send(frame); } _onMessage(data) { const incoming = new Uint8Array(data); const combined = new Uint8Array(this._recvBuf.length + incoming.length); combined.set(this._recvBuf); combined.set(incoming, this._recvBuf.length); this._recvBuf = combined; while (this._recvBuf.length >= 4) { const len = new DataView(this._recvBuf.buffer, this._recvBuf.byteOffset).getUint32(0, false); if (this._recvBuf.length < 4 + len) break; const msgBytes = this._recvBuf.slice(4, 4 + len); this._recvBuf = this._recvBuf.slice(4 + len); const msg = msgpack_decode(msgBytes); this._dispatch(msg); } } _dispatch(msg) { if (msg.type === 'chat_msg' && this._onChat) { this._onChat(msg); return; } const oldest = this._pending.entries().next(); if (!oldest.done) { const [, handler] = oldest.value; handler.resolve(msg); } } } // ── Minimal msgpack encode/decode ──────────────────────────────────────────── // Covers the subset used by MNP: maps, strings, integers, binary, arrays, null. function msgpack_encode(obj) { const parts = []; _encodeValue(obj, parts); const total = parts.reduce((s, p) => s + p.length, 0); const result = new Uint8Array(total); let off = 0; for (const p of parts) { result.set(p, off); off += p.length; } return result; } function _encodeValue(val, parts) { if (val === null || val === undefined) { parts.push(new Uint8Array([0xc0])); } else if (typeof val === 'boolean') { parts.push(new Uint8Array([val ? 0xc3 : 0xc2])); } else if (typeof val === 'number') { if (Number.isInteger(val)) { if (val >= 0 && val <= 127) { parts.push(new Uint8Array([val])); } else if (val >= 0 && val <= 0xff) { parts.push(new Uint8Array([0xcc, val])); } else if (val >= 0 && val <= 0xffff) { const b = new Uint8Array(3); b[0] = 0xcd; new DataView(b.buffer).setUint16(1, val, false); parts.push(b); } else if (val >= 0 && val <= 0xffffffff) { const b = new Uint8Array(5); b[0] = 0xce; new DataView(b.buffer).setUint32(1, val, false); parts.push(b); } else if (val >= -32 && val < 0) { parts.push(new Uint8Array([val & 0xff])); } else if (val >= -128 && val < 0) { const b = new Uint8Array(2); b[0] = 0xd0; b[1] = val & 0xff; parts.push(b); } else { const b = new Uint8Array(5); b[0] = 0xd2; new DataView(b.buffer).setInt32(1, val, false); parts.push(b); } } else { const b = new Uint8Array(9); b[0] = 0xcb; new DataView(b.buffer).setFloat64(1, val, false); parts.push(b); } } else if (typeof val === 'string') { const encoded = new TextEncoder().encode(val); if (encoded.length <= 31) { parts.push(new Uint8Array([0xa0 | encoded.length])); } else if (encoded.length <= 0xff) { parts.push(new Uint8Array([0xd9, encoded.length])); } else if (encoded.length <= 0xffff) { const b = new Uint8Array(3); b[0] = 0xda; new DataView(b.buffer).setUint16(1, encoded.length, false); parts.push(b); } else { const b = new Uint8Array(5); b[0] = 0xdb; new DataView(b.buffer).setUint32(1, encoded.length, false); parts.push(b); } parts.push(encoded); } else if (val instanceof Uint8Array) { if (val.length <= 0xff) { parts.push(new Uint8Array([0xc4, val.length])); } else if (val.length <= 0xffff) { const b = new Uint8Array(3); b[0] = 0xc5; new DataView(b.buffer).setUint16(1, val.length, false); parts.push(b); } else { const b = new Uint8Array(5); b[0] = 0xc6; new DataView(b.buffer).setUint32(1, val.length, false); parts.push(b); } parts.push(val); } else if (Array.isArray(val)) { if (val.length <= 15) { parts.push(new Uint8Array([0x90 | val.length])); } else if (val.length <= 0xffff) { const b = new Uint8Array(3); b[0] = 0xdc; new DataView(b.buffer).setUint16(1, val.length, false); parts.push(b); } else { const b = new Uint8Array(5); b[0] = 0xdd; new DataView(b.buffer).setUint32(1, val.length, false); parts.push(b); } for (const item of val) _encodeValue(item, parts); } else if (typeof val === 'object') { const keys = Object.keys(val); if (keys.length <= 15) { parts.push(new Uint8Array([0x80 | keys.length])); } else if (keys.length <= 0xffff) { const b = new Uint8Array(3); b[0] = 0xde; new DataView(b.buffer).setUint16(1, keys.length, false); parts.push(b); } else { const b = new Uint8Array(5); b[0] = 0xdf; new DataView(b.buffer).setUint32(1, keys.length, false); parts.push(b); } for (const k of keys) { _encodeValue(k, parts); _encodeValue(val[k], parts); } } } function msgpack_decode(buf) { const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); const [val] = _decodeValue(buf, view, 0); return val; } function _decodeValue(buf, view, offset) { const byte = buf[offset]; if (byte <= 0x7f) return [byte, offset + 1]; if ((byte & 0xe0) === 0xe0) return [byte - 256, offset + 1]; if ((byte & 0xa0) === 0xa0) { const len = byte & 0x1f; return [new TextDecoder().decode(buf.slice(offset + 1, offset + 1 + len)), offset + 1 + len]; } if ((byte & 0xf0) === 0x90) { const len = byte & 0x0f; return _decodeArray(buf, view, offset + 1, len); } if ((byte & 0xf0) === 0x80) { const len = byte & 0x0f; return _decodeMap(buf, view, offset + 1, len); } switch (byte) { case 0xc0: return [null, offset + 1]; case 0xc2: return [false, offset + 1]; case 0xc3: return [true, offset + 1]; case 0xc4: { const len = buf[offset + 1]; return [buf.slice(offset + 2, offset + 2 + len), offset + 2 + len]; } case 0xc5: { const len = view.getUint16(offset + 1, false); return [buf.slice(offset + 3, offset + 3 + len), offset + 3 + len]; } case 0xc6: { const len = view.getUint32(offset + 1, false); return [buf.slice(offset + 5, offset + 5 + len), offset + 5 + len]; } case 0xcc: return [buf[offset + 1], offset + 2]; case 0xcd: return [view.getUint16(offset + 1, false), offset + 3]; case 0xce: return [view.getUint32(offset + 1, false), offset + 5]; case 0xcb: return [view.getFloat64(offset + 1, false), offset + 9]; case 0xd0: return [view.getInt8(offset + 1), offset + 2]; case 0xd1: return [view.getInt16(offset + 1, false), offset + 3]; case 0xd2: return [view.getInt32(offset + 1, false), offset + 5]; case 0xd9: { const len = buf[offset + 1]; return [new TextDecoder().decode(buf.slice(offset + 2, offset + 2 + len)), offset + 2 + len]; } case 0xda: { const len = view.getUint16(offset + 1, false); return [new TextDecoder().decode(buf.slice(offset + 3, offset + 3 + len)), offset + 3 + len]; } case 0xdb: { const len = view.getUint32(offset + 1, false); return [new TextDecoder().decode(buf.slice(offset + 5, offset + 5 + len)), offset + 5 + len]; } case 0xdc: { const len = view.getUint16(offset + 1, false); return _decodeArray(buf, view, offset + 3, len); } case 0xdd: { const len = view.getUint32(offset + 1, false); return _decodeArray(buf, view, offset + 5, len); } case 0xde: { const len = view.getUint16(offset + 1, false); return _decodeMap(buf, view, offset + 3, len); } case 0xdf: { const len = view.getUint32(offset + 1, false); return _decodeMap(buf, view, offset + 5, len); } default: throw new Error(`Unknown msgpack type: 0x${byte.toString(16)}`); } } function _decodeArray(buf, view, offset, count) { const arr = []; for (let i = 0; i < count; i++) { const [val, newOff] = _decodeValue(buf, view, offset); arr.push(val); offset = newOff; } return [arr, offset]; } function _decodeMap(buf, view, offset, count) { const obj = {}; for (let i = 0; i < count; i++) { const [key, off1] = _decodeValue(buf, view, offset); const [val, off2] = _decodeValue(buf, view, off1); obj[key] = val; offset = off2; } return [obj, offset]; } function _b64decode(b64) { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } // Export window.MeshBayTransport = MeshBayTransport;