wenay-react2 1.0.48 → 1.0.50

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.
Files changed (58) hide show
  1. package/README.md +14 -14
  2. package/doc/EXAMPLE_USAGE.md +48 -0
  3. package/doc/PROJECT_FUNCTIONALITY.md +9 -0
  4. package/doc/PROJECT_RULES.md +27 -27
  5. package/doc/WENAY_REACT2_RENAMES.md +170 -170
  6. package/doc/changes/v1.0.41.md +35 -35
  7. package/doc/changes/v1.0.42.md +26 -26
  8. package/doc/changes/v1.0.43.md +10 -10
  9. package/doc/changes/v1.0.44.md +14 -14
  10. package/doc/changes/v1.0.45.md +8 -8
  11. package/doc/changes/v1.0.46.md +11 -11
  12. package/doc/changes/v1.0.47.md +9 -9
  13. package/doc/changes/v1.0.48.md +8 -8
  14. package/doc/changes/v1.0.49.md +7 -0
  15. package/doc/changes/v1.0.50.md +7 -0
  16. package/doc/examples/README.md +1 -0
  17. package/doc/examples/conference-client.html +34 -0
  18. package/doc/examples/conference-client.ts +212 -0
  19. package/doc/examples/conference-server.mjs +150 -0
  20. package/doc/examples/peer-call-media.tsx +7 -7
  21. package/doc/examples/stand.tsx +5 -5
  22. package/doc/native.md +37 -37
  23. package/doc/progress/README.md +13 -13
  24. package/doc/progress/architecture-fix-queue.md +74 -74
  25. package/doc/progress/column-state-present-gate.md +28 -28
  26. package/doc/progress/common2-adoption-1.0.73.md +28 -28
  27. package/doc/progress/common2-adoption-1.0.74.md +24 -24
  28. package/doc/progress/hook-controller-opportunities.md +363 -363
  29. package/doc/progress/hook-extraction-audit.md +195 -195
  30. package/doc/progress/public-surface-normalization.md +351 -351
  31. package/doc/progress/qa-stand-walkthrough-2026-07-12.md +62 -62
  32. package/doc/progress/stand-as-examples-audit.md +20 -20
  33. package/doc/progress/style-system-normalization.md +121 -121
  34. package/doc/target/README.md +32 -32
  35. package/doc/target/my.md +11 -1
  36. package/doc/wenay-react2-rare.md +3 -0
  37. package/doc/wenay-react2.md +3 -1
  38. package/lib/common/demo/fakeRtcLoopback.d.ts +17 -0
  39. package/lib/common/demo/fakeRtcLoopback.js +108 -0
  40. package/lib/common/demo/peerConference.d.ts +449 -0
  41. package/lib/common/demo/peerConference.js +372 -0
  42. package/lib/common/src/hooks/index.d.ts +1 -0
  43. package/lib/common/src/hooks/index.js +1 -0
  44. package/lib/common/src/hooks/useReplay.js +6 -2
  45. package/lib/common/src/hooks/useRoute.d.ts +28 -0
  46. package/lib/common/src/hooks/useRoute.js +30 -0
  47. package/lib/common/testUseReact/qa.js +3 -2
  48. package/lib/common/testUseReact/replayVideo.d.ts +5 -0
  49. package/lib/common/testUseReact/replayVideo.js +98 -1
  50. package/lib/style/menuRight.css +19 -19
  51. package/lib/style/style.css +23 -23
  52. package/lib/style/tokens.css +184 -184
  53. package/package.json +54 -53
  54. package/doc/changes/v1.0.35.md +0 -26
  55. package/doc/changes/v1.0.37.md +0 -13
  56. package/doc/changes/v1.0.38.md +0 -48
  57. package/doc/changes/v1.0.39.md +0 -35
  58. package/doc/changes/v1.0.40.md +0 -15
