vibedate 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-VZXPYU2C.js → chunk-HDHJLZZG.js} +100 -0
- package/dist/{chunk-JIBC3OHV.js → chunk-ZD6JBWEW.js} +1 -1
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +666 -28
- package/dist/index.d.ts +82 -1
- package/dist/index.js +7 -1
- package/dist/mcp.js +2 -2
- package/package.json +1 -1
|
@@ -1145,6 +1145,103 @@ async function createNostrPoolTransport(urls = DEFAULT_RELAYS) {
|
|
|
1145
1145
|
};
|
|
1146
1146
|
}
|
|
1147
1147
|
|
|
1148
|
+
// src/room.ts
|
|
1149
|
+
import { createHash as createHash3 } from "crypto";
|
|
1150
|
+
var ROOM_TOPIC_PREFIX = "vibedate-room:";
|
|
1151
|
+
function roomTopic(name) {
|
|
1152
|
+
return createHash3("sha256").update(`${ROOM_TOPIC_PREFIX}${name}`, "utf8").digest();
|
|
1153
|
+
}
|
|
1154
|
+
async function startRoom(opts) {
|
|
1155
|
+
const topic = roomTopic(opts.room);
|
|
1156
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1157
|
+
const memberHellos = /* @__PURE__ */ new Map();
|
|
1158
|
+
const messageCbs = /* @__PURE__ */ new Set();
|
|
1159
|
+
const rosterCbs = /* @__PURE__ */ new Set();
|
|
1160
|
+
const signalCbs = /* @__PURE__ */ new Set();
|
|
1161
|
+
const fireRoster = () => {
|
|
1162
|
+
const snapshot = [...memberHellos.values()];
|
|
1163
|
+
for (const cb of rosterCbs) cb(snapshot);
|
|
1164
|
+
};
|
|
1165
|
+
let discovery;
|
|
1166
|
+
const ready = startDiscovery({
|
|
1167
|
+
hello: opts.hello,
|
|
1168
|
+
// ONE topic — the room's own. Rooms are intentionally cross-league, so
|
|
1169
|
+
// every member of the room is accepted regardless of advertised league.
|
|
1170
|
+
topics: [topic],
|
|
1171
|
+
acceptLeague: () => true,
|
|
1172
|
+
...opts.isBlocked === void 0 ? {} : { isBlocked: opts.isBlocked },
|
|
1173
|
+
...opts.bootstrap === void 0 ? {} : { bootstrap: opts.bootstrap },
|
|
1174
|
+
...opts.stateDir === void 0 ? {} : { stateDir: opts.stateDir },
|
|
1175
|
+
...opts.notify === void 0 ? {} : { notify: opts.notify },
|
|
1176
|
+
onLink: (link) => {
|
|
1177
|
+
const handle = link.hello.handle;
|
|
1178
|
+
entries.set(handle, { hello: link.hello, link });
|
|
1179
|
+
memberHellos.set(handle, link.hello);
|
|
1180
|
+
fireRoster();
|
|
1181
|
+
link.onMessage((m) => {
|
|
1182
|
+
for (const cb of messageCbs) cb({ from: handle, ...m });
|
|
1183
|
+
});
|
|
1184
|
+
link.onSignal((frame) => {
|
|
1185
|
+
for (const cb of signalCbs) cb(handle, frame);
|
|
1186
|
+
});
|
|
1187
|
+
link.onClose(() => {
|
|
1188
|
+
const cur = entries.get(handle);
|
|
1189
|
+
if (cur && cur.link === link) {
|
|
1190
|
+
entries.delete(handle);
|
|
1191
|
+
memberHellos.delete(handle);
|
|
1192
|
+
fireRoster();
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
}).then((s) => {
|
|
1197
|
+
discovery = s;
|
|
1198
|
+
return s.ready;
|
|
1199
|
+
});
|
|
1200
|
+
const session = {
|
|
1201
|
+
room: opts.room,
|
|
1202
|
+
topic,
|
|
1203
|
+
hello: opts.hello,
|
|
1204
|
+
members: memberHellos,
|
|
1205
|
+
ready,
|
|
1206
|
+
broadcast(text) {
|
|
1207
|
+
const reached = [];
|
|
1208
|
+
for (const [handle, entry] of entries) {
|
|
1209
|
+
entry.link.send(text);
|
|
1210
|
+
reached.push(handle);
|
|
1211
|
+
}
|
|
1212
|
+
return reached;
|
|
1213
|
+
},
|
|
1214
|
+
onMessage(cb) {
|
|
1215
|
+
messageCbs.add(cb);
|
|
1216
|
+
},
|
|
1217
|
+
onRoster(cb) {
|
|
1218
|
+
rosterCbs.add(cb);
|
|
1219
|
+
},
|
|
1220
|
+
onSignal(cb) {
|
|
1221
|
+
signalCbs.add(cb);
|
|
1222
|
+
},
|
|
1223
|
+
sendSignal(handle, frame) {
|
|
1224
|
+
entries.get(handle)?.link.sendSignal(frame);
|
|
1225
|
+
},
|
|
1226
|
+
linkFor(handle) {
|
|
1227
|
+
return entries.get(handle)?.link;
|
|
1228
|
+
},
|
|
1229
|
+
async close() {
|
|
1230
|
+
await ready.catch(() => void 0);
|
|
1231
|
+
for (const entry of entries.values()) {
|
|
1232
|
+
try {
|
|
1233
|
+
entry.link.close();
|
|
1234
|
+
} catch {
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
entries.clear();
|
|
1238
|
+
memberHellos.clear();
|
|
1239
|
+
if (discovery !== void 0) await discovery.close();
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
return session;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1148
1245
|
// src/index.ts
|
|
1149
1246
|
var LEAGUES = [
|
|
1150
1247
|
{ name: "1M", min: 1e6, max: 4999999 },
|
|
@@ -1320,6 +1417,9 @@ export {
|
|
|
1320
1417
|
conversationTag,
|
|
1321
1418
|
createNostrRelayLink,
|
|
1322
1419
|
createNostrPoolTransport,
|
|
1420
|
+
ROOM_TOPIC_PREFIX,
|
|
1421
|
+
roomTopic,
|
|
1422
|
+
startRoom,
|
|
1323
1423
|
LEAGUES,
|
|
1324
1424
|
BELOW_LEAGUE,
|
|
1325
1425
|
league,
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Recognized top-level commands, plus the synthetic help/version. */
|
|
3
|
-
type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'daemon' | 'mcp' | 'help' | 'version' | null;
|
|
3
|
+
type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'daemon' | 'mcp' | 'room' | 'help' | 'version' | null;
|
|
4
4
|
interface ParsedArgs {
|
|
5
5
|
readonly command: Command;
|
|
6
6
|
/** Port for `open --port`; undefined means "let the OS pick". */
|
|
@@ -23,6 +23,8 @@ interface ParsedArgs {
|
|
|
23
23
|
* auto-fallback-on-timeout is a follow-up. Default false.
|
|
24
24
|
*/
|
|
25
25
|
readonly viaRelay: boolean;
|
|
26
|
+
/** `room <name>` positional, or `--room <name>`: join/create a named room. */
|
|
27
|
+
readonly room: string | undefined;
|
|
26
28
|
}
|
|
27
29
|
/**
|
|
28
30
|
* Parse argv (the slice AFTER the program name) into a command + options.
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
runMcp
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-ZD6JBWEW.js";
|
|
5
5
|
import {
|
|
6
6
|
CANDIDATES,
|
|
7
7
|
DEFAULT_HANDLE,
|
|
8
8
|
LIVE_NOTICE,
|
|
9
9
|
MAX_HANDLE_LEN,
|
|
10
|
+
ROOM_TOPIC_PREFIX,
|
|
10
11
|
TOPIC_PREFIX,
|
|
11
12
|
addBlock,
|
|
12
13
|
allLeagueNames,
|
|
@@ -37,8 +38,9 @@ import {
|
|
|
37
38
|
sanitizePeerText,
|
|
38
39
|
saveHandle,
|
|
39
40
|
signHelloClaims,
|
|
40
|
-
startDiscovery
|
|
41
|
-
|
|
41
|
+
startDiscovery,
|
|
42
|
+
startRoom
|
|
43
|
+
} from "./chunk-HDHJLZZG.js";
|
|
42
44
|
|
|
43
45
|
// src/cli.ts
|
|
44
46
|
import readline from "readline";
|
|
@@ -912,6 +914,56 @@ var webAppHtml = `<!DOCTYPE html>
|
|
|
912
914
|
.incoming-call p{ color: var(--muted); font-size: .85rem; margin: 0 0 18px; }
|
|
913
915
|
.incoming-call .ic-actions{ display: flex; gap: 10px; }
|
|
914
916
|
|
|
917
|
+
/* ---- room full-mesh group video ---- */
|
|
918
|
+
.room-panel{
|
|
919
|
+
position: fixed; right: 18px; bottom: 18px; z-index: 45;
|
|
920
|
+
background: linear-gradient(180deg, var(--bg-card), var(--bg-card-2));
|
|
921
|
+
border: 1px solid var(--border-2); border-radius: 16px; box-shadow: var(--shadow-2);
|
|
922
|
+
padding: 12px 14px; min-width: 240px; max-width: 280px; display: none;
|
|
923
|
+
}
|
|
924
|
+
.room-panel.is-open{ display: block; animation: rise var(--dur-med) var(--ease-out); }
|
|
925
|
+
.room-panel .rp-title{ font-size: .78rem; font-weight: 800; margin-bottom: 6px; display:flex; align-items:center; gap:6px; }
|
|
926
|
+
.room-panel .rp-meta{ font-size: .7rem; color: var(--muted-2); margin-bottom: 10px; line-height: 1.4; word-break: break-all; }
|
|
927
|
+
.room-panel .rp-actions{ display:flex; gap: 8px; }
|
|
928
|
+
.room-panel .rp-btn{
|
|
929
|
+
border: 0; border-radius: 9px; padding: 8px 14px; font-weight: 700; font-size: .78rem;
|
|
930
|
+
background: linear-gradient(180deg, var(--coral), var(--coral-dim)); color: #2a1109;
|
|
931
|
+
flex: 1;
|
|
932
|
+
}
|
|
933
|
+
.room-panel .rp-btn:disabled{ opacity: .4; cursor: not-allowed; }
|
|
934
|
+
.room-panel .rp-btn.is-leave{ background: var(--danger); color: #2a0a0c; }
|
|
935
|
+
.room-video{
|
|
936
|
+
position: fixed; inset: 0; z-index: 68;
|
|
937
|
+
display: none; flex-direction: column; align-items: stretch; justify-content: flex-start;
|
|
938
|
+
background: rgba(12,7,15,.86); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
939
|
+
padding: 18px 18px 80px;
|
|
940
|
+
}
|
|
941
|
+
.room-video.is-open{ display: flex; }
|
|
942
|
+
.room-video .rv-head{
|
|
943
|
+
display:flex; align-items:center; justify-content: space-between; gap: 12px;
|
|
944
|
+
margin-bottom: 14px; color: var(--fg); font-weight: 800; font-size: .9rem;
|
|
945
|
+
}
|
|
946
|
+
.room-video .rv-grid{
|
|
947
|
+
flex: 1; display: grid; gap: 12px; min-height: 0;
|
|
948
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
949
|
+
align-content: start;
|
|
950
|
+
}
|
|
951
|
+
.room-video .rv-tile{
|
|
952
|
+
display:flex; flex-direction: column; gap: 6px; min-width: 0;
|
|
953
|
+
}
|
|
954
|
+
.room-video .rv-tile video{
|
|
955
|
+
width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 14px;
|
|
956
|
+
border: 1px solid var(--border-2); object-fit: cover;
|
|
957
|
+
}
|
|
958
|
+
.room-video .rv-tile .rv-label{
|
|
959
|
+
font-size: .75rem; color: var(--muted); font-weight: 600; word-break: break-all;
|
|
960
|
+
}
|
|
961
|
+
.room-video .rv-leave{
|
|
962
|
+
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
963
|
+
border: 0; border-radius: 999px; padding: 12px 22px; font-weight: 800; font-size: .9rem;
|
|
964
|
+
background: var(--danger); color: #2a0a0c;
|
|
965
|
+
}
|
|
966
|
+
|
|
915
967
|
/* ---- live text chat (browser <-> PeerLink over /live/message) ---- */
|
|
916
968
|
.live-actions{ display: flex; gap: 6px; flex-shrink: 0; }
|
|
917
969
|
.live-row .cbtn{
|
|
@@ -1149,6 +1201,24 @@ var webAppHtml = `<!DOCTYPE html>
|
|
|
1149
1201
|
<button class="vhangup" id="hangupBtn" type="button">Hang up</button>
|
|
1150
1202
|
</div>
|
|
1151
1203
|
|
|
1204
|
+
<aside class="room-panel" id="roomPanel" aria-label="Room">
|
|
1205
|
+
<div class="rp-title"><span class="lp-dot" aria-hidden="true"></span> Room</div>
|
|
1206
|
+
<div class="rp-meta" id="roomMeta">not in a room</div>
|
|
1207
|
+
<div class="rp-actions">
|
|
1208
|
+
<button class="rp-btn" id="roomJoinBtn" type="button">Join video</button>
|
|
1209
|
+
<button class="rp-btn is-leave" id="roomLeaveBtn" type="button" disabled>Leave</button>
|
|
1210
|
+
</div>
|
|
1211
|
+
</aside>
|
|
1212
|
+
|
|
1213
|
+
<div class="room-video" id="roomVideo" role="dialog" aria-modal="true" aria-label="Room video">
|
|
1214
|
+
<div class="rv-head">
|
|
1215
|
+
<span id="roomVideoTitle">Room video</span>
|
|
1216
|
+
<span id="roomVideoCount" style="font-weight:600;color:var(--muted);font-size:.78rem"></span>
|
|
1217
|
+
</div>
|
|
1218
|
+
<div class="rv-grid" id="roomVideoGrid"></div>
|
|
1219
|
+
<button class="rv-leave" id="roomVideoLeaveBtn" type="button">Leave video</button>
|
|
1220
|
+
</div>
|
|
1221
|
+
|
|
1152
1222
|
<script>
|
|
1153
1223
|
(function(){
|
|
1154
1224
|
"use strict";
|
|
@@ -1814,6 +1884,252 @@ var webAppHtml = `<!DOCTYPE html>
|
|
|
1814
1884
|
}
|
|
1815
1885
|
refreshPeers();
|
|
1816
1886
|
setInterval(refreshPeers, 4000);
|
|
1887
|
+
|
|
1888
|
+
/* ---- Room full-mesh group video ----------------------------------------
|
|
1889
|
+
* When vibedate open --room <name> is attached, /api/room exposes the
|
|
1890
|
+
* roster. Each browser grabs getUserMedia once and opens one
|
|
1891
|
+
* RTCPeerConnection to every other member. Deterministic offerer election
|
|
1892
|
+
* (self < other lexicographic) avoids glare. Signaling rides the local
|
|
1893
|
+
* server's merged mailbox: GET /room/signal?handle=SELF returns
|
|
1894
|
+
* { frame: { from, rtc } }, POST /room/signal { handle: TARGET, frame }
|
|
1895
|
+
* relays one rtc-* frame to that member. */
|
|
1896
|
+
var roomPanel = document.getElementById("roomPanel");
|
|
1897
|
+
var roomMeta = document.getElementById("roomMeta");
|
|
1898
|
+
var roomJoinBtn = document.getElementById("roomJoinBtn");
|
|
1899
|
+
var roomLeaveBtn = document.getElementById("roomLeaveBtn");
|
|
1900
|
+
var roomVideo = document.getElementById("roomVideo");
|
|
1901
|
+
var roomVideoGrid = document.getElementById("roomVideoGrid");
|
|
1902
|
+
var roomVideoTitle = document.getElementById("roomVideoTitle");
|
|
1903
|
+
var roomVideoCount = document.getElementById("roomVideoCount");
|
|
1904
|
+
var roomVideoLeaveBtn = document.getElementById("roomVideoLeaveBtn");
|
|
1905
|
+
|
|
1906
|
+
var roomVid = { localStream: null, peers: {}, self: null, room: null, joined: false, polling: false };
|
|
1907
|
+
var roomLastRoster = [];
|
|
1908
|
+
|
|
1909
|
+
function roomPostSignal(handle, frame){
|
|
1910
|
+
return fetch("/room/signal", {
|
|
1911
|
+
method: "POST",
|
|
1912
|
+
headers: { "content-type": "application/json" },
|
|
1913
|
+
body: JSON.stringify({ handle: handle, frame: frame })
|
|
1914
|
+
}).catch(function(){ /* best effort */ });
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function roomFetchSignal(selfHandle, timeoutMs){
|
|
1918
|
+
var ctrl = new AbortController();
|
|
1919
|
+
var t = setTimeout(function(){ ctrl.abort(); }, timeoutMs);
|
|
1920
|
+
return fetch("/room/signal?handle=" + encodeURIComponent(selfHandle), { signal: ctrl.signal })
|
|
1921
|
+
.then(function(r){ clearTimeout(t); return r; })
|
|
1922
|
+
.catch(function(e){ clearTimeout(t); throw e; });
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function makeRoomTile(label, muted){
|
|
1926
|
+
var tile = document.createElement("div");
|
|
1927
|
+
tile.className = "rv-tile";
|
|
1928
|
+
var vid = document.createElement("video");
|
|
1929
|
+
vid.autoplay = true;
|
|
1930
|
+
vid.playsInline = true;
|
|
1931
|
+
if (muted) vid.muted = true;
|
|
1932
|
+
var lab = document.createElement("div");
|
|
1933
|
+
lab.className = "rv-label";
|
|
1934
|
+
lab.textContent = label;
|
|
1935
|
+
tile.appendChild(vid);
|
|
1936
|
+
tile.appendChild(lab);
|
|
1937
|
+
roomVideoGrid.appendChild(tile);
|
|
1938
|
+
return vid;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function updateRoomCount(){
|
|
1942
|
+
var n = 0;
|
|
1943
|
+
if (roomVid.localStream) n++;
|
|
1944
|
+
for (var k in roomVid.peers) if (Object.prototype.hasOwnProperty.call(roomVid.peers, k)) n++;
|
|
1945
|
+
roomVideoCount.textContent = n ? (n + " tile" + (n === 1 ? "" : "s")) : "";
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function connectPeer(handle, role){
|
|
1949
|
+
if (!roomVid.localStream || !roomVid.self) return null;
|
|
1950
|
+
if (roomVid.peers[handle]) return roomVid.peers[handle];
|
|
1951
|
+
var pc = new RTCPeerConnection(rtcConfig());
|
|
1952
|
+
var videoEl = makeRoomTile(handle, false);
|
|
1953
|
+
var peer = { pc: pc, videoEl: videoEl, role: role };
|
|
1954
|
+
roomVid.peers[handle] = peer;
|
|
1955
|
+
updateRoomCount();
|
|
1956
|
+
|
|
1957
|
+
roomVid.localStream.getTracks().forEach(function(t){ pc.addTrack(t, roomVid.localStream); });
|
|
1958
|
+
pc.onicecandidate = function(e){
|
|
1959
|
+
// Trickle ICE to this peer. Empty candidate = end-of-candidates marker.
|
|
1960
|
+
roomPostSignal(handle, {
|
|
1961
|
+
t: "rtc-ice",
|
|
1962
|
+
candidate: e.candidate && e.candidate.candidate ? e.candidate.candidate : ""
|
|
1963
|
+
});
|
|
1964
|
+
};
|
|
1965
|
+
pc.ontrack = function(e){
|
|
1966
|
+
if (e.streams && e.streams[0]) videoEl.srcObject = e.streams[0];
|
|
1967
|
+
};
|
|
1968
|
+
pc.onconnectionstatechange = function(){
|
|
1969
|
+
// Quiet \u2014 closed peers stay in the grid until Leave clears everything.
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
if (role === "offerer") {
|
|
1973
|
+
(async function(){
|
|
1974
|
+
try {
|
|
1975
|
+
var offer = await pc.createOffer();
|
|
1976
|
+
await pc.setLocalDescription(offer);
|
|
1977
|
+
await roomPostSignal(handle, { t: "rtc-offer", sdp: offer.sdp });
|
|
1978
|
+
} catch(e){ /* offer failed \u2014 peer will retry on next roster refresh if still present */ }
|
|
1979
|
+
})();
|
|
1980
|
+
}
|
|
1981
|
+
return peer;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
async function handleRoomSignal(from, rtc){
|
|
1985
|
+
if (!rtc || !from || from === roomVid.self) return;
|
|
1986
|
+
if (rtc.t === "rtc-offer") {
|
|
1987
|
+
var peer = roomVid.peers[from];
|
|
1988
|
+
if (!peer) peer = connectPeer(from, "answerer");
|
|
1989
|
+
if (!peer) return;
|
|
1990
|
+
try {
|
|
1991
|
+
await peer.pc.setRemoteDescription({ type: "offer", sdp: rtc.sdp });
|
|
1992
|
+
var answer = await peer.pc.createAnswer();
|
|
1993
|
+
await peer.pc.setLocalDescription(answer);
|
|
1994
|
+
await roomPostSignal(from, { t: "rtc-answer", sdp: answer.sdp });
|
|
1995
|
+
} catch(e){}
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
if (rtc.t === "rtc-answer") {
|
|
1999
|
+
var pAns = roomVid.peers[from];
|
|
2000
|
+
if (!pAns) return;
|
|
2001
|
+
try { await pAns.pc.setRemoteDescription({ type: "answer", sdp: rtc.sdp }); } catch(e){}
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (rtc.t === "rtc-ice") {
|
|
2005
|
+
var pIce = roomVid.peers[from];
|
|
2006
|
+
if (!pIce) return;
|
|
2007
|
+
// Skip empty candidate (end-of-candidates) \u2014 browsers tolerate either way.
|
|
2008
|
+
if (!rtc.candidate) return;
|
|
2009
|
+
try { await pIce.pc.addIceCandidate({ candidate: rtc.candidate }); } catch(e){}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
async function roomSignalLoop(){
|
|
2014
|
+
if (roomVid.polling) return;
|
|
2015
|
+
roomVid.polling = true;
|
|
2016
|
+
while (roomVid.joined && roomVid.self) {
|
|
2017
|
+
var res;
|
|
2018
|
+
try { res = await roomFetchSignal(roomVid.self, 5000); }
|
|
2019
|
+
catch(e){ await sleep(400); continue; }
|
|
2020
|
+
if (!res || res.status !== 200) { await sleep(400); continue; }
|
|
2021
|
+
var data;
|
|
2022
|
+
try { data = await res.json(); } catch(e){ continue; }
|
|
2023
|
+
var f = data && data.frame;
|
|
2024
|
+
if (!f) continue; // timeout empty
|
|
2025
|
+
// Sender-routed shape: { from, rtc } (or a nested frame.frame fallback).
|
|
2026
|
+
var from = f.from;
|
|
2027
|
+
var rtc = f.rtc || f.frame || f;
|
|
2028
|
+
if (from && rtc && rtc.t) await handleRoomSignal(from, rtc);
|
|
2029
|
+
}
|
|
2030
|
+
roomVid.polling = false;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function meshWithMembers(members){
|
|
2034
|
+
if (!roomVid.joined || !roomVid.self || !roomVid.localStream) return;
|
|
2035
|
+
var self = roomVid.self;
|
|
2036
|
+
var seen = {};
|
|
2037
|
+
(members || []).forEach(function(m){
|
|
2038
|
+
var h = m && m.handle;
|
|
2039
|
+
if (!h || h === self) return;
|
|
2040
|
+
seen[h] = true;
|
|
2041
|
+
if (roomVid.peers[h]) return;
|
|
2042
|
+
// Deterministic glare avoidance: lower handle is the offerer.
|
|
2043
|
+
if (self < h) connectPeer(h, "offerer");
|
|
2044
|
+
// else wait for their offer via the long-poll loop
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
async function joinRoomVideo(){
|
|
2049
|
+
if (roomVid.joined) return;
|
|
2050
|
+
if (!roomVid.self || !roomVid.room) return;
|
|
2051
|
+
roomJoinBtn.disabled = true;
|
|
2052
|
+
try {
|
|
2053
|
+
roomVid.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
|
2054
|
+
} catch(e){
|
|
2055
|
+
roomJoinBtn.disabled = false;
|
|
2056
|
+
roomMeta.textContent = (roomVid.room || "room") + " \xB7 camera/mic blocked";
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
roomVid.joined = true;
|
|
2060
|
+
roomVid.peers = {};
|
|
2061
|
+
roomVideoGrid.innerHTML = "";
|
|
2062
|
+
var localEl = makeRoomTile((roomVid.self || "you") + " (you)", true);
|
|
2063
|
+
localEl.srcObject = roomVid.localStream;
|
|
2064
|
+
roomVideo.classList.add("is-open");
|
|
2065
|
+
roomVideoTitle.textContent = "Room \xB7 " + roomVid.room;
|
|
2066
|
+
roomLeaveBtn.disabled = false;
|
|
2067
|
+
updateRoomCount();
|
|
2068
|
+
roomSignalLoop();
|
|
2069
|
+
// Seed mesh against the latest roster, then refresh once more for races.
|
|
2070
|
+
meshWithMembers(roomLastRoster);
|
|
2071
|
+
try {
|
|
2072
|
+
var res = await fetch("/api/room");
|
|
2073
|
+
if (res.status === 200) {
|
|
2074
|
+
var data = await res.json();
|
|
2075
|
+
var members = (data && data.members) || [];
|
|
2076
|
+
roomLastRoster = members;
|
|
2077
|
+
meshWithMembers(members);
|
|
2078
|
+
}
|
|
2079
|
+
} catch(e){}
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function leaveRoomVideo(){
|
|
2083
|
+
roomVid.joined = false;
|
|
2084
|
+
for (var h in roomVid.peers) {
|
|
2085
|
+
if (!Object.prototype.hasOwnProperty.call(roomVid.peers, h)) continue;
|
|
2086
|
+
try { roomVid.peers[h].pc.close(); } catch(e){}
|
|
2087
|
+
}
|
|
2088
|
+
roomVid.peers = {};
|
|
2089
|
+
if (roomVid.localStream) {
|
|
2090
|
+
roomVid.localStream.getTracks().forEach(function(t){ try { t.stop(); } catch(e){} });
|
|
2091
|
+
roomVid.localStream = null;
|
|
2092
|
+
}
|
|
2093
|
+
roomVideoGrid.innerHTML = "";
|
|
2094
|
+
roomVideo.classList.remove("is-open");
|
|
2095
|
+
roomLeaveBtn.disabled = true;
|
|
2096
|
+
roomJoinBtn.disabled = !roomVid.room;
|
|
2097
|
+
updateRoomCount();
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
roomJoinBtn.addEventListener("click", function(){ joinRoomVideo().catch(function(){ leaveRoomVideo(); }); });
|
|
2101
|
+
roomLeaveBtn.addEventListener("click", leaveRoomVideo);
|
|
2102
|
+
roomVideoLeaveBtn.addEventListener("click", leaveRoomVideo);
|
|
2103
|
+
|
|
2104
|
+
async function refreshRoom(){
|
|
2105
|
+
try {
|
|
2106
|
+
var res = await fetch("/api/room");
|
|
2107
|
+
if (res.status !== 200) return;
|
|
2108
|
+
var data = await res.json();
|
|
2109
|
+
var roomName = data && data.room;
|
|
2110
|
+
if (!roomName) {
|
|
2111
|
+
if (roomVid.joined) leaveRoomVideo();
|
|
2112
|
+
roomVid.room = null;
|
|
2113
|
+
roomVid.self = null;
|
|
2114
|
+
roomLastRoster = [];
|
|
2115
|
+
roomPanel.classList.remove("is-open");
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
roomVid.room = roomName;
|
|
2119
|
+
roomVid.self = data.self || null;
|
|
2120
|
+
roomLastRoster = (data.members) || [];
|
|
2121
|
+
var n = roomLastRoster.length;
|
|
2122
|
+
roomMeta.textContent = roomName +
|
|
2123
|
+
(roomVid.self ? " \xB7 you " + roomVid.self : "") +
|
|
2124
|
+
" \xB7 " + n + " other" + (n === 1 ? "" : "s");
|
|
2125
|
+
roomPanel.classList.add("is-open");
|
|
2126
|
+
roomJoinBtn.disabled = roomVid.joined || !roomVid.self;
|
|
2127
|
+
roomLeaveBtn.disabled = !roomVid.joined;
|
|
2128
|
+
if (roomVid.joined) meshWithMembers(roomLastRoster);
|
|
2129
|
+
} catch(e){}
|
|
2130
|
+
}
|
|
2131
|
+
refreshRoom();
|
|
2132
|
+
setInterval(refreshRoom, 4000);
|
|
1817
2133
|
})();
|
|
1818
2134
|
</script>
|
|
1819
2135
|
</body>
|
|
@@ -1891,6 +2207,93 @@ function createLiveBridge() {
|
|
|
1891
2207
|
};
|
|
1892
2208
|
return bridge;
|
|
1893
2209
|
}
|
|
2210
|
+
var MAX_QUEUED_ROOM_SIGNALS = 200;
|
|
2211
|
+
function createRoomBridge(name) {
|
|
2212
|
+
let session;
|
|
2213
|
+
const messageQueue = [];
|
|
2214
|
+
const messageWaiters = [];
|
|
2215
|
+
const signalQueue = [];
|
|
2216
|
+
const signalWaiters = [];
|
|
2217
|
+
const drainMessage = (m) => {
|
|
2218
|
+
const safe = { ...m, text: sanitizePeerText(m.text) };
|
|
2219
|
+
const waiter = messageWaiters.shift();
|
|
2220
|
+
if (waiter !== void 0) {
|
|
2221
|
+
waiter(safe);
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
messageQueue.push(safe);
|
|
2225
|
+
if (messageQueue.length > MAX_QUEUED_MESSAGES) {
|
|
2226
|
+
messageQueue.splice(0, messageQueue.length - MAX_QUEUED_MESSAGES);
|
|
2227
|
+
}
|
|
2228
|
+
};
|
|
2229
|
+
const drainSignal = (from, frame) => {
|
|
2230
|
+
const tagged = { from, rtc: frame };
|
|
2231
|
+
const waiter = signalWaiters.shift();
|
|
2232
|
+
if (waiter !== void 0) {
|
|
2233
|
+
waiter(tagged);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
signalQueue.push(tagged);
|
|
2237
|
+
if (signalQueue.length > MAX_QUEUED_ROOM_SIGNALS) {
|
|
2238
|
+
signalQueue.splice(0, signalQueue.length - MAX_QUEUED_ROOM_SIGNALS);
|
|
2239
|
+
}
|
|
2240
|
+
};
|
|
2241
|
+
return {
|
|
2242
|
+
name,
|
|
2243
|
+
get self() {
|
|
2244
|
+
return session?.hello.handle;
|
|
2245
|
+
},
|
|
2246
|
+
get members() {
|
|
2247
|
+
if (session === void 0) return [];
|
|
2248
|
+
return [...session.members.values()].map((h) => ({
|
|
2249
|
+
handle: h.handle,
|
|
2250
|
+
league: h.league,
|
|
2251
|
+
harness: h.harness,
|
|
2252
|
+
...h.verified !== void 0 ? { verified: h.verified } : {},
|
|
2253
|
+
...h.identityVerified !== void 0 ? { identityVerified: h.identityVerified } : {}
|
|
2254
|
+
}));
|
|
2255
|
+
},
|
|
2256
|
+
attach(s) {
|
|
2257
|
+
session = s;
|
|
2258
|
+
s.onMessage(drainMessage);
|
|
2259
|
+
s.onSignal((from, frame) => drainSignal(from, frame));
|
|
2260
|
+
},
|
|
2261
|
+
broadcast(text) {
|
|
2262
|
+
session?.broadcast(text);
|
|
2263
|
+
},
|
|
2264
|
+
async pollMessage(timeoutMs) {
|
|
2265
|
+
if (messageQueue.length > 0) return messageQueue.shift() ?? null;
|
|
2266
|
+
return new Promise((resolve) => {
|
|
2267
|
+
const timer = setTimeout(() => {
|
|
2268
|
+
const idx = messageWaiters.indexOf(resolve);
|
|
2269
|
+
if (idx >= 0) messageWaiters.splice(idx, 1);
|
|
2270
|
+
resolve(null);
|
|
2271
|
+
}, timeoutMs);
|
|
2272
|
+
messageWaiters.push((m) => {
|
|
2273
|
+
clearTimeout(timer);
|
|
2274
|
+
resolve(m);
|
|
2275
|
+
});
|
|
2276
|
+
});
|
|
2277
|
+
},
|
|
2278
|
+
sendSignal(handle2, frame) {
|
|
2279
|
+
session?.sendSignal(handle2, frame);
|
|
2280
|
+
},
|
|
2281
|
+
async pollSignal(_handle, timeoutMs) {
|
|
2282
|
+
if (signalQueue.length > 0) return signalQueue.shift() ?? null;
|
|
2283
|
+
return new Promise((resolve) => {
|
|
2284
|
+
const timer = setTimeout(() => {
|
|
2285
|
+
const idx = signalWaiters.indexOf(resolve);
|
|
2286
|
+
if (idx >= 0) signalWaiters.splice(idx, 1);
|
|
2287
|
+
resolve(null);
|
|
2288
|
+
}, timeoutMs);
|
|
2289
|
+
signalWaiters.push((s) => {
|
|
2290
|
+
clearTimeout(timer);
|
|
2291
|
+
resolve(s);
|
|
2292
|
+
});
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
1894
2297
|
function currentState(dir) {
|
|
1895
2298
|
const p = loadProfile(dir);
|
|
1896
2299
|
if (!p) return { connected: false, candidates: [] };
|
|
@@ -2111,11 +2514,101 @@ async function handle(req, res, opts) {
|
|
|
2111
2514
|
sendJson(res, 200, { ok: true });
|
|
2112
2515
|
return;
|
|
2113
2516
|
}
|
|
2517
|
+
if (req.method === "GET" && pathname === "/api/room") {
|
|
2518
|
+
const room = opts.room;
|
|
2519
|
+
sendJson(res, 200, room ? { room: room.name, self: room.self ?? null, members: room.members } : { room: null, self: null, members: [] });
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
if (req.method === "GET" && pathname === "/room/message") {
|
|
2523
|
+
const room = opts.room;
|
|
2524
|
+
if (!room) {
|
|
2525
|
+
sendJson(res, 200, { message: null, reason: "room-not-attached" });
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
const message = await room.pollMessage(25e3);
|
|
2529
|
+
if (req.destroyed || res.writableEnded) return;
|
|
2530
|
+
sendJson(res, 200, { message });
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
if (req.method === "POST" && pathname === "/room/message") {
|
|
2534
|
+
const room = opts.room;
|
|
2535
|
+
if (!room) {
|
|
2536
|
+
sendJson(res, 400, { error: "room-not-attached" });
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
const body = await readBody(req);
|
|
2540
|
+
let parsed = {};
|
|
2541
|
+
try {
|
|
2542
|
+
parsed = JSON.parse(body);
|
|
2543
|
+
} catch {
|
|
2544
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
const text = parsed["text"];
|
|
2548
|
+
if (typeof text !== "string") {
|
|
2549
|
+
sendJson(res, 400, { error: "missing text" });
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
const reParsed = parseFrame(
|
|
2553
|
+
JSON.stringify({ t: "msg", id: randomUUID(), text, at: Date.now() })
|
|
2554
|
+
);
|
|
2555
|
+
if (reParsed === null || reParsed.t !== "msg") {
|
|
2556
|
+
sendJson(res, 400, { error: "invalid message text" });
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
room.broadcast(reParsed.text);
|
|
2560
|
+
sendJson(res, 200, { ok: true });
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
if (req.method === "GET" && pathname === "/room/signal") {
|
|
2564
|
+
const room = opts.room;
|
|
2565
|
+
if (!room) {
|
|
2566
|
+
sendJson(res, 200, { frame: null, reason: "room-not-attached" });
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
const handle2 = url.searchParams.get("handle") ?? "";
|
|
2570
|
+
if (handle2 === "") {
|
|
2571
|
+
sendJson(res, 400, { error: "missing handle" });
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
const frame = await room.pollSignal(handle2, 25e3);
|
|
2575
|
+
if (req.destroyed || res.writableEnded) return;
|
|
2576
|
+
sendJson(res, 200, { frame });
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
if (req.method === "POST" && pathname === "/room/signal") {
|
|
2580
|
+
const room = opts.room;
|
|
2581
|
+
if (!room) {
|
|
2582
|
+
sendJson(res, 400, { error: "room-not-attached" });
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
const body = await readBody(req);
|
|
2586
|
+
let parsed = {};
|
|
2587
|
+
try {
|
|
2588
|
+
parsed = JSON.parse(body);
|
|
2589
|
+
} catch {
|
|
2590
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
const handle2 = typeof parsed["handle"] === "string" ? parsed["handle"] : "";
|
|
2594
|
+
if (handle2 === "") {
|
|
2595
|
+
sendJson(res, 400, { error: "missing handle" });
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
const reParsed = parseFrame(JSON.stringify(parsed["frame"]));
|
|
2599
|
+
if (reParsed === null || reParsed.t !== "rtc-offer" && reParsed.t !== "rtc-answer" && reParsed.t !== "rtc-ice") {
|
|
2600
|
+
sendJson(res, 400, { error: "invalid rtc frame" });
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
room.sendSignal(handle2, reParsed);
|
|
2604
|
+
sendJson(res, 200, { ok: true });
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2114
2607
|
sendJson(res, 404, { error: "not found" });
|
|
2115
2608
|
}
|
|
2116
2609
|
|
|
2117
2610
|
// src/cli.ts
|
|
2118
|
-
var VERSION = "0.
|
|
2611
|
+
var VERSION = "0.7.1";
|
|
2119
2612
|
function parsePort(raw) {
|
|
2120
2613
|
const n = Number(raw);
|
|
2121
2614
|
if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
|
|
@@ -2131,7 +2624,8 @@ function parseArgs(argv) {
|
|
|
2131
2624
|
arg: void 0,
|
|
2132
2625
|
to: void 0,
|
|
2133
2626
|
keepAlive: false,
|
|
2134
|
-
viaRelay: false
|
|
2627
|
+
viaRelay: false,
|
|
2628
|
+
room: void 0
|
|
2135
2629
|
};
|
|
2136
2630
|
for (let i = 0; i < argv.length; i++) {
|
|
2137
2631
|
const a = argv[i];
|
|
@@ -2145,7 +2639,8 @@ function parseArgs(argv) {
|
|
|
2145
2639
|
arg: void 0,
|
|
2146
2640
|
to: void 0,
|
|
2147
2641
|
keepAlive: false,
|
|
2148
|
-
viaRelay: false
|
|
2642
|
+
viaRelay: false,
|
|
2643
|
+
room: void 0
|
|
2149
2644
|
};
|
|
2150
2645
|
}
|
|
2151
2646
|
if (a === "--help" || a === "-h") {
|
|
@@ -2158,7 +2653,8 @@ function parseArgs(argv) {
|
|
|
2158
2653
|
arg: void 0,
|
|
2159
2654
|
to: void 0,
|
|
2160
2655
|
keepAlive: false,
|
|
2161
|
-
viaRelay: false
|
|
2656
|
+
viaRelay: false,
|
|
2657
|
+
room: void 0
|
|
2162
2658
|
};
|
|
2163
2659
|
}
|
|
2164
2660
|
if (a === "--live") {
|
|
@@ -2173,6 +2669,18 @@ function parseArgs(argv) {
|
|
|
2173
2669
|
out = { ...out, viaRelay: true };
|
|
2174
2670
|
continue;
|
|
2175
2671
|
}
|
|
2672
|
+
if (a === "--room") {
|
|
2673
|
+
const next = argv[i + 1];
|
|
2674
|
+
if (next !== void 0) {
|
|
2675
|
+
out = { ...out, room: next };
|
|
2676
|
+
i++;
|
|
2677
|
+
}
|
|
2678
|
+
continue;
|
|
2679
|
+
}
|
|
2680
|
+
if (a.startsWith("--room=")) {
|
|
2681
|
+
out = { ...out, room: a.slice("--room=".length) };
|
|
2682
|
+
continue;
|
|
2683
|
+
}
|
|
2176
2684
|
if (a === "--any") {
|
|
2177
2685
|
out = { ...out, any: true };
|
|
2178
2686
|
continue;
|
|
@@ -2208,7 +2716,7 @@ function parseArgs(argv) {
|
|
|
2208
2716
|
continue;
|
|
2209
2717
|
}
|
|
2210
2718
|
if (a.startsWith("-")) continue;
|
|
2211
|
-
const known = a === "connect" || a === "matches" || a === "discover" || a === "open" || a === "live" || a === "find" || a === "handle" || a === "block" || a === "unblock" || a === "blocklist" || a === "daemon" || a === "mcp" || a === "help" ? a : null;
|
|
2719
|
+
const known = a === "connect" || a === "matches" || a === "discover" || a === "open" || a === "live" || a === "find" || a === "handle" || a === "block" || a === "unblock" || a === "blocklist" || a === "daemon" || a === "mcp" || a === "room" || a === "help" ? a : null;
|
|
2212
2720
|
if (known !== null && out.command === null) {
|
|
2213
2721
|
out = { ...out, command: known };
|
|
2214
2722
|
} else if (out.arg === void 0) {
|
|
@@ -2452,36 +2960,59 @@ async function cmdDiscover(live, any, viaRelay) {
|
|
|
2452
2960
|
);
|
|
2453
2961
|
return 0;
|
|
2454
2962
|
}
|
|
2455
|
-
async function cmdOpen(port, any) {
|
|
2963
|
+
async function cmdOpen(port, any, room) {
|
|
2456
2964
|
const profile = loadProfile();
|
|
2457
2965
|
let live;
|
|
2966
|
+
let roomBridge;
|
|
2458
2967
|
let session;
|
|
2968
|
+
let roomSession;
|
|
2459
2969
|
if (profile) {
|
|
2460
2970
|
if (!canShareLive()) grantLiveConsent();
|
|
2461
|
-
|
|
2971
|
+
if (room !== void 0) {
|
|
2972
|
+
roomBridge = createRoomBridge(room);
|
|
2973
|
+
} else {
|
|
2974
|
+
live = createLiveBridge();
|
|
2975
|
+
}
|
|
2462
2976
|
}
|
|
2463
|
-
const started = await startServer({
|
|
2464
|
-
|
|
2977
|
+
const started = await startServer({
|
|
2978
|
+
port,
|
|
2979
|
+
live,
|
|
2980
|
+
...roomBridge === void 0 ? {} : { room: roomBridge }
|
|
2981
|
+
});
|
|
2982
|
+
if (profile) {
|
|
2465
2983
|
process2.stdout.write(`
|
|
2466
2984
|
${LIVE_NOTICE}
|
|
2467
2985
|
`);
|
|
2468
2986
|
const hello = buildHello(profile);
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2987
|
+
if (room !== void 0 && roomBridge !== void 0) {
|
|
2988
|
+
void startRoom({ hello, room, isBlocked: blockedChecker() }).then((s) => {
|
|
2989
|
+
roomSession = s;
|
|
2990
|
+
roomBridge.attach(s);
|
|
2991
|
+
}).catch(() => {
|
|
2992
|
+
});
|
|
2993
|
+
} else if (live) {
|
|
2994
|
+
const { topics, acceptLeague } = discoveryScope(profile.league, any);
|
|
2995
|
+
void startDiscovery({
|
|
2996
|
+
hello,
|
|
2997
|
+
topics,
|
|
2998
|
+
acceptLeague,
|
|
2999
|
+
isBlocked: blockedChecker(),
|
|
3000
|
+
onLink: (link) => live.addLink(link)
|
|
3001
|
+
}).then((s) => {
|
|
3002
|
+
session = s;
|
|
3003
|
+
}).catch(() => {
|
|
3004
|
+
});
|
|
3005
|
+
}
|
|
2480
3006
|
}
|
|
2481
3007
|
process2.stdout.write(`
|
|
2482
3008
|
vibedating local web app \u2192 ${started.url}
|
|
2483
3009
|
`);
|
|
2484
|
-
if (
|
|
3010
|
+
if (room !== void 0) {
|
|
3011
|
+
process2.stdout.write(
|
|
3012
|
+
` \u2022 room: ${room} \u2014 roster + group chat + full-mesh group video (~6 people; an SFU is the upgrade path for bigger rooms)
|
|
3013
|
+
`
|
|
3014
|
+
);
|
|
3015
|
+
} else if (live) {
|
|
2485
3016
|
process2.stdout.write(
|
|
2486
3017
|
any ? " \u2022 live video + chat available for connected peers (ANY league \u2014 --any)\n" : " \u2022 live video + chat available for connected peers (your league + adjacent; --any = everyone)\n"
|
|
2487
3018
|
);
|
|
@@ -2495,6 +3026,7 @@ async function cmdOpen(port, any) {
|
|
|
2495
3026
|
process2.once("SIGTERM", () => resolve());
|
|
2496
3027
|
});
|
|
2497
3028
|
process2.stdout.write("\n shutting down\u2026\n");
|
|
3029
|
+
if (roomSession) await roomSession.close();
|
|
2498
3030
|
if (session) await session.close();
|
|
2499
3031
|
await new Promise((resolve) => started.server.close(() => resolve()));
|
|
2500
3032
|
return 0;
|
|
@@ -2717,6 +3249,104 @@ async function cmdLive(dating, any, to, keepAlive, viaRelay) {
|
|
|
2717
3249
|
process2.stdout.write("\n");
|
|
2718
3250
|
return 0;
|
|
2719
3251
|
}
|
|
3252
|
+
async function cmdRoom(name, keepAlive) {
|
|
3253
|
+
if (name === void 0 || name.trim() === "") {
|
|
3254
|
+
process2.stderr.write("usage: vibedating room <name>\n");
|
|
3255
|
+
return 1;
|
|
3256
|
+
}
|
|
3257
|
+
const profile = loadProfile();
|
|
3258
|
+
if (!profile) {
|
|
3259
|
+
process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
|
|
3260
|
+
return 1;
|
|
3261
|
+
}
|
|
3262
|
+
if (!canShareLive()) grantLiveConsent();
|
|
3263
|
+
if (!canShareLive()) {
|
|
3264
|
+
process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
|
|
3265
|
+
return 1;
|
|
3266
|
+
}
|
|
3267
|
+
const hello = buildHello(profile);
|
|
3268
|
+
process2.stdout.write("\n");
|
|
3269
|
+
process2.stdout.write(` ${LIVE_NOTICE}
|
|
3270
|
+
`);
|
|
3271
|
+
process2.stdout.write(` ${MARKS_LEGEND}
|
|
3272
|
+
`);
|
|
3273
|
+
process2.stdout.write(` room: ${name} (cross-league \u2014 everyone in the room is a member)
|
|
3274
|
+
`);
|
|
3275
|
+
const session = await startRoom({
|
|
3276
|
+
hello,
|
|
3277
|
+
room: name,
|
|
3278
|
+
isBlocked: blockedChecker()
|
|
3279
|
+
});
|
|
3280
|
+
session.onRoster((members) => {
|
|
3281
|
+
if (members.length === 0) {
|
|
3282
|
+
process2.stdout.write(" \xB7 room empty \u2014 waiting for members\u2026\n");
|
|
3283
|
+
return;
|
|
3284
|
+
}
|
|
3285
|
+
const list = members.map(
|
|
3286
|
+
(m) => `${sanitizePeerText(m.handle)} (${m.league} \xB7 ${m.harness}) ${usageMark(m)}${idMark(m)}`
|
|
3287
|
+
).join(", ");
|
|
3288
|
+
process2.stdout.write(` \xB7 room (${members.length}): ${list}
|
|
3289
|
+
`);
|
|
3290
|
+
});
|
|
3291
|
+
session.onMessage((m) => {
|
|
3292
|
+
process2.stdout.write(` <${sanitizePeerText(m.from)}> ${sanitizePeerText(m.text)}
|
|
3293
|
+
`);
|
|
3294
|
+
});
|
|
3295
|
+
process2.stdout.write(
|
|
3296
|
+
` topic: ${ROOM_TOPIC_PREFIX}${name} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026
|
|
3297
|
+
`
|
|
3298
|
+
);
|
|
3299
|
+
process2.stdout.write(" type to broadcast to the whole room \xB7 /who \xB7 /quit\n");
|
|
3300
|
+
process2.stdout.write(` group video: run \`vibedating open --room ${name}\`
|
|
3301
|
+
|
|
3302
|
+
`);
|
|
3303
|
+
const rl = readline.createInterface({ input: process2.stdin, terminal: false });
|
|
3304
|
+
const stop = () => {
|
|
3305
|
+
rl.close();
|
|
3306
|
+
};
|
|
3307
|
+
process2.once("SIGINT", stop);
|
|
3308
|
+
process2.once("SIGTERM", stop);
|
|
3309
|
+
let quitRequested = false;
|
|
3310
|
+
for await (const line of rl) {
|
|
3311
|
+
const text = line.trim();
|
|
3312
|
+
if (text === "/quit") {
|
|
3313
|
+
quitRequested = true;
|
|
3314
|
+
break;
|
|
3315
|
+
}
|
|
3316
|
+
if (text === "/who") {
|
|
3317
|
+
const members = [...session.members.values()];
|
|
3318
|
+
if (members.length === 0) {
|
|
3319
|
+
process2.stdout.write(" \xB7 room empty\n");
|
|
3320
|
+
} else {
|
|
3321
|
+
for (const m of members) {
|
|
3322
|
+
process2.stdout.write(
|
|
3323
|
+
` \xB7 ${sanitizePeerText(m.handle)} (${m.league} \xB7 ${m.harness}) ${usageMark(m)}${idMark(m)}
|
|
3324
|
+
`
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
if (text === "") continue;
|
|
3331
|
+
const reached = session.broadcast(text);
|
|
3332
|
+
if (reached.length === 0) {
|
|
3333
|
+
process2.stdout.write(" \xB7 room empty \u2014 no one to hear you yet\n");
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
if (!quitRequested && shouldKeepAlive(keepAlive, process2.stdin.isTTY)) {
|
|
3337
|
+
process2.stdout.write(" stdin closed \u2014 staying in the room (Ctrl+C / SIGTERM to leave)\n");
|
|
3338
|
+
await new Promise((resolve) => {
|
|
3339
|
+
process2.once("SIGINT", () => resolve());
|
|
3340
|
+
process2.once("SIGTERM", () => resolve());
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
process2.removeListener("SIGINT", stop);
|
|
3344
|
+
process2.removeListener("SIGTERM", stop);
|
|
3345
|
+
process2.stdout.write("\n leaving the room\u2026\n");
|
|
3346
|
+
await session.close();
|
|
3347
|
+
process2.stdout.write("\n");
|
|
3348
|
+
return 0;
|
|
3349
|
+
}
|
|
2720
3350
|
async function cmdFind(targetArg, any) {
|
|
2721
3351
|
if (targetArg === void 0 || targetArg.trim() === "") {
|
|
2722
3352
|
process2.stderr.write("usage: vibedating find <@handle> [--any]\n");
|
|
@@ -2968,9 +3598,15 @@ Usage:
|
|
|
2968
3598
|
NEW matches, never opens chat/video. install adds a
|
|
2969
3599
|
login service (launchd on macOS, systemd on Linux);
|
|
2970
3600
|
uninstall removes it.
|
|
2971
|
-
vibedating
|
|
2972
|
-
+
|
|
2973
|
-
|
|
3601
|
+
vibedating room <name> Join/create a named room (multi-peer): live roster
|
|
3602
|
+
+ group text chat broadcast to all members.
|
|
3603
|
+
Consent-gated like live. /who lists members, /quit leaves.
|
|
3604
|
+
vibedating open [--port N] [--any] [--room <name>] Serve the local web app (default:
|
|
3605
|
+
random port) + live video + chat with connected peers
|
|
3606
|
+
(your league + adjacent; --any = every league).
|
|
3607
|
+
--room <name> opens the room view instead: roster +
|
|
3608
|
+
group chat + full-mesh group video (~6 people; an SFU
|
|
3609
|
+
is the upgrade path for bigger rooms).
|
|
2974
3610
|
vibedating mcp Run the stdio MCP server (profile, matches)
|
|
2975
3611
|
vibedating --version
|
|
2976
3612
|
vibedating --help
|
|
@@ -3022,11 +3658,13 @@ async function main(argv) {
|
|
|
3022
3658
|
case "discover":
|
|
3023
3659
|
return cmdDiscover(parsed.live, parsed.any, parsed.viaRelay);
|
|
3024
3660
|
case "open":
|
|
3025
|
-
return cmdOpen(parsed.port, parsed.any);
|
|
3661
|
+
return cmdOpen(parsed.port, parsed.any, parsed.room);
|
|
3026
3662
|
case "live":
|
|
3027
3663
|
return cmdLive(parsed.dating, parsed.any, parsed.to, parsed.keepAlive, parsed.viaRelay);
|
|
3028
3664
|
case "find":
|
|
3029
3665
|
return cmdFind(parsed.arg, parsed.any);
|
|
3666
|
+
case "room":
|
|
3667
|
+
return cmdRoom(parsed.arg ?? parsed.room, parsed.keepAlive);
|
|
3030
3668
|
case "mcp":
|
|
3031
3669
|
await runMcp();
|
|
3032
3670
|
return 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -351,6 +351,87 @@ declare function createNostrRelayLink(opts: CreateNostrRelayLinkOptions): Promis
|
|
|
351
351
|
*/
|
|
352
352
|
declare function createNostrPoolTransport(urls?: readonly string[]): Promise<RelayTransport>;
|
|
353
353
|
|
|
354
|
+
/** Namespace prefix so room topics never collide with league topics (or anything
|
|
355
|
+
* else on the DHT). Mirrors {@link p2p.TOPIC_PREFIX} for the 1:1 path. */
|
|
356
|
+
declare const ROOM_TOPIC_PREFIX = "vibedate-room:";
|
|
357
|
+
/**
|
|
358
|
+
* Derive the 32-byte DHT topic for a named room. Deterministic: everyone who
|
|
359
|
+
* joins the same room name anywhere in the world hashes to the same topic,
|
|
360
|
+
* which is the entire discovery mechanism. Pure (mirrors {@link p2p.leagueTopic}).
|
|
361
|
+
*/
|
|
362
|
+
declare function roomTopic(name: string): Buffer;
|
|
363
|
+
/** A room member = a connected, handshaken peer. Same shape as a live peer. */
|
|
364
|
+
type RoomMember = PeerHello;
|
|
365
|
+
/** One group chat message: a `msg` frame's payload tagged with the sender. */
|
|
366
|
+
interface RoomMessage {
|
|
367
|
+
/** Sender's handle (from the validated hello — UNTRUSTED display data). */
|
|
368
|
+
readonly from: string;
|
|
369
|
+
readonly id: string;
|
|
370
|
+
readonly text: string;
|
|
371
|
+
readonly at: number;
|
|
372
|
+
}
|
|
373
|
+
interface RoomOptions {
|
|
374
|
+
/** What we broadcast. Must already be consent-gated by the caller. */
|
|
375
|
+
readonly hello: PeerHello;
|
|
376
|
+
/** Room name → its own DHT topic (see {@link roomTopic}). */
|
|
377
|
+
readonly room: string;
|
|
378
|
+
/** DHT bootstrap nodes; omit for the public DHT. Tests pass a local testnet. */
|
|
379
|
+
readonly bootstrap?: ReadonlyArray<{
|
|
380
|
+
readonly host: string;
|
|
381
|
+
readonly port: number;
|
|
382
|
+
}>;
|
|
383
|
+
/** Where peers.json lives. Defaults to ~/.vibedating. */
|
|
384
|
+
readonly stateDir?: string;
|
|
385
|
+
/** Predicate over a blocked handle — a blocked peer's hello is dropped exactly
|
|
386
|
+
* like in 1:1 discovery. Default: nothing blocked. */
|
|
387
|
+
readonly isBlocked?: (handle: string) => boolean;
|
|
388
|
+
/** Match-notification sink (tests capture with a fake). Best-effort. */
|
|
389
|
+
readonly notify?: NotifySink;
|
|
390
|
+
}
|
|
391
|
+
interface RoomSession {
|
|
392
|
+
/** The room name. */
|
|
393
|
+
readonly room: string;
|
|
394
|
+
/** The joined DHT topic (see {@link roomTopic}). */
|
|
395
|
+
readonly topic: Buffer;
|
|
396
|
+
/** What we broadcast. */
|
|
397
|
+
readonly hello: PeerHello;
|
|
398
|
+
/** Live member set, keyed by handle (excludes self). Mirrors the live view
|
|
399
|
+
* semantics of {@link p2p.DiscoverySession.peers}. */
|
|
400
|
+
readonly members: ReadonlyMap<string, RoomMember>;
|
|
401
|
+
/** Resolves when the first DHT announce/lookup round completes. */
|
|
402
|
+
readonly ready: Promise<unknown>;
|
|
403
|
+
/**
|
|
404
|
+
* Broadcast a text message to ALL room members (fan-out: one `msg` frame per
|
|
405
|
+
* member's {@link PeerLink}). Returns the handles the message was sent to.
|
|
406
|
+
* Best-effort: a member whose link has just closed is skipped silently.
|
|
407
|
+
*/
|
|
408
|
+
broadcast(text: string): readonly string[];
|
|
409
|
+
/** Register a callback fired for each incoming group message (with sender). */
|
|
410
|
+
onMessage(cb: (m: RoomMessage) => void): void;
|
|
411
|
+
/** Register a callback fired whenever the member roster changes (join/leave). */
|
|
412
|
+
onRoster(cb: (members: readonly RoomMember[]) => void): void;
|
|
413
|
+
/** Register a callback fired for each incoming `rtc-*` signaling frame,
|
|
414
|
+
* tagged with the sender's handle (full-mesh video signaling). */
|
|
415
|
+
onSignal(cb: (from: string, frame: RtcFrame) => void): void;
|
|
416
|
+
/** Relay one `rtc-*` signaling frame to one member (by handle). */
|
|
417
|
+
sendSignal(handle: string, frame: RtcFrame): void;
|
|
418
|
+
/** The underlying {@link PeerLink} to a member (by handle), or undefined. */
|
|
419
|
+
linkFor(handle: string): PeerLink | undefined;
|
|
420
|
+
/** Leave the room and destroy the node. Idempotent. */
|
|
421
|
+
close(): Promise<void>;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Join (or create) a named room on the DHT and discover ALL members. CONSENT
|
|
425
|
+
* GATE LIVES WITH THE CALLER — never call this without the `share:live` grant
|
|
426
|
+
* (or an explicit opt-in in the same breath), exactly like
|
|
427
|
+
* {@link p2p.startDiscovery}.
|
|
428
|
+
*
|
|
429
|
+
* Resolves once the first DHT announce/lookup round completes; the returned
|
|
430
|
+
* session's {@link RoomSession.members} map + {@link RoomSession.onRoster}
|
|
431
|
+
* callback track every member that joins or leaves thereafter.
|
|
432
|
+
*/
|
|
433
|
+
declare function startRoom(opts: RoomOptions): Promise<RoomSession>;
|
|
434
|
+
|
|
354
435
|
/**
|
|
355
436
|
* Persistent ed25519 identity — binds a handle to a keypair so a peer cannot
|
|
356
437
|
* impersonate it.
|
|
@@ -558,4 +639,4 @@ declare const CANDIDATES: readonly Candidate[];
|
|
|
558
639
|
*/
|
|
559
640
|
declare function matches(myLeague: string, candidates?: readonly Candidate[]): Candidate[];
|
|
560
641
|
|
|
561
|
-
export { BELOW_LEAGUE, CANDIDATES, type Candidate, type CreateNostrRelayLinkOptions, DEFAULT_RELAYS, DEMO_TOTAL_TOKENS, type DiscoveryOptions, type DiscoverySession, type HelloClaims, type Identity, type IdentityProof, type IdentityVerdict, LEAGUES, LIVE_NOTICE, type League, type LocalUsageSnapshot, type NostrKey, type NostrRelayLink, type PeerHello, type RelayEvent, type RelayFilter, type RelayTransport, type StoredPeer, TOKENS_ENV, TOPIC_PREFIX, VIBEDATE_MESSAGE_KIND, VIBEDATE_PRESENCE_KIND, allLeagueNames, canonicalHelloClaims, classifyHelloIdentity, conversationTag, createNostrPoolTransport, createNostrRelayLink, league, leagueIndex, leagueTopic, leaguesWithin, loadOrCreateIdentity, loadOrCreateNostrKey, loadPeers, matches, parseHandshake, parseTokensEnv, readUsage, recordPeer, recordPeerMessage, serializeHandshake, signHelloClaims, startDiscovery, verifyHelloClaims };
|
|
642
|
+
export { BELOW_LEAGUE, CANDIDATES, type Candidate, type CreateNostrRelayLinkOptions, DEFAULT_RELAYS, DEMO_TOTAL_TOKENS, type DiscoveryOptions, type DiscoverySession, type HelloClaims, type Identity, type IdentityProof, type IdentityVerdict, LEAGUES, LIVE_NOTICE, type League, type LocalUsageSnapshot, type NostrKey, type NostrRelayLink, type PeerHello, ROOM_TOPIC_PREFIX, type RelayEvent, type RelayFilter, type RelayTransport, type RoomMember, type RoomMessage, type RoomOptions, type RoomSession, type StoredPeer, TOKENS_ENV, TOPIC_PREFIX, VIBEDATE_MESSAGE_KIND, VIBEDATE_PRESENCE_KIND, allLeagueNames, canonicalHelloClaims, classifyHelloIdentity, conversationTag, createNostrPoolTransport, createNostrRelayLink, league, leagueIndex, leagueTopic, leaguesWithin, loadOrCreateIdentity, loadOrCreateNostrKey, loadPeers, matches, parseHandshake, parseTokensEnv, readUsage, recordPeer, recordPeerMessage, roomTopic, serializeHandshake, signHelloClaims, startDiscovery, startRoom, verifyHelloClaims };
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DEMO_TOTAL_TOKENS,
|
|
6
6
|
LEAGUES,
|
|
7
7
|
LIVE_NOTICE,
|
|
8
|
+
ROOM_TOPIC_PREFIX,
|
|
8
9
|
TOKENS_ENV,
|
|
9
10
|
TOPIC_PREFIX,
|
|
10
11
|
VIBEDATE_MESSAGE_KIND,
|
|
@@ -29,11 +30,13 @@ import {
|
|
|
29
30
|
readUsage,
|
|
30
31
|
recordPeer,
|
|
31
32
|
recordPeerMessage,
|
|
33
|
+
roomTopic,
|
|
32
34
|
serializeHandshake,
|
|
33
35
|
signHelloClaims,
|
|
34
36
|
startDiscovery,
|
|
37
|
+
startRoom,
|
|
35
38
|
verifyHelloClaims
|
|
36
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-HDHJLZZG.js";
|
|
37
40
|
export {
|
|
38
41
|
BELOW_LEAGUE,
|
|
39
42
|
CANDIDATES,
|
|
@@ -41,6 +44,7 @@ export {
|
|
|
41
44
|
DEMO_TOTAL_TOKENS,
|
|
42
45
|
LEAGUES,
|
|
43
46
|
LIVE_NOTICE,
|
|
47
|
+
ROOM_TOPIC_PREFIX,
|
|
44
48
|
TOKENS_ENV,
|
|
45
49
|
TOPIC_PREFIX,
|
|
46
50
|
VIBEDATE_MESSAGE_KIND,
|
|
@@ -65,8 +69,10 @@ export {
|
|
|
65
69
|
readUsage,
|
|
66
70
|
recordPeer,
|
|
67
71
|
recordPeerMessage,
|
|
72
|
+
roomTopic,
|
|
68
73
|
serializeHandshake,
|
|
69
74
|
signHelloClaims,
|
|
70
75
|
startDiscovery,
|
|
76
|
+
startRoom,
|
|
71
77
|
verifyHelloClaims
|
|
72
78
|
};
|
package/dist/mcp.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibedate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Dating by tokens — matched by usage league across agentic CLIs. Raw usage stays local; only your league is shared. CLI + local web app + MCP. Local-first.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|