450 lines
15 KiB
HTML
450 lines
15 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>unibus playground</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0d1117;
|
|
--panel: #161b22;
|
|
--panel2: #1c2230;
|
|
--border: #2b333f;
|
|
--fg: #e6edf3;
|
|
--muted: #8b98a5;
|
|
--accent: #2f81f7;
|
|
--green: #3fb950;
|
|
--gold: #d29922;
|
|
--red: #f85149;
|
|
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--fg);
|
|
font-family: var(--mono);
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
header {
|
|
padding: 14px 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 12px;
|
|
}
|
|
header h1 { margin: 0; font-size: 18px; letter-spacing: 0.5px; }
|
|
header .sub { color: var(--muted); font-size: 12px; }
|
|
.wrap {
|
|
display: grid;
|
|
grid-template-columns: 360px 1fr;
|
|
gap: 16px;
|
|
padding: 16px 20px;
|
|
max-width: 1200px;
|
|
}
|
|
.col { display: flex; flex-direction: column; gap: 14px; }
|
|
.card {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
}
|
|
.card h2 {
|
|
margin: 0 0 10px;
|
|
font-size: 13px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
color: var(--muted);
|
|
}
|
|
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 3px; }
|
|
input[type=text], select {
|
|
width: 100%;
|
|
background: var(--panel2);
|
|
border: 1px solid var(--border);
|
|
color: var(--fg);
|
|
padding: 7px 9px;
|
|
border-radius: 6px;
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
}
|
|
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
|
.row { display: flex; gap: 8px; align-items: center; }
|
|
.row > * { flex: 1; }
|
|
.checkrow { display: flex; align-items: center; gap: 6px; margin: 10px 0; }
|
|
.checkrow input { flex: 0 0 auto; width: auto; }
|
|
.checkrow label { margin: 0; flex: 0 0 auto; }
|
|
button {
|
|
background: var(--accent);
|
|
border: none;
|
|
color: #fff;
|
|
padding: 7px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
margin-top: 8px;
|
|
}
|
|
button:hover { filter: brightness(1.12); }
|
|
button.ghost { background: var(--panel2); border: 1px solid var(--border); color: var(--fg); }
|
|
button.danger { background: #3a1d1d; border: 1px solid var(--red); color: var(--red); }
|
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
.pill {
|
|
display: inline-block;
|
|
background: var(--panel2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 2px 9px;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
.pill.on { color: var(--green); border-color: var(--green); }
|
|
.ident { word-break: break-all; font-size: 11px; color: var(--gold); margin-top: 6px; }
|
|
.copy {
|
|
cursor: pointer; color: var(--accent); font-size: 11px;
|
|
margin-left: 6px; text-decoration: underline;
|
|
}
|
|
#log {
|
|
background: #08090c;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
height: 520px;
|
|
overflow-y: auto;
|
|
font-size: 12.5px;
|
|
white-space: pre-wrap;
|
|
}
|
|
.msg { padding: 2px 0; border-bottom: 1px solid #11151b; }
|
|
.msg .subj { color: var(--accent); }
|
|
.msg .from { color: var(--gold); }
|
|
.msg .meta { color: var(--muted); font-size: 11px; }
|
|
.msg .enc { color: var(--green); }
|
|
.msg .clear { color: var(--muted); }
|
|
.sys { color: var(--muted); font-style: italic; }
|
|
.err { color: var(--red); }
|
|
.help {
|
|
background: var(--panel2);
|
|
border-left: 3px solid var(--accent);
|
|
padding: 10px 12px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
line-height: 1.6;
|
|
}
|
|
.help b { color: var(--fg); }
|
|
.help code { color: var(--gold); }
|
|
.status { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
|
|
.status.ok { color: var(--green); }
|
|
.status.bad { color: var(--red); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>unibus playground</h1>
|
|
<span class="sub">embedded NATS + JetStream · E2E rooms · forward secrecy · SSE</span>
|
|
</header>
|
|
|
|
<div class="wrap">
|
|
<!-- LEFT COLUMN: controls -->
|
|
<div class="col">
|
|
<div class="card">
|
|
<h2>1 · Identity</h2>
|
|
<label>Peer name</label>
|
|
<div class="row">
|
|
<input id="peerName" type="text" placeholder="alice" autocomplete="off" />
|
|
<button id="connectBtn" style="flex:0 0 auto">Connect</button>
|
|
</div>
|
|
<div id="peerIdent" class="ident"></div>
|
|
<div id="connStatus" class="status"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>2 · Rooms</h2>
|
|
<label>Subject (e.g. room.general)</label>
|
|
<input id="roomSubject" type="text" placeholder="room.general" autocomplete="off" />
|
|
<div class="checkrow">
|
|
<input id="roomEncrypt" type="checkbox" />
|
|
<label for="roomEncrypt">🔒 encrypted (E2E)</label>
|
|
</div>
|
|
<button id="createRoomBtn" disabled>Create room</button>
|
|
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
|
|
<label>Join by room_id</label>
|
|
<input id="joinRoomId" type="text" placeholder="01J..." autocomplete="off" />
|
|
<button id="joinBtn" class="ghost" disabled>Join</button>
|
|
<div id="roomStatus" class="status"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>3 · Action</h2>
|
|
<label>Active room</label>
|
|
<select id="activeRoom"></select>
|
|
<label>Message</label>
|
|
<div class="row">
|
|
<input id="msgText" type="text" placeholder="hello bus" autocomplete="off" />
|
|
<button id="sendBtn" style="flex:0 0 auto" disabled>Send</button>
|
|
</div>
|
|
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
|
|
<label>Target peer</label>
|
|
<div class="row">
|
|
<select id="targetPeer"></select>
|
|
<button id="refreshPeersBtn" class="ghost" style="flex:0 0 auto" title="reload peer list">↻</button>
|
|
</div>
|
|
<button id="inviteBtn" disabled>Invite to this room</button>
|
|
<button id="kickBtn" class="danger" disabled>Kick from this room</button>
|
|
<div id="actionStatus" class="status"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RIGHT COLUMN: live messages + help -->
|
|
<div class="col">
|
|
<div class="card" style="padding-bottom:8px">
|
|
<h2>Live messages <span id="streamPill" class="pill">disconnected</span></h2>
|
|
<div id="log"></div>
|
|
</div>
|
|
<div class="help">
|
|
<b>ⓘ How to try it</b><br />
|
|
Open <b>2 tabs</b>. Connect as <code>alice</code> in one and <code>bob</code> in the other.
|
|
In alice: create a <code>🔒 encrypted</code> room, copy the <code>room_id</code>,
|
|
then pick <code>bob</code> as target and <b>Invite to this room</b>.
|
|
In bob: paste that <code>room_id</code> and <b>Join</b>.
|
|
Type in both → messages appear live on each side.
|
|
In alice: <b>Kick</b> bob → bob stops seeing new messages (forward secrecy: the room
|
|
key rotates and bob no longer holds it).
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
"use strict";
|
|
|
|
const state = {
|
|
peer: null, // connected peer name
|
|
rooms: {}, // room_id -> {subject, encrypt}
|
|
es: null, // EventSource
|
|
};
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
async function api(path, body) {
|
|
const opts = { method: "POST", headers: { "Content-Type": "application/json" } };
|
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
const res = await fetch(path, opts);
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
|
return data;
|
|
}
|
|
async function apiGet(path) {
|
|
const res = await fetch(path);
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
|
return data;
|
|
}
|
|
|
|
function setStatus(id, msg, kind) {
|
|
const el = $(id);
|
|
el.textContent = msg || "";
|
|
el.className = "status" + (kind ? " " + kind : "");
|
|
}
|
|
|
|
function short(s, n = 10) {
|
|
if (!s) return "";
|
|
return s.length <= n * 2 ? s : s.slice(0, n) + "…" + s.slice(-4);
|
|
}
|
|
|
|
function hhmmss(ms) {
|
|
const d = new Date(ms);
|
|
const p = (x) => String(x).padStart(2, "0");
|
|
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
|
}
|
|
|
|
function logSys(text, cls) {
|
|
const log = $("log");
|
|
const div = document.createElement("div");
|
|
div.className = "msg " + (cls || "sys");
|
|
div.textContent = text;
|
|
log.appendChild(div);
|
|
log.scrollTop = log.scrollHeight;
|
|
}
|
|
|
|
function logMsg(ev) {
|
|
const log = $("log");
|
|
const div = document.createElement("div");
|
|
div.className = "msg";
|
|
const enc = ev.encrypted
|
|
? '<span class="enc">🔒</span>'
|
|
: '<span class="clear">clear</span>';
|
|
div.innerHTML =
|
|
'<span class="subj">[' + escapeHtml(ev.subject) + ']</span> ' +
|
|
'<span class="from">' + escapeHtml(short(ev.sender)) + '</span> ↦ ' +
|
|
escapeHtml(ev.text) +
|
|
' <span class="meta">· ' + hhmmss(ev.ts) + ' · ' + enc + '</span>';
|
|
log.appendChild(div);
|
|
log.scrollTop = log.scrollHeight;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[c]));
|
|
}
|
|
|
|
function refreshRoomSelect() {
|
|
const sel = $("activeRoom");
|
|
const cur = sel.value;
|
|
sel.innerHTML = "";
|
|
for (const [id, info] of Object.entries(state.rooms)) {
|
|
const opt = document.createElement("option");
|
|
opt.value = id;
|
|
opt.textContent = info.subject + " (" + short(id, 6) + ")" + (info.encrypt ? " 🔒" : "");
|
|
sel.appendChild(opt);
|
|
}
|
|
if (state.rooms[cur]) sel.value = cur;
|
|
const has = Object.keys(state.rooms).length > 0;
|
|
$("sendBtn").disabled = !has;
|
|
$("inviteBtn").disabled = !has;
|
|
$("kickBtn").disabled = !has;
|
|
}
|
|
|
|
async function refreshPeers() {
|
|
try {
|
|
const peers = await apiGet("/api/peers");
|
|
const sel = $("targetPeer");
|
|
const cur = sel.value;
|
|
sel.innerHTML = "";
|
|
for (const p of peers) {
|
|
if (p.name === state.peer) continue; // don't target yourself
|
|
const opt = document.createElement("option");
|
|
opt.value = p.name;
|
|
opt.textContent = p.name + " (" + short(p.endpoint_id, 6) + ")";
|
|
sel.appendChild(opt);
|
|
}
|
|
if ([...sel.options].some((o) => o.value === cur)) sel.value = cur;
|
|
} catch (e) {
|
|
setStatus("actionStatus", "peers: " + e.message, "bad");
|
|
}
|
|
}
|
|
|
|
function openStream(name) {
|
|
if (state.es) state.es.close();
|
|
const es = new EventSource("/api/stream?peer=" + encodeURIComponent(name));
|
|
es.onopen = () => {
|
|
$("streamPill").textContent = "live: " + name;
|
|
$("streamPill").className = "pill on";
|
|
};
|
|
es.onmessage = (e) => {
|
|
try { logMsg(JSON.parse(e.data)); } catch (_) {}
|
|
};
|
|
es.onerror = () => {
|
|
$("streamPill").textContent = "reconnecting…";
|
|
$("streamPill").className = "pill";
|
|
};
|
|
state.es = es;
|
|
}
|
|
|
|
// ---- handlers ----
|
|
|
|
$("connectBtn").onclick = async () => {
|
|
const name = $("peerName").value.trim();
|
|
if (!name) { setStatus("connStatus", "enter a name", "bad"); return; }
|
|
try {
|
|
const res = await api("/api/peer", { name });
|
|
state.peer = res.name;
|
|
state.rooms = {};
|
|
refreshRoomSelect();
|
|
$("peerIdent").innerHTML =
|
|
'endpoint: ' + escapeHtml(res.endpoint_id) +
|
|
' <span class="copy" id="copyId">copy</span>';
|
|
$("copyId").onclick = () => navigator.clipboard.writeText(res.endpoint_id);
|
|
setStatus("connStatus", "connected as " + res.name, "ok");
|
|
$("createRoomBtn").disabled = false;
|
|
$("joinBtn").disabled = false;
|
|
$("log").innerHTML = "";
|
|
logSys("connected as " + res.name + " — listening for messages");
|
|
openStream(res.name);
|
|
refreshPeers();
|
|
} catch (e) {
|
|
setStatus("connStatus", e.message, "bad");
|
|
}
|
|
};
|
|
|
|
$("createRoomBtn").onclick = async () => {
|
|
const subject = $("roomSubject").value.trim();
|
|
const encrypt = $("roomEncrypt").checked;
|
|
if (!subject) { setStatus("roomStatus", "subject required", "bad"); return; }
|
|
try {
|
|
const res = await api("/api/room", { peer: state.peer, subject, encrypt });
|
|
state.rooms[res.room_id] = { subject: res.subject, encrypt: res.encrypt };
|
|
refreshRoomSelect();
|
|
$("activeRoom").value = res.room_id;
|
|
setStatus("roomStatus", "created " + res.room_id + " (click to copy)", "ok");
|
|
$("roomStatus").style.cursor = "pointer";
|
|
$("roomStatus").onclick = () => navigator.clipboard.writeText(res.room_id);
|
|
logSys("created room " + res.subject + " [" + short(res.room_id) + "]" + (encrypt ? " 🔒" : ""));
|
|
} catch (e) {
|
|
setStatus("roomStatus", e.message, "bad");
|
|
}
|
|
};
|
|
|
|
$("joinBtn").onclick = async () => {
|
|
const roomId = $("joinRoomId").value.trim();
|
|
if (!roomId) { setStatus("roomStatus", "room_id required", "bad"); return; }
|
|
try {
|
|
const res = await api("/api/join", { peer: state.peer, room_id: roomId });
|
|
state.rooms[roomId] = { subject: res.subject, encrypt: res.encrypt };
|
|
refreshRoomSelect();
|
|
$("activeRoom").value = roomId;
|
|
setStatus("roomStatus", "joined " + res.subject + (res.encrypt ? " 🔒" : ""), "ok");
|
|
logSys("joined room " + res.subject + " [" + short(roomId) + "]");
|
|
} catch (e) {
|
|
setStatus("roomStatus", e.message, "bad");
|
|
}
|
|
};
|
|
|
|
$("sendBtn").onclick = async () => {
|
|
const roomId = $("activeRoom").value;
|
|
const text = $("msgText").value;
|
|
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
|
|
try {
|
|
await api("/api/publish", { peer: state.peer, room_id: roomId, text });
|
|
$("msgText").value = "";
|
|
setStatus("actionStatus", "sent", "ok");
|
|
} catch (e) {
|
|
setStatus("actionStatus", e.message, "bad");
|
|
}
|
|
};
|
|
$("msgText").addEventListener("keydown", (e) => { if (e.key === "Enter") $("sendBtn").click(); });
|
|
|
|
$("inviteBtn").onclick = async () => {
|
|
const roomId = $("activeRoom").value;
|
|
const target = $("targetPeer").value;
|
|
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
|
|
if (!target) { setStatus("actionStatus", "no target peer (connect another peer first)", "bad"); return; }
|
|
try {
|
|
await api("/api/invite", { peer: state.peer, room_id: roomId, target });
|
|
setStatus("actionStatus", "invited " + target, "ok");
|
|
logSys("invited " + target + " to " + short(roomId));
|
|
} catch (e) {
|
|
setStatus("actionStatus", e.message, "bad");
|
|
}
|
|
};
|
|
|
|
$("kickBtn").onclick = async () => {
|
|
const roomId = $("activeRoom").value;
|
|
const target = $("targetPeer").value;
|
|
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
|
|
if (!target) { setStatus("actionStatus", "no target peer", "bad"); return; }
|
|
try {
|
|
await api("/api/kick", { peer: state.peer, room_id: roomId, target });
|
|
setStatus("actionStatus", "kicked " + target + " (key rotated)", "ok");
|
|
logSys("kicked " + target + " from " + short(roomId) + " — key rotated (forward secrecy)");
|
|
} catch (e) {
|
|
setStatus("actionStatus", e.message, "bad");
|
|
}
|
|
};
|
|
|
|
$("refreshPeersBtn").onclick = refreshPeers;
|
|
$("peerName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("connectBtn").click(); });
|
|
</script>
|
|
</body>
|
|
</html>
|