@@ -0,0 +1,372 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Shipped conference demonstration: a 3-way host-star room where media travels over
4
+ * BOTH technologies at once — the shared media relay (Peer.createMediaRelay, the grid)
5
+ * and a policy-routed WebRTC direct line (Replay.createRouteCoordinator, the focus tile).
6
+ * The QA board imports this exact component; consumers can import it from
7
+ * `wenay-react2/demo/peer-conference`.
8
+ *
9
+ * Load-bearing invariant: the focus link's relay hop and its WebRTC direct route both
10
+ * serve the SAME owner-sequenced routed line (replayListen per seat). Route hand-offs
11
+ * resume from `lastDelivered` and silently drop lower seq, so serving two different
12
+ * journals (e.g. a relay.watchOf line — per-watcher re-sequenced) would freeze frames
13
+ * after a switch. Never "simplify" the relay hop to watchOf.
14
+ *
15
+ * Frames are JSON (SVG data-URL snapshots): the replay channel wire is text and the
16
+ * webrtc connector declares binary:false. Real-camera binary frames through
17
+ * Media.attachVideoCanvas stay covered by QA cards 43-45.
18
+ */
19
+ import { useEffect, useMemo, useRef, useState } from "react";
20
+ import { listen as createListen, Peer, Replay } from "wenay-common2";
21
+ import { usePeerCalls } from "../src/hooks/usePeerCall";
22
+ import { useRouteState } from "../src/hooks/useRoute";
23
+ import { createFakeRtcNet } from "./fakeRtcLoopback";
24
+ const seatHue = (account) => {
25
+ let hash = 0;
26
+ for (const ch of account)
27
+ hash = (hash * 31 + ch.charCodeAt(0)) % 360;
28
+ return hash;
29
+ };
30
+ const defaultFrame = (account, n) => {
31
+ const hue = seatHue(account);
32
+ const cx = 16 + (n * 9) % 128;
33
+ const cy = 24 + (n * 5) % 44;
34
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="90">` +
35
+ `<rect width="160" height="90" fill="hsl(${hue},65%,42%)"/>` +
36
+ `<circle cx="${cx}" cy="${cy}" r="7" fill="#fff" opacity="0.9"/>` +
37
+ `<text x="8" y="82" font-size="12" font-family="monospace" fill="#fff">${account} #${n}</text></svg>`;
38
+ return { n, at: Date.now(), image: "data:image/svg+xml," + encodeURIComponent(svg) };
39
+ };
40
+ function makeChannelPair() {
41
+ let closed = false;
42
+ function side(other) {
43
+ let onMsg = null;
44
+ let onCls = null;
45
+ const me = {
46
+ send: (data) => { if (!closed)
47
+ setTimeout(function deliverHop() { if (!closed)
48
+ other().deliver(data); }, 0); },
49
+ onMessage: (cb) => { onMsg = cb; },
50
+ onClose: (cb) => { onCls = cb; },
51
+ close: () => {
52
+ if (closed)
53
+ return;
54
+ closed = true;
55
+ setTimeout(function closeHop() { me.fireClose(); other().fireClose(); }, 0);
56
+ },
57
+ deliver: (data) => onMsg?.(data),
58
+ fireClose: () => onCls?.(),
59
+ };
60
+ return me;
61
+ }
62
+ let a, b;
63
+ a = side(() => b);
64
+ b = side(() => a);
65
+ return [a, b];
66
+ }
67
+ /** Hand-built relay-route connector: a fresh serveReplayChannel hop over an in-proc text
68
+ * channel per open(). Fresh-per-open matters: the coordinator closes the relay connector
69
+ * on a successful promote and calls connect(ref, "relay") anew on every demote. */
70
+ function makeRelayHopConnector(line) {
71
+ let state = "idle";
72
+ let stopServe = null;
73
+ let serverEnd = null;
74
+ return {
75
+ info: { label: "relay", kind: "relay", ordered: true, reliable: true },
76
+ open() {
77
+ state = "opening";
78
+ const [server, client] = makeChannelPair();
79
+ serverEnd = server;
80
+ stopServe = Replay.serveReplayChannel(Replay.exposeReplay(line), server);
81
+ state = "open";
82
+ return Replay.channelReplayRemote(client);
83
+ },
84
+ close() {
85
+ stopServe?.();
86
+ stopServe = null;
87
+ serverEnd?.close();
88
+ serverEnd = null;
89
+ state = "closed";
90
+ },
91
+ state: () => state,
92
+ };
93
+ }
94
+ /* ---------- the headless conference world ---------- */
95
+ export function createConferenceWorld(options = {}) {
96
+ const accounts = options.accounts ?? ["conf-a", "conf-b", "conf-c"];
97
+ const hostAccount = accounts[0];
98
+ const room = options.room ?? "qa-conference";
99
+ const frameOf = options.frame ?? defaultFrame;
100
+ // client policy refs (the coordinator hooks read them live) + the server-side gate
101
+ const policy = { forceRelay: false, allowEndpoint: true };
102
+ const host = Peer.createPeerHost({
103
+ authorize: env => env.type === "offer" ? policy.allowEndpoint : true,
104
+ });
105
+ // exactly ONE connection per account: the signal hub delivers to the LAST registered
106
+ // port, and the call manager, webrtc connector and acceptor deliberately share it
107
+ const connections = new Map(accounts.map(account => [account, host.connection(account)]));
108
+ const managers = new Map(accounts.map(account => [account,
109
+ Peer.createCallManager({ port: Peer.callPortOf(connections.get(account).fragment), self: account })]));
110
+ // roster = host + every member whose host-star call is active; it is the single
111
+ // authority for BOTH media planes (relay ACL and direct accept/canDirect)
112
+ const roster = new Set([hostAccount]);
113
+ const [emitChanged, changed] = createListen();
114
+ const hostCalls = new Map();
115
+ function ring(member) {
116
+ const existing = hostCalls.get(member);
117
+ if (existing && existing.state() !== "ended")
118
+ return existing;
119
+ const handle = managers.get(hostAccount).call(member, { room });
120
+ hostCalls.set(member, handle);
121
+ handle.changed.on(function onStarCallState(state) {
122
+ if (state === "active")
123
+ roster.add(member);
124
+ emitChanged();
125
+ });
126
+ void handle.ended.then(function onStarCallEnd() {
127
+ roster.delete(member);
128
+ if (hostCalls.get(member) === handle)
129
+ hostCalls.delete(member);
130
+ emitChanged();
131
+ });
132
+ emitChanged();
133
+ return handle;
134
+ }
135
+ const relay = Peer.createMediaRelay({
136
+ lines: { cam: "video" },
137
+ videoHistory: 8,
138
+ canWatch: (watcher, owner) => watcher !== owner && roster.has(watcher) && roster.has(owner),
139
+ });
140
+ // one owner-sequenced routed line per seat: the seq authority BOTH routes serve
141
+ const routed = new Map(accounts.map(account => {
142
+ const [emit, line] = Replay.replayListen({ history: 8, current: "last" });
143
+ return [account, { emit, line }];
144
+ }));
145
+ const frameNumbers = new Map(accounts.map(account => [account, 0]));
146
+ const publishers = new Map(accounts.map(account => [account, relay.publishOf(account)]));
147
+ /** Seats stream only while in the room (the host always is): leaving freezes every
148
+ * consumer of that seat on any route, joining resumes them. */
149
+ function tick(only) {
150
+ for (const account of accounts) {
151
+ if (only && account !== only)
152
+ continue;
153
+ if (!roster.has(account))
154
+ continue;
155
+ const n = (frameNumbers.get(account) ?? 0) + 1;
156
+ frameNumbers.set(account, n);
157
+ const frame = frameOf(account, n);
158
+ publishers.get(account)("cam", frame, frame.at);
159
+ routed.get(account).emit(frame, frame.at);
160
+ }
161
+ }
162
+ const fps = options.fps ?? 8;
163
+ const painter = fps > 0 ? setInterval(function paintConference() { tick(); }, Math.max(16, Math.round(1000 / fps))) : null;
164
+ // WebRTC runtime: injected factory or the browser's real RTCPeerConnection
165
+ const livePcs = new Set();
166
+ const baseRtc = options.rtc ?? (() => new globalThis.RTCPeerConnection());
167
+ const rtc = () => {
168
+ const pc = baseRtc();
169
+ livePcs.add(pc);
170
+ return pc;
171
+ };
172
+ // every seat serves ITS OWN routed line to direct viewers; membership gates accept
173
+ const acceptorStops = accounts.map(owner => Replay.acceptWebRtcDirect({
174
+ port: connections.get(owner).fragment.signal,
175
+ rtc,
176
+ self: owner,
177
+ serve: env => env.pair === "media:" + owner + ">" + env.from
178
+ ? Replay.exposeReplay(routed.get(owner).line)
179
+ : null,
180
+ accept: env => roster.has(env.from) && roster.has(owner),
181
+ }));
182
+ // focus coordinator for the first seat: directional signaling pair "media:OWNER>VIEWER"
183
+ // (the coordinator's own ref.key is symmetric — never reuse it for signaling)
184
+ const viewer = hostAccount;
185
+ const ownerOf = (ref) => ref.a === viewer ? ref.b : ref.a;
186
+ const coordinator = Replay.createRouteCoordinator({
187
+ policy: {
188
+ mustRelay: () => policy.forceRelay,
189
+ canDirect: ctx => roster.has(ownerOf(ctx)),
190
+ },
191
+ connect: (ref, kind) => {
192
+ const owner = ownerOf(ref);
193
+ if (kind === "relay")
194
+ return makeRelayHopConnector(routed.get(owner).line);
195
+ return Replay.createWebRtcConnector({
196
+ port: connections.get(viewer).fragment.signal,
197
+ rtc,
198
+ self: viewer,
199
+ peer: owner,
200
+ pair: "media:" + owner + ">" + viewer,
201
+ openTimeoutMs: 8000,
202
+ });
203
+ },
204
+ });
205
+ const focusLinks = new Map();
206
+ function focus(owner) {
207
+ let link = focusLinks.get(owner);
208
+ if (!link) {
209
+ link = coordinator.pair(viewer, owner);
210
+ focusLinks.set(owner, link);
211
+ }
212
+ return link;
213
+ }
214
+ /** Server-side revoke of the viewer's live direct session; the connector fails and
215
+ * the coordinator auto-falls back to the relay hop without losing frames. */
216
+ function revokeDirect(owner) {
217
+ host.revoke("media:" + owner + ">" + viewer, [viewer], "server revoke");
218
+ }
219
+ /** Transport-death drill: the direct channel dies mid-flight (not a policy event). */
220
+ function killDirect() {
221
+ if (options.killTransport) {
222
+ options.killTransport();
223
+ return;
224
+ }
225
+ for (const pc of livePcs)
226
+ pc.close();
227
+ livePcs.clear();
228
+ }
229
+ let closed = false;
230
+ function close() {
231
+ if (closed)
232
+ return;
233
+ closed = true;
234
+ if (painter)
235
+ clearInterval(painter);
236
+ coordinator.close();
237
+ for (const stop of acceptorStops)
238
+ stop();
239
+ for (const handle of hostCalls.values())
240
+ if (handle.state() !== "ended")
241
+ handle.hangup();
242
+ for (const manager of managers.values())
243
+ manager.close();
244
+ relay.close();
245
+ for (const entry of routed.values())
246
+ entry.line.close();
247
+ for (const connection of connections.values())
248
+ connection.close();
249
+ host.close();
250
+ }
251
+ return {
252
+ accounts,
253
+ hostAccount,
254
+ viewer,
255
+ room,
256
+ managers,
257
+ relay,
258
+ coordinator,
259
+ focus,
260
+ ring,
261
+ hostCalls,
262
+ roster: () => [...roster].sort(),
263
+ inRoster: (account) => roster.has(account),
264
+ changed,
265
+ lineOf: (account) => routed.get(account).line,
266
+ frameNumber: (account) => frameNumbers.get(account) ?? 0,
267
+ tick,
268
+ setForceRelay(on) { policy.forceRelay = on; emitChanged(); },
269
+ setAllowEndpoint(on) { policy.allowEndpoint = on; emitChanged(); },
270
+ getPolicy: () => ({ ...policy }),
271
+ revokeDirect,
272
+ killDirect,
273
+ close,
274
+ };
275
+ }
276
+ /* ---------- React demo: 3 seats, relay grid, direct focus ---------- */
277
+ const tileStyle = { width: 160, height: 90, background: "#111", borderRadius: 6, objectFit: "cover" };
278
+ const panelStyle = { border: "1px solid #d0d7de", borderRadius: 8, padding: 8, display: "grid", gap: 6, alignContent: "start" };
279
+ const SeatPanel = (p) => {
280
+ const manager = p.world.managers.get(p.account);
281
+ const calls = usePeerCalls(manager);
282
+ const isHost = p.account === p.world.hostAccount;
283
+ const members = p.world.accounts.filter(account => account !== p.world.hostAccount);
284
+ const incoming = calls.rings.find(call => call.direction === "in");
285
+ const live = calls.calls.filter(call => call.state() === "active");
286
+ return _jsxs("div", { style: panelStyle, children: [_jsxs("b", { children: [p.account, isHost ? " (host)" : "", " ", p.world.inRoster(p.account) ? "· in room" : ""] }), isHost && members.map(member => {
287
+ const handle = p.world.hostCalls.get(member);
288
+ const state = handle?.state();
289
+ return _jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [(!handle || state === "ended") && _jsxs("button", { onClick: () => p.world.ring(member), children: ["ring ", member] }), state === "ringing" && _jsxs("span", { children: ["ringing ", member, "\u2026"] }), state === "active" && _jsxs("button", { onClick: () => handle.hangup(), children: ["hang up ", member] })] }, member);
290
+ }), !isHost && incoming && _jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { onClick: () => incoming.accept(), children: "accept" }), _jsx("button", { onClick: () => incoming.decline(), children: "decline" })] }), !isHost && live.map(call => _jsxs("button", { onClick: () => call.hangup(), children: ["hang up (", call.peer, ")"] }, call.id)), _jsxs("span", { style: { fontSize: 11, color: "#57606a" }, children: ["calls: ", calls.calls.map(call => `${call.peer}:${call.state()}`).join(" ") || "none"] })] });
291
+ };
292
+ const GridTile = (p) => {
293
+ const imgRef = useRef(null);
294
+ const counter = useRef({ n: 0, at: 0 });
295
+ const [snapshot, setSnapshot] = useState({ n: 0, ageMs: 0 });
296
+ useEffect(() => {
297
+ // watchOf hides owners the ACL denies (the property reads as undefined), so the
298
+ // tile attaches lazily and retries whenever room membership changes
299
+ let off = null;
300
+ const tryAttach = () => {
301
+ if (off)
302
+ return;
303
+ const watch = p.world.relay.watchOf(p.viewer);
304
+ const line = watch[p.owner];
305
+ if (!line)
306
+ return;
307
+ off = line.cam.on((frame) => {
308
+ counter.current = { n: frame.n, at: frame.at };
309
+ if (imgRef.current)
310
+ imgRef.current.src = frame.image;
311
+ });
312
+ };
313
+ tryAttach();
314
+ const offChanged = p.world.changed.on(tryAttach);
315
+ const timer = window.setInterval(() => setSnapshot({
316
+ n: counter.current.n,
317
+ ageMs: counter.current.at ? Date.now() - counter.current.at : 0,
318
+ }), 500);
319
+ return () => { off?.(); offChanged(); window.clearInterval(timer); };
320
+ }, [p.world, p.viewer, p.owner]);
321
+ return _jsxs("figure", { style: { margin: 0, display: "grid", gap: 2, justifyItems: "start" }, children: [_jsx("img", { ref: imgRef, alt: `${p.viewer} watches ${p.owner}`, style: tileStyle }), _jsxs("figcaption", { style: { fontSize: 11, color: "#57606a" }, children: [p.viewer, " \u2190 ", p.owner, " \u00B7 #", snapshot.n, " \u00B7 ", Math.round(snapshot.ageMs / 100) / 10, "s"] })] });
322
+ };
323
+ const FocusPanel = (p) => {
324
+ const members = p.world.accounts.filter(account => account !== p.world.viewer);
325
+ const [owner, setOwner] = useState(members[0]);
326
+ const link = useMemo(() => p.world.focus(owner), [p.world, owner]);
327
+ const route = useRouteState(p.world.coordinator, link);
328
+ const imgRef = useRef(null);
329
+ const counter = useRef({ n: 0, seq: 0 });
330
+ const [snapshot, setSnapshot] = useState({ n: 0, seq: 0 });
331
+ const [lastOp, setLastOp] = useState("");
332
+ const [forceRelay, setForceRelay] = useState(false);
333
+ const [allowEndpoint, setAllowEndpoint] = useState(true);
334
+ useEffect(() => {
335
+ let off = null;
336
+ let seq = () => 0;
337
+ try {
338
+ const sub = link.subscribe(frame => {
339
+ counter.current = { n: frame.n, seq: seq() };
340
+ if (imgRef.current)
341
+ imgRef.current.src = frame.image;
342
+ });
343
+ seq = sub.seq;
344
+ off = sub;
345
+ }
346
+ catch (error) {
347
+ setLastOp("subscribe failed: " + String(error));
348
+ }
349
+ const timer = window.setInterval(() => setSnapshot({ ...counter.current }), 500);
350
+ return () => { off?.(); window.clearInterval(timer); };
351
+ }, [link]);
352
+ const run = (label, op) => void op
353
+ .then(result => setLastOp(`${label}: ${result.ok ? "ok" : "denied"} → ${result.state}${result.reason ? ` (${String(result.reason)})` : ""}`))
354
+ .catch(error => setLastOp(`${label}: ${String(error)}`));
355
+ const chipColor = route.state === "direct" ? "#1a7f37" : route.state.startsWith("direct") ? "#9a6700" : route.state === "fallback" ? "#cf222e" : "#0969da";
356
+ return _jsxs("div", { style: { ...panelStyle, minWidth: 320 }, children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }, children: [_jsxs("b", { children: ["focus: ", p.world.viewer, " \u2190"] }), _jsx("select", { value: owner, onChange: event => setOwner(event.target.value), children: members.map(member => _jsx("option", { value: member, children: member }, member)) }), _jsx("span", { style: { padding: "2px 8px", borderRadius: 10, fontSize: 12, color: "#fff", background: chipColor }, children: route.state })] }), _jsx("img", { ref: imgRef, alt: `focus on ${owner}`, style: { ...tileStyle, width: 240, height: 135 } }), _jsxs("span", { style: { fontSize: 11, color: "#57606a" }, children: ["frame #", snapshot.n, " \u00B7 seq ", snapshot.seq, route.metrics?.relay ? ` · relay:${route.metrics.relay.state}` : "", route.metrics?.direct ? ` · direct:${route.metrics.direct.state}` : ""] }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [_jsx("button", { onClick: () => run("promote", link.promoteDirect({ timeoutMs: 8000 })), children: "go direct" }), _jsx("button", { onClick: () => run("relay", link.reinterposeRelay("manual")), children: "back to relay" }), _jsx("button", { onClick: () => p.world.revokeDirect(owner), children: "server revoke" }), _jsx("button", { onClick: () => p.world.killDirect(), children: "kill direct transport" })] }), _jsxs("label", { style: { fontSize: 12 }, children: [_jsx("input", { type: "checkbox", checked: forceRelay, onChange: event => { setForceRelay(event.target.checked); p.world.setForceRelay(event.target.checked); } }), " policy: force relay"] }), _jsxs("label", { style: { fontSize: 12 }, children: [_jsx("input", { type: "checkbox", checked: !allowEndpoint, onChange: event => { setAllowEndpoint(!event.target.checked); p.world.setAllowEndpoint(!event.target.checked); } }), " server: refuse endpoint exposure"] }), lastOp && _jsx("span", { style: { fontSize: 12 }, children: lastOp }), route.log.length > 0 && _jsx("span", { style: { fontSize: 11, color: "#57606a" }, children: route.log.map(entry => `${entry.from}→${entry.to}${entry.reason ? ` (${String(entry.reason)})` : ""}`).join(" | ") })] });
357
+ };
358
+ export const ConferenceCallDemo = () => {
359
+ const hasRealRtc = typeof globalThis.RTCPeerConnection === "function";
360
+ const [simulateRtc, setSimulateRtc] = useState(!hasRealRtc);
361
+ const setup = useMemo(() => {
362
+ const net = simulateRtc ? createFakeRtcNet() : null;
363
+ const world = createConferenceWorld(net ? { rtc: net.pc, killTransport: net.killLiveChannels } : {});
364
+ return { net, world };
365
+ }, [simulateRtc]);
366
+ useEffect(() => () => setup.world.close(), [setup]);
367
+ const [, setVersion] = useState(0);
368
+ useEffect(() => setup.world.changed.on(() => setVersion(value => value + 1)), [setup]);
369
+ const world = setup.world;
370
+ const pairs = world.accounts.flatMap(viewer => world.accounts.filter(owner => owner !== viewer).map(owner => ({ viewer, owner })));
371
+ return _jsxs("div", { style: { display: "grid", gap: 10 }, children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }, children: [_jsxs("b", { children: ["room: ", world.roster().join(", ")] }), _jsxs("label", { style: { fontSize: 12 }, children: [_jsx("input", { type: "checkbox", checked: simulateRtc, onChange: event => setSimulateRtc(event.target.checked) }), " simulate RTC (loopback runtime", hasRealRtc ? "" : "; real RTCPeerConnection unavailable here", ")"] })] }), _jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(3, minmax(180px, 1fr))", gap: 8 }, children: world.accounts.map(account => _jsx(SeatPanel, { world: world, account: account }, account)) }), _jsxs("div", { style: { display: "flex", gap: 12, flexWrap: "wrap", alignItems: "start" }, children: [_jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(3, auto)", gap: 8 }, children: pairs.map(pair => _jsx(GridTile, { world: world, viewer: pair.viewer, owner: pair.owner }, pair.viewer + "<" + pair.owner)) }), _jsx(FocusPanel, { world: world })] })] });
372
+ };
@@ -8,3 +8,4 @@ export * from './useReorderBoard';
8
8
  export * from './useMedia';
