wenay-react2 1.0.48 → 1.0.49
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/doc/changes/v1.0.49.md +7 -0
- package/doc/examples/README.md +1 -0
- package/doc/examples/conference-client.html +34 -0
- package/doc/examples/conference-client.ts +212 -0
- package/doc/examples/conference-server.mjs +150 -0
- package/doc/target/my.md +11 -1
- package/doc/wenay-react2.md +2 -0
- package/lib/common/demo/fakeRtcLoopback.d.ts +17 -0
- package/lib/common/demo/fakeRtcLoopback.js +108 -0
- package/lib/common/demo/peerConference.d.ts +449 -0
- package/lib/common/demo/peerConference.js +372 -0
- package/lib/common/src/hooks/index.d.ts +1 -0
- package/lib/common/src/hooks/index.js +1 -0
- package/lib/common/src/hooks/useRoute.d.ts +28 -0
- package/lib/common/src/hooks/useRoute.js +30 -0
- package/lib/common/testUseReact/qa.js +2 -1
- package/package.json +2 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# 1.0.49
|
|
2
|
+
|
|
3
|
+
- Added the conference demo `wenay-react2/demo/peer-conference` (QA card 46): a 3-way host-star room exercising BOTH media technologies — `Peer.createMediaRelay` fan-out gated by call-derived room membership, and a policy-routed focus pair over `Replay.createRouteCoordinator` whose relay hop (`serveReplayChannel` over an in-proc text channel) and WebRTC direct route (`createWebRtcConnector`/`acceptWebRtcDirect`) serve ONE owner-sequenced line, making every hand-off gap-free by seq. Includes a headless `createConferenceWorld` factory (injectable rtc/fps/frame for tests) and a fake-RTC loopback runtime ported from the common2 oracle.
|
|
4
|
+
- Added `useRouteState(coordinator, link)`: thin React binding for route chips — live route state, last denial/failure reason, 500ms connector metrics and a short hand-off log.
|
|
5
|
+
- Added the real-backend conference example: `doc/examples/conference-server.mjs` (Node + Socket.IO peer host with a server-owned room policy: accepted calls join both parties, WebRTC offers are brokered only inside a shared room, `canWatch` reads shared-room membership) and `doc/examples/conference-client.html/.ts` (one seat per tab: media grid through the server relay, peer-store rows promotable to a real RTCPeerConnection datachannel and back).
|
|
6
|
+
|
|
7
|
+
Verification: tsc qa-check; Jest 25 suites / 87 tests including 9 new (star-room roster, relay ACL follows membership, relay-hop seq authority, gap-free promote/demote over loopback RTC, loud policy denials with recovery, server revoke + transport-death fallbacks, late-joiner catch-up, React flow, useRouteState transitions); library build; stand card 46 walkthrough.
|
package/doc/examples/README.md
CHANGED
|
@@ -4,3 +4,4 @@
|
|
|
4
4
|
|
|
5
5
|
The component uses an in-process `Peer.createPeerHost`, so calls, presence, camera and microphone relay work without server credentials. For production, expose `publishOf(account)` and `watchOf(account)` from the RPC server and keep `canWatch` plus call authorization on that server.
|
|
6
6
|
- [stand.tsx](stand.tsx) — exports the entire interactive stand from wenay-react2/demo/stand. Active cards teach canonical APIs; Archive cards remain visible regression/compat examples.
|
|
7
|
+
- [`conference-server.mjs`](conference-server.mjs) + [`conference-client.html`](conference-client.html) / [`conference-client.ts`](conference-client.ts) — the real-backend conference bridge (QA card 46's in-process world is `wenay-react2/demo/peer-conference`). Run `node doc/examples/conference-server.mjs` (needs `socket.io`), serve the client page from any dev server that transpiles TS (the wenay-react2 stand does), and open 2-3 tabs with `?me=a&peers=b,c&host=1` / `?me=b&peers=a,c` / `?me=c&peers=a,b`. The server owns the room policy (accepted calls join both parties to the room; WebRTC offers are brokered only inside a shared room) and the media relay; each peer row in a tab can promote its store link to a real RTCPeerConnection datachannel and re-interpose back.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>wenay conference seat</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; margin: 16px; background: #f6f8fa; color: #1f2328; }
|
|
9
|
+
h2 { margin: 0 0 4px; }
|
|
10
|
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
|
|
11
|
+
.tiles { display: flex; gap: 12px; flex-wrap: wrap; }
|
|
12
|
+
figure { margin: 0; display: grid; gap: 2px; }
|
|
13
|
+
img.tile { width: 160px; height: 90px; background: #111; border-radius: 6px; object-fit: cover; }
|
|
14
|
+
figcaption, .fine { font-size: 12px; color: #57606a; }
|
|
15
|
+
.peers { display: grid; gap: 6px; margin: 8px 0; }
|
|
16
|
+
.peerRow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 6px 10px; }
|
|
17
|
+
.chip { padding: 1px 8px; border-radius: 10px; font-size: 12px; color: #fff; background: #0969da; }
|
|
18
|
+
.chip.direct { background: #1a7f37; }
|
|
19
|
+
.chip.fallback, .chip.offline { background: #cf222e; }
|
|
20
|
+
#log { font: 11px/1.5 monospace; background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 8px; max-height: 180px; overflow: auto; }
|
|
21
|
+
button { cursor: pointer; }
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<h2 id="who">conference seat</h2>
|
|
26
|
+
<div class="fine">media grid = server relay (room ACL) · peer rows = store links, promotable to a real WebRTC datachannel</div>
|
|
27
|
+
<div class="row" id="callControls"></div>
|
|
28
|
+
<div class="tiles" id="tiles"></div>
|
|
29
|
+
<div class="peers" id="peers"></div>
|
|
30
|
+
<div class="row"><input id="status" placeholder="my status (syncs over the peer store)" style="flex: 1; max-width: 320px;" /></div>
|
|
31
|
+
<div id="log"></div>
|
|
32
|
+
<script type="module" src="./conference-client.ts"></script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Browser seat for conference-server.mjs — the real-backend variant of demo/peer-conference.
|
|
2
|
+
// One tab = one participant. Media tiles ride the SERVER relay (room-gated fan-out);
|
|
3
|
+
// each peer row is a peer-store link that starts on the server relay and can promote to
|
|
4
|
+
// a real RTCPeerConnection datachannel (negotiated through the same server signal hub),
|
|
5
|
+
// then re-interpose back — the two technologies of the conference bridge, cross-tab.
|
|
6
|
+
//
|
|
7
|
+
// Serve this page from the wenay-react2 dev stand (vite transpiles the .ts):
|
|
8
|
+
// /doc/examples/conference-client.html?me=a&peers=b,c&host=1&room=demo
|
|
9
|
+
import {io} from "socket.io-client";
|
|
10
|
+
import {createRpcClientHub, Peer} from "wenay-common2";
|
|
11
|
+
|
|
12
|
+
type SeatWorld = {name: string, hue: number, beat: number, status: string};
|
|
13
|
+
type ConfFrame = {n: number, at: number, image: string};
|
|
14
|
+
|
|
15
|
+
const params = new URLSearchParams(location.search);
|
|
16
|
+
const me = params.get("me") ?? "a";
|
|
17
|
+
const peers = (params.get("peers") ?? (me === "a" ? "b,c" : "a")).split(",").map(value => value.trim()).filter(Boolean);
|
|
18
|
+
const isHost = params.get("host") === "1" || me === "a";
|
|
19
|
+
const room = params.get("room") ?? "demo";
|
|
20
|
+
const serverUrl = params.get("server") ?? `${location.protocol}//${location.hostname}:8391`;
|
|
21
|
+
|
|
22
|
+
const el = (id: string) => document.getElementById(id)!;
|
|
23
|
+
const logBox = el("log");
|
|
24
|
+
function log(line: string) {
|
|
25
|
+
const row = document.createElement("div");
|
|
26
|
+
row.textContent = `${new Date().toLocaleTimeString()} ${line}`;
|
|
27
|
+
logBox.prepend(row);
|
|
28
|
+
while (logBox.children.length > 60) logBox.lastChild?.remove();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const seatHue = (account: string) => {
|
|
32
|
+
let hash = 0;
|
|
33
|
+
for (const ch of account) hash = (hash * 31 + ch.charCodeAt(0)) % 360;
|
|
34
|
+
return hash;
|
|
35
|
+
};
|
|
36
|
+
const paintFrame = (account: string, n: number): ConfFrame => {
|
|
37
|
+
const hue = seatHue(account);
|
|
38
|
+
const cx = 16 + (n * 9) % 128;
|
|
39
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="90">` +
|
|
40
|
+
`<rect width="160" height="90" fill="hsl(${hue},65%,42%)"/>` +
|
|
41
|
+
`<circle cx="${cx}" cy="${24 + (n * 5) % 44}" r="7" fill="#fff" opacity="0.9"/>` +
|
|
42
|
+
`<text x="8" y="82" font-size="12" font-family="monospace" fill="#fff">${account} #${n}</text></svg>`;
|
|
43
|
+
return {n, at: Date.now(), image: "data:image/svg+xml," + encodeURIComponent(svg)};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
document.title = `conf seat ${me}`;
|
|
48
|
+
el("who").textContent = `seat ${me}${isHost ? " (host)" : ""} · room "${room}" · peers: ${peers.join(", ")}`;
|
|
49
|
+
|
|
50
|
+
const hub = createRpcClientHub(
|
|
51
|
+
() => io(serverUrl, {transports: ["websocket"], auth: {account: me}}),
|
|
52
|
+
r => ({app: r<any>("app")}) as const,
|
|
53
|
+
);
|
|
54
|
+
const clients = await hub.setToken(null);
|
|
55
|
+
await clients.app.readyStrict();
|
|
56
|
+
const deep: any = clients.app.func;
|
|
57
|
+
log("connected; server time " + await deep.serverTime());
|
|
58
|
+
|
|
59
|
+
// ============== calls: host-star membership over the server policy ==============
|
|
60
|
+
const manager = Peer.createCallManager({port: Peer.callPortOf(deep.peer), self: me});
|
|
61
|
+
const live = new Map<string, any>();
|
|
62
|
+
const controls = el("callControls");
|
|
63
|
+
function renderCalls() {
|
|
64
|
+
controls.innerHTML = "";
|
|
65
|
+
const inRoom = [...live.values()].some(call => call.state() === "active");
|
|
66
|
+
const state = document.createElement("b");
|
|
67
|
+
state.textContent = inRoom ? "in room" : "not in room";
|
|
68
|
+
controls.append(state);
|
|
69
|
+
if (isHost) {
|
|
70
|
+
for (const peer of peers) {
|
|
71
|
+
const current = [...live.values()].find(call => call.peer === peer && call.state() !== "ended");
|
|
72
|
+
const button = document.createElement("button");
|
|
73
|
+
if (!current) {
|
|
74
|
+
button.textContent = `ring ${peer}`;
|
|
75
|
+
button.onclick = () => bindCall(manager.call(peer, {room}));
|
|
76
|
+
} else if (current.state() === "ringing") {
|
|
77
|
+
button.textContent = `cancel ${peer}…`;
|
|
78
|
+
button.onclick = () => current.hangup();
|
|
79
|
+
} else {
|
|
80
|
+
button.textContent = `hang up ${peer}`;
|
|
81
|
+
button.onclick = () => current.hangup();
|
|
82
|
+
}
|
|
83
|
+
controls.append(button);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
const incoming = [...live.values()].find(call => call.direction === "in" && call.state() === "ringing");
|
|
87
|
+
if (incoming) {
|
|
88
|
+
const accept = document.createElement("button");
|
|
89
|
+
accept.textContent = `accept ${incoming.peer}`;
|
|
90
|
+
accept.onclick = () => incoming.accept();
|
|
91
|
+
const decline = document.createElement("button");
|
|
92
|
+
decline.textContent = "decline";
|
|
93
|
+
decline.onclick = () => incoming.decline();
|
|
94
|
+
controls.append(accept, decline);
|
|
95
|
+
}
|
|
96
|
+
const active = [...live.values()].find(call => call.state() === "active");
|
|
97
|
+
if (active) {
|
|
98
|
+
const leave = document.createElement("button");
|
|
99
|
+
leave.textContent = "leave room";
|
|
100
|
+
leave.onclick = () => active.hangup();
|
|
101
|
+
controls.append(leave);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function bindCall(call: any) {
|
|
106
|
+
live.set(call.id, call);
|
|
107
|
+
call.changed.on((state: string) => { log(`call ${call.peer}: ${state}${call.reason() ? ` (${call.reason()})` : ""}`); renderCalls(); });
|
|
108
|
+
void call.ended.then(() => { live.delete(call.id); renderCalls(); });
|
|
109
|
+
renderCalls();
|
|
110
|
+
}
|
|
111
|
+
manager.rings.on(bindCall);
|
|
112
|
+
await manager.ready;
|
|
113
|
+
renderCalls();
|
|
114
|
+
const amInRoom = () => [...live.values()].some(call => call.state() === "active");
|
|
115
|
+
|
|
116
|
+
// ============== technology 1: media grid through the server relay ==============
|
|
117
|
+
let frameN = 0;
|
|
118
|
+
setInterval(function publishSyntheticCam() {
|
|
119
|
+
if (!amInRoom()) return; // stream only while in the room (the server ACL gates viewers anyway)
|
|
120
|
+
frameN++;
|
|
121
|
+
const frame = paintFrame(me, frameN);
|
|
122
|
+
void deep.media.publish("cam", frame, frame.at);
|
|
123
|
+
}, 125);
|
|
124
|
+
|
|
125
|
+
const tiles = el("tiles");
|
|
126
|
+
for (const peer of peers) {
|
|
127
|
+
const figure = document.createElement("figure");
|
|
128
|
+
const img = document.createElement("img");
|
|
129
|
+
img.className = "tile";
|
|
130
|
+
const caption = document.createElement("figcaption");
|
|
131
|
+
caption.textContent = `${peer} · waiting`;
|
|
132
|
+
figure.append(img, caption);
|
|
133
|
+
tiles.append(figure);
|
|
134
|
+
// the server's watch proxy resolves per access through canWatch: a subscription
|
|
135
|
+
// made before the room grant lands on nothing, so keep re-attaching until the
|
|
136
|
+
// FIRST FRAME proves the line is live (then stop churning)
|
|
137
|
+
let off: (() => void) | null = null;
|
|
138
|
+
let lastN = 0, lastAt = 0;
|
|
139
|
+
const attach = () => {
|
|
140
|
+
if (lastAt) return;
|
|
141
|
+
try { off?.(); } catch { /* stale RPC handle */ }
|
|
142
|
+
off = null;
|
|
143
|
+
try {
|
|
144
|
+
const line = deep.media.watch?.[peer]?.cam;
|
|
145
|
+
if (!line?.on) return;
|
|
146
|
+
off = line.on((frame: ConfFrame) => {
|
|
147
|
+
if (!lastAt) log(`watching ${peer} through the server relay`);
|
|
148
|
+
lastN = frame.n; lastAt = frame.at;
|
|
149
|
+
img.src = frame.image;
|
|
150
|
+
});
|
|
151
|
+
} catch (error) { log(`watch ${peer} failed: ${error}`); }
|
|
152
|
+
};
|
|
153
|
+
setInterval(attach, 1500);
|
|
154
|
+
setInterval(() => {
|
|
155
|
+
const age = lastAt ? Math.round((Date.now() - lastAt) / 100) / 10 : 0;
|
|
156
|
+
caption.textContent = `${peer} · #${lastN}${lastAt ? ` · ${age}s ago` : " · waiting"}`;
|
|
157
|
+
}, 500);
|
|
158
|
+
attach();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============== technology 2: peer-store links, relay <-> direct per pair ==============
|
|
162
|
+
const client = Peer.createPeerClient<SeatWorld>({
|
|
163
|
+
remote: deep.peer,
|
|
164
|
+
account: me,
|
|
165
|
+
initial: {name: me, hue: seatHue(me), beat: 0, status: ""},
|
|
166
|
+
rtc: () => new RTCPeerConnection(),
|
|
167
|
+
drain: "micro",
|
|
168
|
+
});
|
|
169
|
+
client.onRoute((event: any) => log(`route ${event.key}: ${event.from} -> ${event.to}${event.reason ? ` (${String(event.reason)})` : ""}`));
|
|
170
|
+
setInterval(function heartbeat() { client.store.state.beat++; }, 1000);
|
|
171
|
+
(el("status") as HTMLInputElement).addEventListener("input", function onStatusInput(event) {
|
|
172
|
+
client.store.state.status = (event.target as HTMLInputElement).value;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const peersBox = el("peers");
|
|
176
|
+
for (const account of peers) {
|
|
177
|
+
const link = client.peer(account);
|
|
178
|
+
const row = document.createElement("div");
|
|
179
|
+
row.className = "peerRow";
|
|
180
|
+
const title = document.createElement("b");
|
|
181
|
+
const chip = document.createElement("span");
|
|
182
|
+
const info = document.createElement("span");
|
|
183
|
+
info.className = "fine";
|
|
184
|
+
const direct = document.createElement("button");
|
|
185
|
+
direct.textContent = "go direct";
|
|
186
|
+
direct.onclick = async () => {
|
|
187
|
+
log(`promoteDirect ${account}…`);
|
|
188
|
+
const result = await link.promoteDirect({timeoutMs: 8000});
|
|
189
|
+
log(result.ok ? `direct with ${account}: ok (${result.state})` : `direct with ${account} denied: ${String(result.reason)}`);
|
|
190
|
+
};
|
|
191
|
+
const relay = document.createElement("button");
|
|
192
|
+
relay.textContent = "back to relay";
|
|
193
|
+
relay.onclick = async () => {
|
|
194
|
+
const result = await link.reinterposeRelay("manual");
|
|
195
|
+
log(result.ok ? `relay with ${account}` : `re-interpose ${account} failed: ${String(result.reason)}`);
|
|
196
|
+
};
|
|
197
|
+
row.append(title, chip, info, direct, relay);
|
|
198
|
+
peersBox.append(row);
|
|
199
|
+
setInterval(function renderPeerRow() {
|
|
200
|
+
const mirrored: any = link.store.state;
|
|
201
|
+
title.textContent = account;
|
|
202
|
+
const route = link.route();
|
|
203
|
+
chip.textContent = `${route} · ${link.state()}`;
|
|
204
|
+
chip.className = "chip" + (route === "direct" ? " direct" : link.state() === "fallback" ? " fallback" : "");
|
|
205
|
+
info.textContent = mirrored
|
|
206
|
+
? `beat ${mirrored.beat} · seq ${link.seq()}${mirrored.status ? ` · "${mirrored.status}"` : ""}`
|
|
207
|
+
: "no mirror yet";
|
|
208
|
+
}, 500);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
main().catch(error => { console.error(error); log("FATAL: " + error); });
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Conference bridge server: the REAL-backend variant of QA card 46 / demo/peer-conference.
|
|
2
|
+
// One Node process owns the peer host (call signaling + WebRTC negotiation pass-through),
|
|
3
|
+
// the media relay and the ROOM policy; browser seats connect over Socket.IO from
|
|
4
|
+
// conference-client.html. Media rides the server relay (technology 1); the peer-store
|
|
5
|
+
// links between seats can promote to a real RTCPeerConnection datachannel negotiated
|
|
6
|
+
// through this same signal hub (technology 2) and re-interpose back.
|
|
7
|
+
//
|
|
8
|
+
// Run: node doc/examples/conference-server.mjs (needs `socket.io` installed)
|
|
9
|
+
// Then open the client page from the wenay-react2 stand dev server in 2-3 tabs:
|
|
10
|
+
// /doc/examples/conference-client.html?me=a&peers=b,c&host=1
|
|
11
|
+
// /doc/examples/conference-client.html?me=b&peers=a,c
|
|
12
|
+
// /doc/examples/conference-client.html?me=c&peers=a,b
|
|
13
|
+
import {createServer} from "node:http";
|
|
14
|
+
import {Server as SocketIOServer} from "socket.io";
|
|
15
|
+
import {createRpcServerAuto, listen, Peer} from "wenay-common2";
|
|
16
|
+
|
|
17
|
+
const PORT = Number(process.env.PORT ?? 8391);
|
|
18
|
+
|
|
19
|
+
// ============== room policy: an accepted call joins BOTH parties to its room ==============
|
|
20
|
+
// Membership is reference-counted per (room, account): in a host-star each member holds
|
|
21
|
+
// one call to the host, while the host holds one call per member. canWatch/canSignal ask
|
|
22
|
+
// "do these two accounts share a live room" — the ONLY media/endpoint authority here.
|
|
23
|
+
function createRoomPolicy() {
|
|
24
|
+
const calls = new Map(); // pair -> {caller, callee, room, state, timer}
|
|
25
|
+
const rooms = new Map(); // room -> Map<account, refCount>
|
|
26
|
+
|
|
27
|
+
function join(room, account) {
|
|
28
|
+
const members = rooms.get(room) ?? new Map();
|
|
29
|
+
members.set(account, (members.get(account) ?? 0) + 1);
|
|
30
|
+
rooms.set(room, members);
|
|
31
|
+
}
|
|
32
|
+
function leave(room, account) {
|
|
33
|
+
const members = rooms.get(room);
|
|
34
|
+
if (!members) return;
|
|
35
|
+
const count = (members.get(account) ?? 0) - 1;
|
|
36
|
+
if (count > 0) members.set(account, count); else members.delete(account);
|
|
37
|
+
if (!members.size) rooms.delete(room);
|
|
38
|
+
}
|
|
39
|
+
function sameRoom(a, b) {
|
|
40
|
+
for (const members of rooms.values()) if (members.has(a) && members.has(b)) return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
function finish(pair, reason) {
|
|
44
|
+
const call = calls.get(pair);
|
|
45
|
+
if (!call) return;
|
|
46
|
+
calls.delete(pair);
|
|
47
|
+
clearTimeout(call.timer);
|
|
48
|
+
if (call.state === "active") {
|
|
49
|
+
leave(call.room, call.caller);
|
|
50
|
+
leave(call.room, call.callee);
|
|
51
|
+
}
|
|
52
|
+
console.log(`[conf] call down: ${call.caller} <-> ${call.callee} in "${call.room}" (${reason})`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function authorize(env) {
|
|
56
|
+
if (env.type === "ring") {
|
|
57
|
+
const room = typeof env.session?.room === "string" ? env.session.room : null;
|
|
58
|
+
if (!env.pair.startsWith("call:") || env.from === env.to || !room || calls.has(env.pair)) return false;
|
|
59
|
+
const timer = setTimeout(() => finish(env.pair, "expired"), 35_000);
|
|
60
|
+
timer.unref?.();
|
|
61
|
+
calls.set(env.pair, {caller: env.from, callee: env.to, room, state: "ringing", timer});
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (env.type === "accept" || env.type === "decline" || env.type === "hangup") {
|
|
65
|
+
const call = calls.get(env.pair);
|
|
66
|
+
if (!call) return false;
|
|
67
|
+
const reverse = env.from === call.callee && env.to === call.caller;
|
|
68
|
+
const participant = reverse || (env.from === call.caller && env.to === call.callee);
|
|
69
|
+
if (env.type === "accept") {
|
|
70
|
+
if (call.state !== "ringing" || !reverse) return false;
|
|
71
|
+
call.state = "active";
|
|
72
|
+
clearTimeout(call.timer);
|
|
73
|
+
join(call.room, call.caller);
|
|
74
|
+
join(call.room, call.callee);
|
|
75
|
+
console.log(`[conf] call up: ${call.caller} <-> ${call.callee} joined "${call.room}"`);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (env.type === "decline" && (!reverse || call.state !== "ringing")) return false;
|
|
79
|
+
if (!participant) return false;
|
|
80
|
+
finish(env.pair, env.type);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
// WebRTC direct negotiation (offer/answer/ice) is endpoint exposure: the server
|
|
84
|
+
// only brokers it between accounts that currently share a room.
|
|
85
|
+
if (env.type === "offer" || env.type === "answer" || env.type === "ice") {
|
|
86
|
+
return sameRoom(env.from, env.to);
|
|
87
|
+
}
|
|
88
|
+
return true; // close/revoke pass through (session teardown)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function dropAccount(account) {
|
|
92
|
+
for (const [pair, call] of calls) {
|
|
93
|
+
if (call.caller === account || call.callee === account) finish(pair, "offline");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
authorize,
|
|
99
|
+
canWatch: (watcher, owner) => watcher !== owner && sameRoom(watcher, owner),
|
|
100
|
+
dropAccount,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const policy = createRoomPolicy();
|
|
105
|
+
const media = Peer.createMediaRelay({lines: {cam: "video"}, videoHistory: 8, canWatch: policy.canWatch});
|
|
106
|
+
const host = Peer.createPeerHost({authorize: policy.authorize});
|
|
107
|
+
|
|
108
|
+
host.presence.changes.on(change => {
|
|
109
|
+
console.log(`[conf] presence: ${change.account} ${change.online ? "online" : "offline"}`);
|
|
110
|
+
if (change.online) return;
|
|
111
|
+
// media BEFORE policy: relay.dropAccount closes watcher views through their
|
|
112
|
+
// policy proxies, so the grants must still be alive while it runs (common2
|
|
113
|
+
// 1.0.74 throws on already-revoked views — see peer-media-relay dropAccount)
|
|
114
|
+
try { media.dropAccount(change.account); } catch (error) { console.log(`[conf] media drop ${change.account}: ${error}`); }
|
|
115
|
+
policy.dropAccount(change.account);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const httpServer = createServer((_req, res) => { res.statusCode = 404; res.end("conference signaling server"); });
|
|
119
|
+
const ioServer = new SocketIOServer(httpServer, {cors: {origin: true}, maxHttpBufferSize: 1e7});
|
|
120
|
+
|
|
121
|
+
ioServer.on("connection", socket => {
|
|
122
|
+
const account = String(socket.handshake.auth?.account ?? "anon");
|
|
123
|
+
const peer = host.connection(account);
|
|
124
|
+
const [disconnect, disconnectListen] = listen();
|
|
125
|
+
socket.on("disconnect", () => { disconnect(); peer.close(); });
|
|
126
|
+
createRpcServerAuto({
|
|
127
|
+
socket: {emit: (key, data) => socket.emit(key, data), on: (key, cb) => socket.on(key, cb)},
|
|
128
|
+
socketKey: "app",
|
|
129
|
+
object: {
|
|
130
|
+
serverTime: () => new Date().toISOString(),
|
|
131
|
+
// the fragment is exposed verbatim: the browser's CallManager, peer-store
|
|
132
|
+
// client and WebRTC connector all ride this one signal port over the socket
|
|
133
|
+
peer: peer.fragment,
|
|
134
|
+
media: {
|
|
135
|
+
publish: media.publishOf(account),
|
|
136
|
+
watch: media.watchOf(account),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
disconnectListen,
|
|
140
|
+
});
|
|
141
|
+
console.log(`[conf] ${account} connected`);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
httpServer.listen(PORT, () => {
|
|
145
|
+
console.log(`[conf] conference bridge is up on :${PORT}`);
|
|
146
|
+
console.log("[conf] open the client page from the stand dev server, e.g.");
|
|
147
|
+
console.log(" /doc/examples/conference-client.html?me=a&peers=b,c&host=1");
|
|
148
|
+
console.log(" /doc/examples/conference-client.html?me=b&peers=a,c");
|
|
149
|
+
console.log(" /doc/examples/conference-client.html?me=c&peers=a,b");
|
|
150
|
+
});
|
package/doc/target/my.md
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## In Progress
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **Конференц-демо: групповой созвон на обеих технологиях — relay-мост + WebRTC direct.** DONE 2026-07-13 (обе надиктовки 2026-07-12, дословно в конце файла).
|
|
8
|
+
- Отгружено: `wenay-react2/demo/peer-conference` — headless `createConferenceWorld` (host-star: групповой звонок КОМПОНУЕТСЯ из парных, один CallManager держит N-1 исходящих; roster активных звонков = единственный ACL-авторитет) + `ConferenceCallDemo` (QA card 46: грид на `Peer.createMediaRelay` fan-out, focus-пара на `Replay.createRouteCoordinator` — relay-hop `serveReplayChannel` и WebRTC `createWebRtcConnector`/`acceptWebRtcDirect` обслуживают ОДНУ owner-seq линию, hand-off без разрывов по seq; никогда не сервить маршрут из `relay.watchOf` — per-watcher seq). Хук `useRouteState`. Fake-RTC loopback (порт оракула common2) для jest/песочниц.
|
|
9
|
+
- **Через бэк (надиктовка №2)**: `doc/examples/conference-server.mjs` (Node+Socket.IO: peer host, комнатная политика — accepted call вступает обеими сторонами в room, offer брокерится только внутри общей комнаты, media relay с `canWatch`=sameRoom) + `conference-client.html/.ts` (одно место = одна вкладка: грид через серверный relay, peer-store строки с promoteDirect в настоящий cross-tab RTCPeerConnection и reinterpose обратно).
|
|
10
|
+
- Проверено: tsc, Jest 25/87 (9 новых), build; карточка 46 пройдена живьём на РЕАЛЬНОМ RTCPeerConnection (promote #153→#174 без разрыва, policy-отказы, server revoke, kill transport → auto-fallback); бэкенд-вариант — две вкладки, cross-tab direct (beat 47→51→69 gap-free), сервер переживает mid-room disconnect. Durable: `doc/changes/v1.0.49.md`, brief-док §Route, examples README.
|
|
11
|
+
- Побочно найден и записан upstream-баг common2 (см. Blocked ниже).
|
|
8
12
|
|
|
9
13
|
|
|
10
14
|
«Hook extraction — do-now кандидаты (3)» выполнен 2026-07-09: `useCacheMapPersistence` (cache.ts), `useResizeObserver`/`useElementSize` (MyResizeObserver.tsx), `useLogsPageTable` (logs.tsx); старые имена — compatibility wrappers, card 25 не тронута. Проверено: tsc qa-check, jest 45/45, build, стенд 3010 (карточки 21 F5-persistence / 19 resize / 9 logs). Durable-запись: `doc/changes/v1.0.41.md`; parked/отклонённые кандидаты остаются в `doc/progress/hook-extraction-audit.md`.
|
|
@@ -101,6 +105,8 @@ _Пусто._ «ColumnState present-gate» выпущен как v1.0.44: repro
|
|
|
101
105
|
|
|
102
106
|
## Blocked — уровень приложения (не эта React-библиотека)
|
|
103
107
|
|
|
108
|
+
- **Upstream-баг wenay-common2 1.0.74 (репозиторий common2): `createMediaRelay().dropAccount()` падает на policy-отозванных вью.** `dropAccount` делает `Object.values(ownerView)` по КЭШИРОВАННОМУ Proxy-вью (`peer-media-relay.js:127`), чей `get` возвращает `undefined`, когда `canWatch` уже отказывает — а к моменту offline звонок обычно завершён и гранты сняты → `line.close()` на undefined роняет процесс. Воспроизведено 2026-07-13 сервером conference-примера; **та же мина лежит в собственном demo/server.ts common2** (порядок `callPolicy.dropAccount` → `media.dropAccount`). Фикс на стороне common2: итерировать raw `policyLines` (target Proxy) либо guard'ить undefined. Обход в нашем примере: `media.dropAccount` ДО снятия грантов + try/catch (doc/examples/conference-server.mjs).
|
|
109
|
+
|
|
104
110
|
Эти задачи живут на стороне приложения (`clientBacktest` / супер-админка), а не в этой библиотеке. Держим как источник, пока не будет доступа к тем репозиториям.
|
|
105
111
|
|
|
106
112
|
- **Balance / Client Backtest**: total по категориям и общий total; фильтры по каждой категории; количество монет в базовой валюте (или фильтр относительно прайса, как на главной странице — есть спец-фильтр для кредитов); кликабельность — возврат займа (по одной и массово, правой кнопкой), панель займа как на главной, трансфер в spot/др. (в т.ч. массовый), массовая очистка лимиток, массовое закрытие позиций на фьючерсах; окно рыночной сделки (buy/sell по выбранному рынку) по кнопке Asset и по BaseOne.
|
|
@@ -125,3 +131,7 @@ _Пусто._ «ColumnState present-gate» выпущен как v1.0.44: repro
|
|
|
125
131
|
> вопрос насоклько этот код вобще лакнчиный и юзабельный?ты бы сам ег остал использовать [оценка 2026-07-10: вызовы лаконичны, изучение — нет; задача «API-витрина» в Ready] да оформи задачу в my.md но текущий релиз запуш
|
|
126
132
|
|
|
127
133
|
> Запиши такую задачу. Значит, надо М-м-м, где менюшка? Ст-- для таблицы есть, особенно вот этот ползунок. Надо его улучшить. А именно: там вот есть фильтр по возрастанию, по убыванию, кнопочка одна. Во-первых, он должен быть множественный. Либо не множественный. Пока что пусть он будет нормальный. С-- но опционально такая настройка должна быть. А-а-а, второй момент важен. Есть ли помимооо фильтра у нас открывается второй ползунок? Если нужно. Там получается две кнопки вызвать ползунок второй. Второй ползунок, оннн так же, как и первый, состоит из каких-то ячеек иии ползунков . Ну, вот этих вот. То есть, допустим, мы там можем задать Кремль, край? Естественно, если мы выбрали «Больше» и выбираем тут-- там на ползунке есть эти цифры, то они, цифры, расположены, ну, вся эта куча, чтобы человек мог выбрать Там в левую часть либо в правую часть. То есть технический человек ставит сразу две точки? А-а-а, ты поставил крайние, я посередине. Это означает, что, ну, был этот диапазон. Крайний правый посередине означает, что был правый диапазон. Если он добавляет еще точки, мы их всё равно считаем по крайним точкам. Нам неважно, если этооо Если этооо, к-к-какой-нибудь, например, тверд-- ну, либо какой-нибудь другой вариант еще рассматриваем. Вот. А если этооо, к примеру Там название чего-то, и этих названий определенное количество. Допустим Девять типов чего-то. То мыыы-- э-э-э, вот у нас есть девять типов. Мы точно так же точки расставляем. Человек может выбрать несколько точек: либо диапазон, автооо, типа, либо конкретно. И режим выбирается, к примеру, вот этой кнопкой: фильтровать вверх, вниз. То есть мы выбираем левее, точки, между точками, только по точкам, там меньше точек. Ну, чё-то типа такого. Вот, я думаю, это будет очень удобно. Особенно на мобиле. Иии нужна настройка ещёёё Сохранить этот настрой. Ну, типа, вот, н-- допустим, так сделали . Понятно, кнопка «Сбросить» их вот, и кнопка «Сохранить». Только пока не очень понимаю, куда это поставить. Особенно на мобиле, на телефоне там места не хватает, ну, точнее, не хватает. Типа ты, допустим, настроил настройку . Кнопку нажал, их там запримел. Иии, соответственно, точно так же, еслиии тыыы Как загружать правильно? Загружать на опять же, смотреть, чё ты сохраняешься. Сохраняешься с именем?
|
|
134
|
+
|
|
135
|
+
> Мы можем в кассете приме работа сделать демонстрацию имеего в силе моста сделать. Причем для созвона нескольких участников. Используя обе технологии. Для меня это важно [записано 2026-07-12; прочтение: «в качестве примера работы сделать демонстрацию, имею в виду мосты, причём для созвона нескольких участников, используя обе технологии (relay-мост + WebRTC direct)»; задача в In Progress]
|
|
136
|
+
|
|
137
|
+
> Прочем желательно пример через бэк тоже. [записано 2026-07-12; прочтение: «причём желательно пример через бэк(енд) тоже» — дополнение к конференц-демо: серверный вариант моста]
|
package/doc/wenay-react2.md
CHANGED
|
@@ -474,6 +474,8 @@ tt.live; tt.seq; tt.head; tt.pause(); tt.play(); tt.seek({seq})
|
|
|
474
474
|
|
|
475
475
|
Route hand-off here is the MANUAL surface (`switchRoute`). common2 1.0.67 `Replay.createRouteCoordinator` moves route decisions (policy, promote/fallback/shadow) out of the consumer; a coordinator `link.subscribe(cb)` handle has the same shape (`ready, seq(), label(), active()`) and survives every route change, so components consuming a coordinator link do not need `switchRoute` at all. common2 1.0.68 supplies the direct transport for it: `createWebRtcConnector` with an app-injected `rtc: () => new RTCPeerConnection(cfg)` factory (the browser — i.e. the React app — owns that injection) and signaling over the existing RPC socket (`createSignalHub`). common2 1.0.69 wraps the whole stack into the `Peer` SDK (`wenay-common2/peer`): `createPeerClient(...).peer(account)` returns a live mirrored store (works with `useStoreNode`/`useStoreKeys` as-is) + route control, surviving relay<->direct hand-offs in one seq space. `usePeer(client, account)` is the thin React adapter: it returns that mirror plus low-frequency route/status and explicit route/resync controls; it does not own journal, repair or transport state. `usePeerCalls(manager)` binds a `Peer.createCallManager` to rings/active/call UI state without owning `manager.close()` or signal policy. The exact interactive components used by QA cards 41–44 ship from `wenay-react2/demo/peer-media`; [`doc/examples/peer-call-media.tsx`](examples/peer-call-media.tsx) shows the import. `usePeerPresence(fragment.presence)` subscribes before reading the host snapshot and exposes online/offline edges. For calls with media, wire `Peer.createMediaRelay` on the server (`publishOf(owner)`, `watchOf(watcher)`, `canWatch`) and attach viewers only when the server-owned call policy grants access; React must never make the ACL decision.
|
|
476
476
|
|
|
477
|
+
Conference composition ships as a live example: `wenay-react2/demo/peer-conference` (QA card 46) builds a 3-way host-star room where group calling is COMPOSED from pairwise calls (one CallManager holds N-1 concurrent outgoing calls; the roster of active calls is the single ACL authority), the grid rides `Peer.createMediaRelay` fan-out, and a focus pair rides `Replay.createRouteCoordinator` over ONE owner-sequenced line served by BOTH routes — an in-proc `serveReplayChannel` hop and a WebRTC datachannel (`createWebRtcConnector`/`acceptWebRtcDirect`) — so hand-offs are gap-free by seq. Never serve a coordinator route from `relay.watchOf` lines: those are per-watcher re-sequenced journals. `useRouteState(coordinator, link)` is the thin React binding for the route chip: state, last reason, 500ms connector metrics and a short hand-off log; the caller owns coordinator/link lifecycle. The real-backend variant lives in [`doc/examples/conference-server.mjs`](examples/conference-server.mjs) + [`conference-client.html`](examples/conference-client.html): the Node server owns the peer host, the room policy (accepted calls join both parties; offers are brokered only inside a shared room) and the media relay; browser seats connect over Socket.IO, and each peer-store pair can `promoteDirect()` to a real RTCPeerConnection negotiated through that same server hub.
|
|
478
|
+
|
|
477
479
|
Media lines (common2 1.0.66 `Media.createAudioSource` / `Media.createVideoSource`) are ordinary binary Listen sources; with `replay:true` their `listen` is a replay line, so `useReplaySubscribe` / `useReplayFrame` consume mic/camera frames with no media-specific hook. `useMediaSource(kind, options)` is only the capture lifecycle adapter (`start`, `stop`, device selection, state and stats); it stops a started source on unmount and returns `listen` unchanged. Each frame is one `Uint8Array` (`Media.decodeMediaFrame`); draw/play it via ref (canvas, AudioContext), never useState — the same rule as any high-frequency line. Without `replay`, the plain `listen` works with the listen hooks above.
|
|
478
480
|
|
|
479
481
|
Contract: `off()` on unmount, StrictMode-safe; seq survives resubscribes inside one mount (keepSeq, default on) — a resubscribe reconnects with `{since}` and gets the journal tail, not a keyframe. Across a FULL unmount/remount keep the position outside via `onSeq` and pass it back as `since`. `seq()` is a getter — high-frequency lines (video frames, ticks) do not re-render per event; draw to canvas via ref, bypassing VDOM. Freshness: detection lives in wenay-common2 (`staleMs` watchdog); the non-route hooks mirror its edge-triggered `onStale` into `stale`, so a fresh 100 ev/s line causes zero extra renders. Route hand-off is explicit through `switchRoute(nextRemote, {label?, since?, reset?, policy?, hint?})`: old route stays live while the replacement catches up by `seq`, then closes. `useReplayHistory` is archive playback — staleness does not apply. QA cards 23 (video line + conflation + time travel + freshness), 24 (store sync), 25 (per-key feed), and 26 (route hand-off) are the live examples.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process fake WebRTC runtime for the conference demo and its tests, ported from
|
|
3
|
+
* the wenay-common2 acceptance oracle (replay/route-webrtc.test.ts). SDP = pc id,
|
|
4
|
+
* ICE trickle is simulated, a datachannel is a pair of in-proc endpoints. It is
|
|
5
|
+
* injected exactly like a browser would inject `() => new RTCPeerConnection(cfg)`,
|
|
6
|
+
* so the demo can run where real WebRTC is unavailable (jsdom, sandboxed panes).
|
|
7
|
+
*/
|
|
8
|
+
import { Replay } from "wenay-common2";
|
|
9
|
+
export declare function createFakeRtcNet(): {
|
|
10
|
+
pc: () => Replay.RtcPeerConnection;
|
|
11
|
+
stats: {
|
|
12
|
+
ice: number;
|
|
13
|
+
channels: number;
|
|
14
|
+
};
|
|
15
|
+
killLiveChannels: () => number;
|
|
16
|
+
};
|
|
17
|
+
export type FakeRtcNet = ReturnType<typeof createFakeRtcNet>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export function createFakeRtcNet() {
|
|
2
|
+
let n = 0;
|
|
3
|
+
const pcs = new Map();
|
|
4
|
+
const stats = { ice: 0, channels: 0 };
|
|
5
|
+
const liveChannels = new Set();
|
|
6
|
+
function makeDcPair() {
|
|
7
|
+
let closed = false;
|
|
8
|
+
function side(other) {
|
|
9
|
+
const me = { onopen: null, onmessage: null, onclose: null, onerror: null };
|
|
10
|
+
me.send = (data) => {
|
|
11
|
+
if (closed)
|
|
12
|
+
return;
|
|
13
|
+
setTimeout(function deliverDc() { if (!closed)
|
|
14
|
+
other().onmessage?.({ data }); }, 0);
|
|
15
|
+
};
|
|
16
|
+
me.close = () => {
|
|
17
|
+
if (closed)
|
|
18
|
+
return;
|
|
19
|
+
closed = true;
|
|
20
|
+
liveChannels.delete(me);
|
|
21
|
+
setTimeout(function closeDc() { me.onclose?.(); other().onclose?.(); }, 0);
|
|
22
|
+
};
|
|
23
|
+
return me;
|
|
24
|
+
}
|
|
25
|
+
let a, b;
|
|
26
|
+
a = side(() => b);
|
|
27
|
+
b = side(() => a);
|
|
28
|
+
liveChannels.add(a);
|
|
29
|
+
return [a, b];
|
|
30
|
+
}
|
|
31
|
+
function tryConnect(me) {
|
|
32
|
+
if (!me.local || !me.remote)
|
|
33
|
+
return;
|
|
34
|
+
const other = pcs.get(me.remote.sdp ?? "");
|
|
35
|
+
if (!other || !other.local || !other.remote || other.remote.sdp !== me.id)
|
|
36
|
+
return;
|
|
37
|
+
const initiator = me.pendingDcs.length ? me : other;
|
|
38
|
+
const responder = initiator === me ? other : me;
|
|
39
|
+
if (!initiator.pendingDcs.length || initiator.linked)
|
|
40
|
+
return;
|
|
41
|
+
initiator.linked = responder.linked = true;
|
|
42
|
+
for (const dc of initiator.pendingDcs) {
|
|
43
|
+
const [a, b] = makeDcPair();
|
|
44
|
+
// the pre-created local end is linked to its newborn twin
|
|
45
|
+
dc.attach(a);
|
|
46
|
+
stats.channels++;
|
|
47
|
+
setTimeout(function announceChannel() {
|
|
48
|
+
responder.pc.ondatachannel?.({ channel: b });
|
|
49
|
+
dc.fireOpen();
|
|
50
|
+
}, 0);
|
|
51
|
+
}
|
|
52
|
+
initiator.pendingDcs = [];
|
|
53
|
+
}
|
|
54
|
+
function pc() {
|
|
55
|
+
const id = "sdp-" + (++n);
|
|
56
|
+
const me = { id, local: null, remote: null, pendingDcs: [], linked: false, pc: null };
|
|
57
|
+
const api = {
|
|
58
|
+
createDataChannel() {
|
|
59
|
+
// the local end exists BEFORE the connection: methods delegate after attach
|
|
60
|
+
let real = null;
|
|
61
|
+
const shell = { onopen: null, onmessage: null, onclose: null, onerror: null };
|
|
62
|
+
shell.send = (data) => real?.send(data);
|
|
63
|
+
shell.close = () => real?.close();
|
|
64
|
+
me.pendingDcs.push({
|
|
65
|
+
attach(a) {
|
|
66
|
+
real = a;
|
|
67
|
+
if (!a)
|
|
68
|
+
return;
|
|
69
|
+
a.onmessage = (ev) => shell.onmessage?.(ev);
|
|
70
|
+
a.onclose = () => shell.onclose?.();
|
|
71
|
+
},
|
|
72
|
+
fireOpen() { shell.onopen?.(); },
|
|
73
|
+
});
|
|
74
|
+
return shell;
|
|
75
|
+
},
|
|
76
|
+
createOffer: async () => ({ type: "offer", sdp: id }),
|
|
77
|
+
createAnswer: async () => ({ type: "answer", sdp: id }),
|
|
78
|
+
setLocalDescription(d) {
|
|
79
|
+
me.local = d;
|
|
80
|
+
setTimeout(function trickleIce() { api.onicecandidate?.({ candidate: { via: id } }); }, 0);
|
|
81
|
+
},
|
|
82
|
+
setRemoteDescription(d) {
|
|
83
|
+
me.remote = d;
|
|
84
|
+
tryConnect(me);
|
|
85
|
+
},
|
|
86
|
+
addIceCandidate() { stats.ice++; },
|
|
87
|
+
close() {
|
|
88
|
+
for (const dc of me.pendingDcs)
|
|
89
|
+
dc.attach(null);
|
|
90
|
+
me.pendingDcs = [];
|
|
91
|
+
},
|
|
92
|
+
onicecandidate: null,
|
|
93
|
+
ondatachannel: null,
|
|
94
|
+
};
|
|
95
|
+
me.pc = api;
|
|
96
|
+
pcs.set(id, me);
|
|
97
|
+
return api;
|
|
98
|
+
}
|
|
99
|
+
/** The "kill direct transport" drill: closing a live end fires onclose on both
|
|
100
|
+
* sides, which the webrtc connector reports as a transport failure. */
|
|
101
|
+
function killLiveChannels() {
|
|
102
|
+
const doomed = [...liveChannels];
|
|
103
|
+
for (const dc of doomed)
|
|
104
|
+
dc.close();
|
|
105
|
+
return doomed.length;
|
|
106
|
+
}
|
|
107
|
+
return { pc, stats, killLiveChannels };
|
|
108
|
+
}
|