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