9
9
  export * from './usePeer';
10
10
  export * from './usePeerCall';
11
+ export * from './useRoute';
@@ -8,3 +8,4 @@ export * from './useReorderBoard';
8
8
  export * from './useMedia';
9
9
  export * from './usePeer';
10
10
  export * from './usePeerCall';
11
+ export * from './useRoute';
@@ -53,6 +53,7 @@ export function useReplaySubscribe(remote, cb, options = {}) {
53
53
  seqRef.current = undefined; // a different line — old seq is meaningless
54
54
  lastRemoteRef.current = remote;
55
55
  let alive = true;
56
+ let failed = false;
56
57
  setReady(false);
57
58
  setError(null);
58
59
  // stale is NOT reset here: it re-syncs from common2 after the first delivery (a stale
@@ -68,8 +69,11 @@ export function useReplaySubscribe(remote, cb, options = {}) {
68
69
  hooksRef.current.onSeq?.(seq);
69
70
  },
70
71
  onError: e => {
71
- if (alive)
72
+ failed = true;
73
+ if (alive) {
72
74
  setError(e);
75
+ setReady(false);
76
+ }
73
77
  hooksRef.current.onError?.(e);
74
78
  },
75
79
  ...(staleMs !== undefined ? {
@@ -83,7 +87,7 @@ export function useReplaySubscribe(remote, cb, options = {}) {
83
87
  });
84
88
  subRef.current = off;
85
89
  off.ready.then(() => {
86
- if (!alive)
90
+ if (!alive || failed)
87
91
  return;
88
92
  setReady(true);
89
93
  if (staleMs !== undefined)
@@ -0,0 +1,28 @@
1
+ import { Replay } from "wenay-common2";
2
+ export type RouteLogEntry = {
3
+ at: number;
4
+ from: Replay.tRouteState;
5
+ to: Replay.tRouteState;
6
+ reason?: unknown;
7
+ };
8
+ /** React binding for one route-coordinator link: live route state, the last denial or
9
+ * failure reason, connector metrics on a 500ms snapshot, and a short hand-off log.
10
+ * Route ownership stays in common2 — the hook only mirrors coordinator events; callers
11
+ * own coordinator/link lifecycle (close), exactly like usePeerCalls over CallManager. */
12
+ export declare function useRouteState<Z extends any[]>(coordinator: Pick<Replay.RouteCoordinator<Z>, "onRoute"> | null, link: Pick<Replay.RouteLink<Z>, "ref" | "state" | "reason" | "metrics"> | null): {
13
+ state: Replay.tRouteState;
14
+ reason: unknown;
15
+ metrics: {
16
+ relay: {
17
+ rtt?: number;
18
+ pending?: number;
19
+ state: Replay.tConnectorState;
20
+ } | null;
21
+ direct: {
22
+ rtt?: number;
23
+ pending?: number;
24
+ state: Replay.tConnectorState;
25
+ } | null;
26
+ } | null;
27
+ log: RouteLogEntry[];
28
+ };
@@ -0,0 +1,30 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ /** React binding for one route-coordinator link: live route state, the last denial or
3
+ * failure reason, connector metrics on a 500ms snapshot, and a short hand-off log.
4
+ * Route ownership stays in common2 — the hook only mirrors coordinator events; callers
5
+ * own coordinator/link lifecycle (close), exactly like usePeerCalls over CallManager. */
6
+ export function useRouteState(coordinator, link) {
7
+ const [state, setState] = useState(() => link?.state() ?? "closed");
8
+ const [reason, setReason] = useState(() => link?.reason());
9
+ const [metrics, setMetrics] = useState(() => link?.metrics() ?? null);
10
+ const [log, setLog] = useState([]);
11
+ useEffect(() => {
12
+ if (!coordinator || !link)
13
+ return;
14
+ setState(link.state());
15
+ setReason(link.reason());
16
+ setMetrics(link.metrics());
17
+ setLog([]);
18
+ const off = coordinator.onRoute(event => {
19
+ if (event.key !== link.ref.key)
20
+ return;
21
+ setState(event.to);
22
+ setReason(event.reason);
23
+ setMetrics(link.metrics());
24
+ setLog(current => [...current.slice(-3), { at: Date.now(), from: event.from, to: event.to, reason: event.reason }]);
25
+ });
26
+ const timer = setInterval(() => setMetrics(link.metrics()), 500);
27
+ return () => { off(); clearInterval(timer); };
28
+ }, [coordinator, link]);
29
+ return useMemo(() => ({ state, reason, metrics, log }), [state, reason, metrics, log]);
30
+ }