pub const HOST_HTML: &str = "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Actor Host \u{2014} actr run --web</title>\n <style>\n * {\n box-sizing: border-box\n }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif;\n max-width: 860px;\n margin: 40px auto;\n padding: 20px;\n background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);\n min-height: 100vh;\n color: #e0e0e0\n }\n\n .container {\n background: rgba(255, 255, 255, .05);\n padding: 28px;\n border-radius: 14px;\n box-shadow: 0 8px 32px rgba(0, 0, 0, .3);\n border: 1px solid rgba(255, 255, 255, .1)\n }\n\n h1 {\n color: #00d4aa;\n margin: 0 0 6px;\n font-size: 1.8em\n }\n\n .subtitle {\n color: #888;\n margin-bottom: 24px;\n font-size: .88em\n }\n\n /* Status bar \u{2014} supports classes: ready, connected, error, connecting */\n .status {\n padding: 12px 18px;\n border-radius: 8px;\n margin-bottom: 16px;\n font-weight: 500;\n display: flex;\n align-items: center;\n gap: 10px;\n background: rgba(255, 165, 0, .12);\n border: 1px solid rgba(255, 165, 0, .25)\n }\n\n .status.ready,\n .status.connected,\n .status.ok {\n background: rgba(0, 212, 170, .1);\n border-color: rgba(0, 212, 170, .3)\n }\n\n .status.error,\n .status.err {\n background: rgba(255, 60, 60, .1);\n border-color: rgba(255, 60, 60, .3)\n }\n\n .status.connecting {\n background: rgba(255, 165, 0, .12);\n border: 1px solid rgba(255, 165, 0, .25)\n }\n\n .info-grid {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 8px 16px;\n margin-bottom: 20px;\n font-size: .88em\n }\n\n .info-grid dt {\n color: #888\n }\n\n .info-grid dd {\n margin: 0;\n color: #ccc;\n word-break: break-all\n }\n\n /* Stats row (server) */\n .stats {\n display: flex;\n gap: 20px;\n margin-bottom: 16px;\n font-size: .92em\n }\n\n .stats .stat {\n display: flex;\n align-items: center;\n gap: 6px\n }\n\n .stats .stat-label {\n color: #888\n }\n\n .stats .stat-value {\n font-weight: 600;\n color: #00d4aa\n }\n\n /* Client send area */\n .send-area {\n display: flex;\n gap: 8px;\n margin-bottom: 16px\n }\n\n .send-area input {\n flex: 1;\n padding: 10px 14px;\n border: 1px solid rgba(255, 255, 255, .15);\n border-radius: 8px;\n background: rgba(0, 0, 0, .3);\n color: #e0e0e0;\n font-size: .95em;\n outline: none\n }\n\n .send-area input:focus {\n border-color: #00d4aa\n }\n\n .send-area button {\n padding: 10px 20px;\n border: none;\n border-radius: 8px;\n background: #00d4aa;\n color: #1a1a2e;\n font-weight: 600;\n cursor: pointer;\n white-space: nowrap\n }\n\n .send-area button:disabled {\n opacity: .4;\n cursor: not-allowed\n }\n\n .send-area button:hover:not(:disabled) {\n background: #00eabb\n }\n\n /* Log panels */\n .log-panel {\n background: rgba(0, 0, 0, .3);\n border: 1px solid rgba(255, 255, 255, .08);\n border-radius: 8px;\n padding: 14px;\n height: 300px;\n overflow-y: auto;\n font-family: \'Fira Code\', monospace;\n font-size: .78em;\n line-height: 1.65\n }\n\n .log-entry {\n padding: 2px 0\n }\n\n .log-entry .time {\n color: #666;\n margin-right: 8px\n }\n\n .log-entry.info {\n color: #8be9fd\n }\n\n .log-entry.success {\n color: #50fa7b\n }\n\n .log-entry.warn {\n color: #ffb86c\n }\n\n .log-entry.error {\n color: #ff5555\n }\n\n /* Client result entries */\n .entry {\n padding: 2px 0\n }\n\n .entry .time {\n color: #666;\n margin-right: 8px\n }\n\n .entry .info {\n color: #8be9fd\n }\n\n .entry .ok {\n color: #50fa7b\n }\n\n .entry .err {\n color: #ff5555\n }\n\n /* Legacy log classes for generic log */\n .log-info {\n color: #8be9fd\n }\n\n .log-warn {\n color: #ffb86c\n }\n\n .log-error {\n color: #ff5555\n }\n\n .log-success {\n color: #50fa7b\n }\n\n .section-label {\n color: #888;\n font-size: .82em;\n margin: 12px 0 6px;\n text-transform: uppercase;\n letter-spacing: .5px\n }\n </style>\n</head>\n\n<body>\n <div class=\"container\">\n <h1>\u{1f3ad} Actor Host</h1>\n <p class=\"subtitle\">actr run --web</p>\n <div id=\"status\" class=\"status\">\u{23f3} Initializing\u{2026}</div>\n\n <!-- Info grid (always shown) -->\n <dl class=\"info-grid\">\n <dt>Actor Type</dt>\n <dd id=\"actrType\">\u{2014}</dd>\n <dt>Role</dt>\n <dd id=\"role\">\u{2014}</dd>\n <dt>Signaling</dt>\n <dd id=\"sigUrl\">\u{2014}</dd>\n <dt>Realm</dt>\n <dd id=\"realmId\">\u{2014}</dd>\n <dt>Service</dt>\n <dd id=\"serviceName\">\u{2014}</dd>\n </dl>\n\n <!-- Inbound stats (populated whenever this actor handles a request) -->\n <div id=\"inboundStats\">\n <div class=\"stats\">\n <div class=\"stat\"><span class=\"stat-label\">Requests:</span><span class=\"stat-value\"\n id=\"requestCount\">0</span></div>\n <div class=\"stat\"><span class=\"stat-label\">Success:</span><span class=\"stat-value\"\n id=\"successCount\">0</span></div>\n <div class=\"stat\"><span class=\"stat-label\">Errors:</span><span class=\"stat-value\"\n id=\"errorCount\">0</span></div>\n </div>\n </div>\n\n <!-- Outbound send area: every actor can call peers, regardless of\n whether it also accepts incoming calls. -->\n <div id=\"sendArea\">\n <div class=\"send-area\">\n <input id=\"msgInput\" type=\"text\" placeholder=\"Enter message to echo\u{2026}\" />\n <button id=\"sendBtn\" disabled>Send</button>\n </div>\n <div class=\"section-label\">Echo Results</div>\n <div id=\"result\" class=\"log-panel\"></div>\n </div>\n\n <!-- Runtime / SW log -->\n <div class=\"section-label\">Runtime Log</div>\n <div id=\"log\" class=\"log-panel\"></div>\n </div>\n\n <script>\n /* \u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\n Inline @actr/dom \u{2014} ServiceWorkerBridge + WebRtcCoordinator + FastPathForwarder\n \u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550} */\n\n // \u{2500}\u{2500} FastPathForwarder \u{2500}\u{2500}\n class FastPathForwarder {\n constructor(bridge) { this.bridge = bridge; }\n forward(streamId, data) {\n const view = new Uint8Array(data);\n this.bridge.sendToSW({ type: \'fast_path_data\', payload: { streamId, data: view, timestamp: Date.now() } }, [view.buffer]);\n }\n dispose() { }\n }\n\n // \u{2500}\u{2500} ServiceWorkerBridge \u{2500}\u{2500}\n class ServiceWorkerBridge {\n constructor() {\n this.swPort = null;\n this.swTarget = null;\n this.handlers = new Set();\n this._ready = null;\n this._resolveReady = null;\n this._ready = new Promise(r => { this._resolveReady = r; });\n this.clientId = \'client_\' + Date.now() + \'_\' + Math.random().toString(36).slice(2, 8);\n }\n async initialize(swUrl, runtimeConfig) {\n const reg = await navigator.serviceWorker.register(swUrl, { updateViaCache: \'none\' });\n await reg.update();\n await navigator.serviceWorker.ready;\n if (!navigator.serviceWorker.controller) {\n await new Promise(r => {\n const h = () => { navigator.serviceWorker.removeEventListener(\'controllerchange\', h); r(); };\n navigator.serviceWorker.addEventListener(\'controllerchange\', h);\n setTimeout(h, 3000);\n });\n }\n const target = navigator.serviceWorker.controller ?? reg.active;\n if (!target) throw new Error(\'SW target not available\');\n this.swTarget = target;\n const ch = new MessageChannel();\n this.swPort = ch.port1;\n this.swPort.onmessage = e => this._dispatch(e.data);\n target.postMessage({ type: \'DOM_PORT_INIT\', port: ch.port2, clientId: this.clientId, runtimeConfig }, [ch.port2]);\n target.postMessage({ type: \'PING\' });\n this._resolveReady();\n }\n sendToSW(msg, transferables) {\n if (!this.swPort) return;\n transferables && transferables.length ? this.swPort.postMessage(msg, transferables) : this.swPort.postMessage(msg);\n }\n sendDataChannelPort(peerId, port) {\n this.sendToSW({ type: \'register_datachannel_port\', payload: { peerId, port } }, [port]);\n }\n onMessage(handler) { this.handlers.add(handler); return () => this.handlers.delete(handler); }\n _dispatch(msg) { for (const h of this.handlers) { try { h(msg); } catch (e) { console.error(\'[Bridge]\', e); } } }\n getClientId() { return this.clientId; }\n close() {\n if (this.swTarget && this.swTarget.postMessage) {\n try { this.swTarget.postMessage({ type: \'CLIENT_UNREGISTER\', clientId: this.clientId }); } catch (_) { }\n }\n if (this.swPort) {\n try { this.swPort.postMessage({ type: \'unregister_client\' }); } catch (_) { }\n this.swPort.close();\n this.swPort = null;\n }\n this.swTarget = null;\n this.handlers.clear();\n }\n }\n\n // \u{2500}\u{2500} WebRtcCoordinator \u{2500}\u{2500}\n class WebRtcCoordinator {\n constructor(bridge, forwarder, config) {\n this.bridge = bridge;\n this.forwarder = forwarder;\n this.peers = new Map();\n this.pendingSends = new Map();\n this.pendingPortFrames = new Map();\n this.rpcPorts = new Map();\n this.fragmentCounters = new Map();\n this.reassembly = new Map();\n this.turnCred = null;\n this.FRAGMENT_HEADER_SIZE = 8;\n this.DC_MAX_MESSAGE_SIZE = 65535;\n this.DC_MAX_PAYLOAD_SIZE = this.DC_MAX_MESSAGE_SIZE - this.FRAGMENT_HEADER_SIZE;\n this.iceServers = config.iceServers || [{ urls: \'stun:stun.l.google.com:19302\' }];\n this.icePolicy = config.iceTransportPolicy;\n this.lanes = [\n { id: 0, label: \'RPC_RELIABLE\', ordered: true },\n { id: 1, label: \'RPC_SIGNAL\', ordered: true },\n { id: 2, label: \'STREAM_RELIABLE\', ordered: true },\n { id: 3, label: \'STREAM_LATENCY_FIRST\', ordered: false, maxRetransmits: 3 },\n ];\n this.labelToId = new Map(this.lanes.map(l => [l.label, l.id]));\n bridge.onMessage(m => {\n if (m.type === \'webrtc_command\') this._handle(m.payload);\n else if (m.type === \'update_turn_credential\') {\n this.turnCred = { username: m.payload.username, credential: m.payload.password };\n }\n });\n }\n _canBindRpcPort(peer, ch) {\n return !!(peer\n && ch\n && ch.readyState === \'open\'\n && peer.state === \'connected\'\n && peer.connection\n && peer.connection.connectionState === \'connected\');\n }\n _dropRpcPort(pid) {\n const port = this.rpcPorts.get(pid);\n if (port) {\n try { port.close(); } catch (_) { }\n this.rpcPorts.delete(pid);\n }\n }\n _reportStaleRpcPeer(pid, peer, ch, reason = \'unknown\') {\n const state = ch ? ch.readyState : (peer ? peer.state : \'missing\');\n console.log(`[HostPage] staleRpcPeer peer=${pid} reason=${reason} state=${state}`);\n this._notify(\'command_error\', {\n peerId: pid,\n action: \'send_port_frame\',\n error: `datachannel_not_open:${state}`,\n });\n }\n async _handle(cmd) {\n const { action, peerId } = cmd;\n try {\n switch (action) {\n case \'create_peer\': return this._createPeer(peerId);\n case \'set_remote_description\': return await this._setRemote(peerId, cmd.payload.sdp);\n case \'set_local_description\': return await this._setLocal(peerId, cmd.payload.sdp);\n case \'add_ice_candidate\': return await this._addIce(peerId, cmd.payload.candidate);\n case \'create_offer\': await this._ensureChannels(peerId); return await this._createOffer(peerId);\n case \'create_ice_restart_offer\': return await this._iceRestart(peerId);\n case \'create_answer\': return await this._createAnswer(peerId);\n case \'close_peer\': return this._close(peerId);\n case \'send_data\': return this._send(peerId, cmd.payload.channelId, cmd.payload.data);\n }\n } catch (e) { this._notify(\'command_error\', { peerId, action, error: String(e) }); }\n }\n _createPeer(peerId) {\n const existing = this.peers.get(peerId);\n if (existing) {\n const state = existing.connection && existing.connection.connectionState\n ? existing.connection.connectionState\n : existing.state;\n if (state === \'connected\' || state === \'connecting\') return;\n console.log(`[HostPage] replaceStalePeer peer=${peerId} state=${state}`);\n this._close(peerId);\n }\n const servers = (this.iceServers || []).map(s => {\n const urls = Array.isArray(s.urls) ? s.urls : [s.urls];\n if (urls.some(u => u.startsWith(\'turn:\') || u.startsWith(\'turns:\')) && this.turnCred) {\n return { urls: s.urls, username: this.turnCred.username, credential: this.turnCred.credential };\n }\n return s;\n });\n const pc = new RTCPeerConnection({ iceServers: servers, iceTransportPolicy: this.icePolicy });\n const dcs = new Map();\n pc.ondatachannel = e => { const lid = this.labelToId.get(e.channel.label); if (lid !== undefined) this._attach(peerId, lid, e.channel); };\n pc.onicecandidate = e => { if (e.candidate) this._notify(\'ice_candidate\', { peerId, candidate: e.candidate.toJSON() }); };\n pc.onconnectionstatechange = () => {\n this._notify(\'connection_state_changed\', { peerId, state: pc.connectionState });\n const p = this.peers.get(peerId); if (p) p.state = pc.connectionState;\n if (pc.connectionState === \'connected\') {\n const rpc = dcs.get(0);\n if (this._canBindRpcPort(this.peers.get(peerId), rpc)) this._bindRpcPort(peerId, rpc, \'connection_state_connected\');\n } else if (pc.connectionState === \'disconnected\' || pc.connectionState === \'failed\' || pc.connectionState === \'closed\') {\n this._dropRpcPort(peerId);\n this._dropPendingPeerFrames(peerId);\n }\n };\n this.peers.set(peerId, { peerId, connection: pc, dataChannels: dcs, state: pc.connectionState });\n }\n async _setRemote(pid, sdp) { const p = this.peers.get(pid); if (!p) throw new Error(\'no peer\'); await p.connection.setRemoteDescription(sdp); }\n async _setLocal(pid, sdp) { const p = this.peers.get(pid); if (!p) throw new Error(\'no peer\'); await p.connection.setLocalDescription(sdp); }\n async _createOffer(pid) { const p = this.peers.get(pid); if (!p) throw new Error(\'no peer\'); const o = await p.connection.createOffer(); await p.connection.setLocalDescription(o); this._notify(\'local_description\', { peerId: pid, sdp: o }); }\n async _iceRestart(pid) { const p = this.peers.get(pid); if (!p) throw new Error(\'no peer\'); const o = await p.connection.createOffer({ iceRestart: true }); await p.connection.setLocalDescription(o); this._notify(\'ice_restart_local_description\', { peerId: pid, sdp: o }); }\n async _createAnswer(pid) { const p = this.peers.get(pid); if (!p) throw new Error(\'no peer\'); const a = await p.connection.createAnswer(); await p.connection.setLocalDescription(a); this._notify(\'local_description\', { peerId: pid, sdp: a }); }\n async _addIce(pid, cand) {\n const p = this.peers.get(pid); if (!p) throw new Error(\'no peer\');\n if (!cand || typeof cand !== \'object\') return;\n const n = { ...cand, sdpMid: cand.sdpMid ?? cand.sdp_mid ?? null, sdpMLineIndex: cand.sdpMLineIndex ?? cand.sdp_mline_index ?? null, usernameFragment: cand.usernameFragment ?? cand.username_fragment ?? null };\n if (n.sdpMid == null && n.sdpMLineIndex == null) n.sdpMLineIndex = 0;\n await p.connection.addIceCandidate(new RTCIceCandidate(n));\n }\n _send(pid, cid, data) {\n const p = this.peers.get(pid); if (!p) { this._notify(\'command_error\', { peerId: pid, action: \'send_data\', error: \'no peer\' }); return; }\n const ch = p.dataChannels.get(cid);\n if (!ch) { this._queuePendingSend(pid, cid, data); return; }\n if (ch.readyState === \'open\') {\n this._sendFramed(pid, cid, ch, data);\n return;\n }\n if (ch.readyState === \'connecting\') {\n this._queuePendingSend(pid, cid, data);\n return;\n }\n this._dropPendingPeerFrames(pid);\n this._notify(\'command_error\', { peerId: pid, action: \'send_data\', error: `datachannel_not_open:${ch.readyState}` });\n }\n _queuePendingSend(pid, cid, data) {\n let byChannel = this.pendingSends.get(pid);\n if (!byChannel) {\n byChannel = new Map();\n this.pendingSends.set(pid, byChannel);\n }\n let queue = byChannel.get(cid);\n if (!queue) {\n queue = [];\n byChannel.set(cid, queue);\n }\n queue.push(new Uint8Array(data));\n }\n _flushPendingSends(pid, cid) {\n const byChannel = this.pendingSends.get(pid);\n const queue = byChannel && byChannel.get(cid);\n if (!queue || queue.length === 0) return;\n byChannel.delete(cid);\n if (byChannel.size === 0) this.pendingSends.delete(pid);\n for (const payload of queue) this._send(pid, cid, payload);\n }\n _queuePendingPortFrame(pid, frame) {\n this._queuePendingPortFrameForChannel(pid, 0, frame);\n }\n _queuePendingPortFrameForChannel(pid, cid, payload) {\n let queue = this.pendingPortFrames.get(pid);\n if (!queue) {\n queue = [];\n this.pendingPortFrames.set(pid, queue);\n }\n queue.push({ channelId: cid, payload: new Uint8Array(payload) });\n }\n _dropPendingPeerFrames(pid) {\n this.pendingSends.delete(pid);\n this.pendingPortFrames.delete(pid);\n }\n _bindRpcPort(pid, ch, reason = \'unknown\') {\n const peer = this.peers.get(pid);\n if (!this._canBindRpcPort(peer, ch)) {\n console.log(`[HostPage] skipBindRpcPort peer=${pid} reason=${reason} state=${peer ? peer.state : \'missing\'} dc=${ch ? ch.readyState : \'missing\'}`);\n return;\n }\n const prev = this.rpcPorts.get(pid);\n if (prev) {\n try { prev.close(); } catch (_) { }\n }\n console.log(`[HostPage] bindRpcPort peer=${pid} reason=${reason}`);\n const mc = new MessageChannel();\n mc.port1.onmessage = e => {\n const src = this._toU8(e.data);\n if (src.byteLength < 5) {\n console.warn(`[HostPage] drop short SW transport frame len=${src.byteLength}`);\n return;\n }\n const cid = src[0];\n const payload = src.subarray(5);\n const target = peer && peer.dataChannels.get(cid);\n if (target && target.readyState === \'open\') this._sendFramed(pid, cid, target, payload);\n else if (target && target.readyState === \'connecting\') this._queuePendingPortFrameForChannel(pid, cid, payload);\n else {\n this._dropPendingPeerFrames(pid);\n this._notify(\'command_error\', { peerId: pid, action: \'send_port_frame\', error: `datachannel_not_open:${target ? target.readyState : \'missing\'}` });\n }\n };\n this.rpcPorts.set(pid, mc.port1);\n this.bridge.sendDataChannelPort(pid, mc.port2);\n this._flushPendingPortFrames(pid, ch);\n }\n refreshRpcPorts(reason = \'manual_refresh\') {\n for (const [pid, peer] of this.peers.entries()) {\n const rpc = peer.dataChannels.get(0);\n if (this._canBindRpcPort(peer, rpc)) {\n this._bindRpcPort(pid, rpc, reason);\n } else if (reason === \'call_raw\' && (rpc || peer.state !== \'connected\')) {\n this._reportStaleRpcPeer(pid, peer, rpc, reason);\n }\n }\n }\n _flushPendingPortFrames(pid, ch) {\n const queue = this.pendingPortFrames.get(pid);\n if (!queue || queue.length === 0) return;\n if (ch.readyState !== \'open\') return;\n this.pendingPortFrames.delete(pid);\n for (const frame of queue) {\n const target = this.peers.get(pid) && this.peers.get(pid).dataChannels.get(frame.channelId);\n if (!target || target.readyState !== \'open\') {\n this._queuePendingPortFrameForChannel(pid, frame.channelId, frame.payload);\n continue;\n }\n this._sendFramed(pid, frame.channelId, target, frame.payload);\n }\n }\n _toU8(data) {\n if (data instanceof ArrayBuffer) return new Uint8Array(data);\n if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n return new Uint8Array(data);\n }\n _sendFramed(pid, cid, ch, payload) {\n for (const frame of this._createFrames(pid, cid, this._toU8(payload))) ch.send(frame);\n }\n _createFrames(pid, cid, payload) {\n const total = Math.max(1, Math.ceil(payload.byteLength / this.DC_MAX_PAYLOAD_SIZE));\n if (total > 0xffff) throw new Error(`DataChannel payload too large: ${payload.byteLength} bytes`);\n const key = `${pid}:${cid}`;\n const msgId = this.fragmentCounters.get(key) || 0;\n this.fragmentCounters.set(key, (msgId + 1) >>> 0);\n const frames = [];\n for (let i = 0; i < total; i++) {\n const start = i * this.DC_MAX_PAYLOAD_SIZE;\n const end = Math.min(start + this.DC_MAX_PAYLOAD_SIZE, payload.byteLength);\n const chunk = payload.subarray(start, end);\n const frame = new Uint8Array(this.FRAGMENT_HEADER_SIZE + chunk.byteLength);\n const view = new DataView(frame.buffer);\n view.setUint32(0, msgId, false);\n view.setUint16(4, i, false);\n view.setUint16(6, total, false);\n frame.set(chunk, this.FRAGMENT_HEADER_SIZE);\n frames.push(frame);\n }\n return frames;\n }\n _decodeFrame(pid, cid, frame) {\n const src = this._toU8(frame);\n if (src.byteLength < this.FRAGMENT_HEADER_SIZE) {\n console.warn(`[HostPage] drop short DataChannel frame len=${src.byteLength}`);\n return null;\n }\n const view = new DataView(src.buffer, src.byteOffset, src.byteLength);\n const msgId = view.getUint32(0, false);\n const index = view.getUint16(4, false);\n const total = view.getUint16(6, false);\n const payload = src.subarray(this.FRAGMENT_HEADER_SIZE);\n if (total === 0 || index >= total) {\n console.warn(`[HostPage] drop invalid DataChannel fragment msg=${msgId} index=${index} total=${total}`);\n return null;\n }\n if (total === 1) return payload;\n const key = `${pid}:${cid}:${msgId}`;\n let entry = this.reassembly.get(key);\n if (!entry) {\n entry = { totalFrags: total, receivedBytes: 0, fragments: new Map() };\n this.reassembly.set(key, entry);\n }\n if (!entry.fragments.has(index)) {\n const copy = new Uint8Array(payload);\n entry.fragments.set(index, copy);\n entry.receivedBytes += copy.byteLength;\n }\n if (entry.fragments.size !== entry.totalFrags) return null;\n const complete = new Uint8Array(entry.receivedBytes);\n let offset = 0;\n for (let i = 0; i < entry.totalFrags; i++) {\n const frag = entry.fragments.get(i);\n if (!frag) return null;\n complete.set(frag, offset);\n offset += frag.byteLength;\n }\n this.reassembly.delete(key);\n return complete;\n }\n _attach(pid, lid, ch) {\n ch.binaryType = \'arraybuffer\';\n ch.onmessage = e => {\n const buf = e.data instanceof Blob ? null : e.data;\n if (buf instanceof ArrayBuffer) {\n const payload = this._decodeFrame(pid, lid, buf);\n if (payload) this.forwarder.forward(pid + \':\' + lid, payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength));\n } else if (e.data instanceof Blob) {\n e.data.arrayBuffer().then(ab => {\n const payload = this._decodeFrame(pid, lid, ab);\n if (payload) this.forwarder.forward(pid + \':\' + lid, payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength));\n });\n }\n };\n ch.onopen = () => {\n this._notify(\'datachannel_open\', { peerId: pid, channelId: lid, label: ch.label });\n if (lid === 0) {\n this._bindRpcPort(pid, ch, \'channel_open\');\n }\n this._flushPendingSends(pid, lid);\n };\n ch.onclose = () => {\n if (lid === 0) {\n this._dropRpcPort(pid);\n }\n this._dropPendingPeerFrames(pid);\n this._notify(\'datachannel_close\', { peerId: pid, channelId: lid });\n };\n const p = this.peers.get(pid); if (p) p.dataChannels.set(lid, ch);\n }\n async _ensureChannels(pid) {\n const p = this.peers.get(pid); if (!p) throw new Error(\'no peer\');\n for (const l of this.lanes) { if (p.dataChannels.has(l.id)) continue; const c = p.connection.createDataChannel(l.label, { ordered: l.ordered, maxRetransmits: l.maxRetransmits }); this._attach(pid, l.id, c); }\n }\n _close(pid) {\n const p = this.peers.get(pid); if (!p) return;\n for (const c of p.dataChannels.values()) c.close(); p.connection.close(); this.peers.delete(pid);\n this._dropRpcPort(pid);\n this.pendingSends.delete(pid);\n this.pendingPortFrames.delete(pid);\n }\n _notify(eventType, data) { this.bridge.sendToSW({ type: \'webrtc_event\', payload: { eventType, data } }); }\n dispose() { for (const pid of this.peers.keys()) this._close(pid); }\n }\n\n /* \u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\n Minimal Protobuf helpers for EchoRequest / EchoResponse\n EchoRequest { string message = 1; }\n EchoResponse { string reply = 1; uint64 timestamp = 2; }\n \u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550} */\n function encodeVarint(value) {\n const bytes = [];\n while (value > 0x7f) { bytes.push((value & 0x7f) | 0x80); value >>>= 7; }\n bytes.push(value & 0x7f);\n return bytes;\n }\n function readVarint(data, offset) {\n let result = 0, shift = 0, bytesRead = 0;\n while (offset < data.length) {\n const b = data[offset++]; bytesRead++;\n result |= (b & 0x7f) << shift;\n if ((b & 0x80) === 0) break;\n shift += 7;\n }\n return [result >>> 0, bytesRead];\n }\n function encodeEchoRequest(message) {\n const msgBytes = new TextEncoder().encode(message);\n const header = [0x0a, ...encodeVarint(msgBytes.length)];\n const buf = new Uint8Array(header.length + msgBytes.length);\n buf.set(header);\n buf.set(msgBytes, header.length);\n return buf;\n }\n function decodeEchoResponse(data) {\n let reply = \'\', timestamp = 0, pos = 0;\n while (pos < data.length) {\n const tag = data[pos++];\n const fieldNumber = tag >>> 3;\n const wireType = tag & 0x07;\n if (wireType === 2) {\n const [len, br] = readVarint(data, pos); pos += br;\n if (fieldNumber === 1) reply = new TextDecoder().decode(data.subarray(pos, pos + len));\n pos += len;\n } else if (wireType === 0) {\n const [val, br] = readVarint(data, pos); pos += br;\n if (fieldNumber === 2) timestamp = val;\n } else break;\n }\n return { reply, timestamp };\n }\n\n /* \u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\n Actor Host Logic \u{2014} fetch config, start SW, create DOM bridge,\n then render the unified actor view (send controls + runtime log).\n Connection role is negotiated per-peer at runtime, so the host\n page does not care whether this actor typically initiates or\n accepts calls.\n \u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550} */\n\n const statusEl = document.getElementById(\'status\');\n const logEl = document.getElementById(\'log\');\n const resultEl = document.getElementById(\'result\');\n const sendBtn = document.getElementById(\'sendBtn\');\n const msgInput = document.getElementById(\'msgInput\');\n\n // Inbound-request stats (incremented whenever this actor handles a peer call)\n let requestCount = 0, successCount = 0, errorCount = 0;\n\n // Global bridge ref (used by callRaw)\n let _bridge = null;\n let _coordinator = null;\n let _rpcId = 0;\n const _pendingRpc = new Map();\n\n function escapeHtml(s) { return s.replace(/&/g, \'&\').replace(/</g, \'<\').replace(/>/g, \'>\'); }\n function timeStr() { return new Date().toLocaleTimeString(\'en-US\', { hour12: false, hour: \'2-digit\', minute: \'2-digit\', second: \'2-digit\' }); }\n\n /** Inbound-side log panel (adds .log-entry elements to #log) */\n function inboundLog(level, msg) {\n const d = document.createElement(\'div\');\n d.className = \'log-entry \' + level;\n d.innerHTML = \'<span class=\"time\">\' + timeStr() + \'</span>\' + escapeHtml(msg);\n logEl.appendChild(d); logEl.scrollTop = logEl.scrollHeight;\n while (logEl.children.length > 200) logEl.removeChild(logEl.firstChild);\n }\n\n /** Outbound-side result panel (adds .entry elements to #result) */\n function outboundLog(level, msg) {\n const d = document.createElement(\'div\');\n d.className = \'entry\';\n d.innerHTML = \'<span class=\"time\">\' + timeStr() + \'</span><span class=\"\' + level + \'\">\' + escapeHtml(msg) + \'</span>\';\n resultEl.appendChild(d); resultEl.scrollTop = resultEl.scrollHeight;\n while (resultEl.children.length > 200) resultEl.removeChild(resultEl.firstChild);\n }\n\n function shouldMirrorRuntimeLog(msg) {\n return msg.includes(\'route_candidates\')\n || msg.includes(\'role_negotiation\')\n || msg.includes(\'handle_dom_control\')\n || msg.includes(\'workload_dom_response\')\n || msg.includes(\'host.call-raw\')\n || msg.includes(\'State Path: request\')\n || msg.includes(\'[Scheduler]\')\n || msg.includes(\'send_channel_data\')\n || msg.includes(\'register_datachannel_port\')\n || msg.includes(\'handle_rpc_response\')\n || msg.includes(\'connection_state_changed\')\n || msg.includes(\'datachannel_open\')\n || msg.includes(\'datachannel_close\');\n }\n\n /** Generic runtime log (also writes .log-entry to #log, for SW events) */\n function runtimeLog(level, msg) {\n const d = document.createElement(\'div\');\n d.className = \'log-entry \' + level;\n d.innerHTML = \'<span class=\"time\">\' + timeStr() + \'</span>\' + escapeHtml(msg);\n logEl.appendChild(d); logEl.scrollTop = logEl.scrollHeight;\n while (logEl.children.length > 200) logEl.removeChild(logEl.firstChild);\n if (shouldMirrorRuntimeLog(msg)) console.log(\'[HostRuntime]\', msg);\n }\n\n function updateInboundStats() {\n document.getElementById(\'requestCount\').textContent = String(requestCount);\n document.getElementById(\'successCount\').textContent = String(successCount);\n document.getElementById(\'errorCount\').textContent = String(errorCount);\n }\n\n function applyInboundStatsFromRuntimeLog(msg) {\n let changed = false;\n if (msg.includes(\'[Scheduler] Processing RPC request:\') && msg.includes(\'route_key=echo.EchoService.Echo\')) {\n requestCount++;\n changed = true;\n }\n if (msg.includes(\'[Scheduler] Service handler success:\')) {\n successCount++;\n changed = true;\n }\n if (msg.includes(\'[Scheduler] Service handler error:\')) {\n errorCount++;\n changed = true;\n }\n if (changed) updateInboundStats();\n }\n\n /** callRaw \u{2014} send RPC via SW bridge (mirrors @actr/web ActorRef.callRaw) */\n function callRaw(routeKey, payload, timeout) {\n timeout = timeout || 30000;\n if (_coordinator) _coordinator.refreshRpcPorts(\'call_raw\');\n const requestId = \'req_\' + (++_rpcId) + \'_\' + Date.now();\n console.log(\'[HostPage] callRaw send request_id=\' + requestId + \' clientId=\' + (_bridge ? _bridge.getClientId() : \'none\') + \' route=\' + routeKey);\n return new Promise((resolve, reject) => {\n const timer = setTimeout(() => {\n _pendingRpc.delete(requestId);\n reject(new Error(\'RPC timeout after \' + timeout + \'ms\'));\n }, timeout);\n _pendingRpc.set(requestId, { resolve, reject, timer });\n _bridge.sendToSW({\n type: \'control\',\n payload: {\n action: \'rpc_call\',\n request_id: requestId,\n request: { route_key: routeKey, payload: payload, timeout: timeout },\n },\n });\n });\n }\n\n /** Send an outbound echo RPC (available on every actor). */\n async function doSendEcho() {\n if (!_bridge) { outboundLog(\'err\', \'\u{274c} Actor is not initialized\'); return; }\n const message = msgInput.value.trim() || (\'Hello! (\' + new Date().toLocaleTimeString() + \')\');\n try {\n sendBtn.disabled = true;\n outboundLog(\'info\', \'\u{1f4e4} Sending: \"\' + message + \'\"\');\n const payload = encodeEchoRequest(message);\n const responseData = await callRaw(\'echo.EchoService.Echo\', payload);\n const response = decodeEchoResponse(new Uint8Array(responseData));\n outboundLog(\'ok\', \'\u{1f4e5} Reply: \"\' + response.reply + \'\"\');\n if (response.timestamp) outboundLog(\'info\', \'\u{23f1}\u{fe0f} Timestamp: \' + new Date(response.timestamp * 1000).toLocaleString());\n } catch (e) {\n console.error(\'Echo failed:\', e);\n outboundLog(\'err\', \'\u{274c} Request failed: \' + e.message);\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n /* \u{2500}\u{2500} Main \u{2500}\u{2500} */\n (async function main() {\n try {\n runtimeLog(\'info\', \'Fetching runtime config\u{2026}\');\n const resp = await fetch(\'/actr-runtime-config.json\');\n if (!resp.ok) throw new Error(\'Config fetch failed: \' + resp.status);\n const cfg = await resp.json();\n\n // Update info grid. Role is a per-peer concept at connect\n // time, so the host page just shows the actor identity.\n document.getElementById(\'actrType\').textContent = cfg.package.full_type || \'\u{2014}\';\n document.getElementById(\'role\').textContent = \'Actor (symmetric)\';\n document.getElementById(\'sigUrl\').textContent = cfg.signaling_url || \'\u{2014}\';\n document.getElementById(\'realmId\').textContent = cfg.realm_id || \'\u{2014}\';\n document.getElementById(\'serviceName\').textContent = cfg.package.full_type ? (\'echo.\' + cfg.package.actr_name || cfg.package.name || cfg.package.full_type) : \'\u{2014}\';\n\n const swRuntimeConfig = {\n ais_endpoint: cfg.ais_endpoint,\n signaling_url: cfg.signaling_url,\n realm_id: cfg.realm_id,\n client_actr_type: cfg.package.full_type,\n target_actr_type: (cfg.acl_allow_types && cfg.acl_allow_types[0]) || \'\',\n service_fingerprint: \'\',\n acl_allow_types: cfg.acl_allow_types || [],\n package_url: cfg.package_url,\n runtime_wasm_url: cfg.runtime_wasm_url,\n trust: cfg.trust || [],\n };\n\n const iceServers = [\n ...(cfg.stun_urls || []).map(u => ({ urls: u })),\n ...(cfg.turn_urls || []).map(u => ({ urls: u })),\n ];\n\n runtimeLog(\'info\', \'Registering Service Worker\u{2026}\');\n const bridge = new ServiceWorkerBridge();\n _bridge = bridge;\n await bridge.initialize(\'/actor.sw.js\', swRuntimeConfig);\n runtimeLog(\'success\', \'Service Worker ready\');\n\n const forwarder = new FastPathForwarder(bridge);\n const coordinator = new WebRtcCoordinator(bridge, forwarder, { iceServers, iceTransportPolicy: cfg.force_relay ? \'relay\' : undefined });\n _coordinator = coordinator;\n\n // Handle RPC responses for callRaw (client mode)\n bridge.onMessage(m => {\n if (m.type === \'control_response\' && m.payload) {\n const { request_id, data, error } = m.payload;\n console.log(\'[HostPage] control_response request_id=\' + request_id + \' clientId=\' + bridge.getClientId() + \' error=\' + (error ? (error.code || \'?\') + \':\' + (error.message || \'\') : \'none\'));\n const pending = _pendingRpc.get(request_id);\n if (pending) {\n clearTimeout(pending.timer);\n _pendingRpc.delete(request_id);\n if (error) pending.reject(new Error(\'RPC error [\' + (error.code || \'?\') + \']: \' + (error.message || \'\')));\n else pending.resolve(data || new Uint8Array());\n }\n }\n });\n\n // Listen for SW bridge messages (WebRTC events, sw_log)\n bridge.onMessage(m => {\n if (m.type === \'webrtc_event\' && m.payload && m.payload.eventType === \'sw_log\') {\n const d = m.payload.data; runtimeLog(d.level || \'info\', d.message || JSON.stringify(d));\n } else if (m.type === \'webrtc_event\' && m.payload) {\n runtimeLog(\'info\', \'[WebRTC] \' + m.payload.eventType + \': \' + JSON.stringify(m.payload.data));\n }\n });\n\n // Listen for broadcast events from SW (echo_event, sw_log).\n // echo_event is fired whenever this actor handles an inbound\n // request, independent of whether it also initiates calls.\n navigator.serviceWorker.addEventListener(\'message\', e => {\n if (!e.data || !e.data.type) return;\n if (e.data.type === \'sw_log\') {\n let msg = e.data.message || \'\';\n msg = msg.replace(/^\\s*INFO\\s+/, \'\');\n msg = msg.replace(/^\\s*WARN\\s+/, \'\u{26a0}\u{fe0f} \');\n msg = msg.replace(/^\\s*ERROR\\s+/, \'\u{274c} \');\n applyInboundStatsFromRuntimeLog(msg);\n runtimeLog(e.data.level || \'info\', msg);\n } else if (e.data.type === \'echo_event\') {\n switch (e.data.event) {\n case \'request\':\n requestCount++;\n inboundLog(\'info\', \'\u{1f4e8} Received Echo request: \"\' + (e.data.detail || \'\') + \'\"\');\n break;\n case \'response\':\n successCount++;\n inboundLog(\'success\', \'\u{2705} Sent Echo response: \"\' + (e.data.detail || \'\') + \'\"\');\n break;\n case \'error\':\n errorCount++;\n inboundLog(\'error\', \'\u{274c} Handling error: \' + (e.data.detail || \'\'));\n break;\n }\n updateInboundStats();\n }\n });\n\n // Unified actor status: every actor is ready both to accept\n // inbound calls and to initiate outbound ones.\n statusEl.innerHTML = \'<span>\u{2705}</span><span>Actor ready</span>\';\n statusEl.className = \'status ready\';\n sendBtn.disabled = false;\n inboundLog(\'success\', \'\u{2705} Actor started \u{2014} inbound handlers registered\');\n outboundLog(\'ok\', \'\u{2705} Actor initialized \u{2014} outbound send available\');\n\n runtimeLog(\'success\', \'Actor initialized (\' + (cfg.package.full_type || \'unknown\') + \')\');\n\n // Wire up the outbound send button (always available).\n sendBtn.addEventListener(\'click\', doSendEcho);\n msgInput.addEventListener(\'keydown\', e => { if (e.key === \'Enter\' && !sendBtn.disabled) doSendEcho(); });\n\n // Keep-alive\n setInterval(() => { if (navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage({ type: \'PING\' }); }, 20000);\n\n // Cleanup on page unload\n window.addEventListener(\'beforeunload\', () => { bridge.close(); });\n\n } catch (e) {\n statusEl.textContent = \'\u{274c} \' + e.message;\n statusEl.className = \'status error\';\n runtimeLog(\'error\', String(e));\n console.error(e);\n }\n })();\n </script>\n</body>\n\n</html>\n";Expand description
Self-contained HTML host page with inline @actr/dom (WebRTC coordinator).