zero-query 1.1.1 → 1.2.0

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 (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -442
  5. package/cli/commands/build.js +254 -247
  6. package/cli/commands/bundle.js +1228 -1224
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -220
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +661 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6614
  81. package/dist/zquery.min.js +8 -631
  82. package/index.d.ts +570 -371
  83. package/index.js +311 -240
  84. package/package.json +76 -70
  85. package/src/component.js +1709 -1691
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -255
  93. package/src/router.js +843 -843
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -318
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1103
  115. package/tests/compare.test.js +497 -486
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -489
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -1650
  121. package/tests/electron-features.test.js +864 -864
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -146
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -0,0 +1,295 @@
1
+ // video-room.js - Mini-Discord style room: video tiles + screen share + chat.
2
+ //
3
+ // Backed by app/lib/room.js (BroadcastChannel signaling) so multiple tabs
4
+ // on the same browser form a working mesh room with no server at all.
5
+
6
+ import { LocalRoom } from '../lib/room.js';
7
+
8
+ $.component('video-room', {
9
+ state: () => ({
10
+ // Pre-join form ----------------------------------------------------
11
+ roomName: 'lobby',
12
+ displayName: 'User-' + Math.random().toString(36).slice(2, 6),
13
+ // Live state -------------------------------------------------------
14
+ joined: false,
15
+ status: 'Pick a room + name and click Join. Open this URL in a second tab to see a peer appear.',
16
+ error: '',
17
+ // Local media ------------------------------------------------------
18
+ localStream: null,
19
+ micOn: true,
20
+ camOn: true,
21
+ sharing: false,
22
+ // Roster + chat ----------------------------------------------------
23
+ peers: [], // [{ id, name, stream }]
24
+ messages: [], // [{ from, name, text, t, mine }]
25
+ draft: '',
26
+ }),
27
+
28
+ mounted() {
29
+ this._room = null;
30
+ this._cameraTrack = null; // original camera video track (kept while screen sharing)
31
+ this._screenStream = null; // current display-media stream (cleared on stop)
32
+ },
33
+
34
+ async destroyed() {
35
+ await this._teardown();
36
+ },
37
+
38
+ // ---- Pre-join form bindings -----------------------------------------
39
+
40
+ setRoom(e) { this.setState({ roomName: e.target.value }); },
41
+ setName(e) { this.setState({ displayName: e.target.value }); },
42
+
43
+ // ---- Join / leave ----------------------------------------------------
44
+
45
+ async join() {
46
+ if (this.state.joined) return;
47
+ this.setState({ status: 'Requesting camera + microphone...', error: '' });
48
+
49
+ let stream = null;
50
+ try {
51
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
52
+ } catch (err) {
53
+ // Joining without media is still useful (viewer + chat only).
54
+ this.setState({ status: 'No camera/mic - joining as viewer.', error: '' });
55
+ }
56
+
57
+ const cam = stream && stream.getVideoTracks()[0];
58
+ if (cam) this._cameraTrack = cam;
59
+
60
+ this._room = new LocalRoom(this.state.roomName, { displayName: this.state.displayName });
61
+ this._room.on('peers', (peers) => this._onPeers(peers));
62
+ this._room.on('chat', (msg) => this._onChat(msg));
63
+ this._room.on('status', (status) => this.setState({ status }));
64
+ this._room.on('error', (err) => this.setState({ error: String(err && err.message || err) }));
65
+ this._room.join(stream);
66
+
67
+ this.setState({
68
+ joined: true,
69
+ localStream: stream,
70
+ micOn: !!(stream && stream.getAudioTracks()[0]),
71
+ camOn: !!cam,
72
+ sharing: false,
73
+ });
74
+ },
75
+
76
+ async leave() {
77
+ await this._teardown();
78
+ this.setState({
79
+ joined: false,
80
+ localStream: null,
81
+ peers: [],
82
+ messages: [],
83
+ sharing: false,
84
+ status: 'Left the room. Click Join to reconnect.',
85
+ });
86
+ },
87
+
88
+ async _teardown() {
89
+ if (this._room) { try { this._room.leave(); } catch (_) {} this._room = null; }
90
+ if (this._screenStream) {
91
+ for (const t of this._screenStream.getTracks()) { try { t.stop(); } catch (_) {} }
92
+ this._screenStream = null;
93
+ }
94
+ if (this.state.localStream) {
95
+ for (const t of this.state.localStream.getTracks()) { try { t.stop(); } catch (_) {} }
96
+ }
97
+ this._cameraTrack = null;
98
+ },
99
+
100
+ // ---- Mic / cam toggles ----------------------------------------------
101
+
102
+ toggleMic() {
103
+ const stream = this.state.localStream;
104
+ if (!stream) return;
105
+ const next = !this.state.micOn;
106
+ for (const t of stream.getAudioTracks()) t.enabled = next;
107
+ this.setState({ micOn: next });
108
+ },
109
+
110
+ toggleCam() {
111
+ const stream = this.state.localStream;
112
+ if (!stream) return;
113
+ const next = !this.state.camOn;
114
+ for (const t of stream.getVideoTracks()) t.enabled = next;
115
+ this.setState({ camOn: next });
116
+ },
117
+
118
+ // ---- Screen share ----------------------------------------------------
119
+
120
+ async toggleShare() {
121
+ if (!this._room) return;
122
+ if (this.state.sharing) {
123
+ // Stop sharing: revert every peer to the camera track.
124
+ if (this._screenStream) {
125
+ for (const t of this._screenStream.getTracks()) { try { t.stop(); } catch (_) {} }
126
+ this._screenStream = null;
127
+ }
128
+ await this._room.replaceVideoTrack(this._cameraTrack || null);
129
+ this.setState({ sharing: false, status: 'Stopped sharing screen.' });
130
+ return;
131
+ }
132
+
133
+ try {
134
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
135
+ this._screenStream = stream;
136
+ const shareTrack = stream.getVideoTracks()[0];
137
+ // When the user clicks the browser's native "Stop sharing", flip back.
138
+ shareTrack.onended = () => { if (this.state.sharing) this.toggleShare(); };
139
+ await this._room.replaceVideoTrack(shareTrack);
140
+ this.setState({ sharing: true, status: 'Sharing your screen.' });
141
+ } catch (err) {
142
+ this.setState({ error: 'Screen share denied or unavailable.' });
143
+ }
144
+ },
145
+
146
+ // ---- Roster + chat ---------------------------------------------------
147
+
148
+ _onPeers(peersMap) {
149
+ const list = Array.from(peersMap.values()).map((p) => ({
150
+ id: p.id, name: p.name, stream: p.stream,
151
+ }));
152
+ this.setState({ peers: list });
153
+ },
154
+
155
+ _onChat(msg) {
156
+ const mine = this._room && msg.from === this._room.id;
157
+ const next = this.state.messages.concat([{ ...msg, mine }]);
158
+ // Cap history so a long-running room doesn't grow forever.
159
+ if (next.length > 200) next.splice(0, next.length - 200);
160
+ this.setState({ messages: next });
161
+ },
162
+
163
+ setDraft(e) { this.setState({ draft: e.target.value }); },
164
+
165
+ sendChat(e) {
166
+ if (e && e.preventDefault) e.preventDefault();
167
+ const text = (this.state.draft || '').trim();
168
+ if (!text || !this._room) return;
169
+ this._room.sendChat(text);
170
+ this.setState({ draft: '' });
171
+ },
172
+
173
+ // ---- Render ----------------------------------------------------------
174
+
175
+ render() {
176
+ if (!this.state.joined) return this._renderLobby();
177
+ return this._renderRoom();
178
+ },
179
+
180
+ _renderLobby() {
181
+ const { roomName, displayName, status, error } = this.state;
182
+ return `
183
+ <div class="lobby">
184
+ <h1>zQuery WebRTC Demo</h1>
185
+ <p class="lead">
186
+ A mini, no-backend room. Signaling runs over
187
+ <code>BroadcastChannel</code>, so opening this page in
188
+ multiple tabs / windows gives you a working mesh call
189
+ with audio, video, screen share, and chat &mdash; no
190
+ server required.
191
+ </p>
192
+ <form class="join-form" @submit="join">
193
+ <label>
194
+ Room
195
+ <input type="text" value="${$.escapeHtml(roomName)}" @input="setRoom" placeholder="lobby" />
196
+ </label>
197
+ <label>
198
+ Your name
199
+ <input type="text" value="${$.escapeHtml(displayName)}" @input="setName" placeholder="display name" />
200
+ </label>
201
+ <button type="button" class="primary" @click="join">Join room</button>
202
+ </form>
203
+ <p class="status ${error ? 'error' : ''}">
204
+ ${error ? $.escapeHtml(error) : $.escapeHtml(status)}
205
+ </p>
206
+ </div>
207
+ `;
208
+ },
209
+
210
+ _renderRoom() {
211
+ const { localStream, peers, status, error, micOn, camOn, sharing, messages, draft, displayName, roomName } = this.state;
212
+
213
+ const peerCount = peers.length + 1; // include self
214
+ const peerTiles = peers.map((p, i) => `
215
+ <div class="tile">
216
+ <video z-stream="peers[${i}].stream" autoplay playsinline></video>
217
+ <div class="label">${$.escapeHtml(p.name || p.id)}</div>
218
+ </div>
219
+ `).join('');
220
+
221
+ const chatLines = messages.map((m) => `
222
+ <div class="msg ${m.mine ? 'mine' : ''}">
223
+ <span class="who">${$.escapeHtml(m.name)}</span>
224
+ <span class="text">${$.escapeHtml(m.text)}</span>
225
+ </div>
226
+ `).join('');
227
+
228
+ return `
229
+ <div class="room">
230
+ <aside class="sidebar">
231
+ <div class="room-meta">
232
+ <div class="room-name">#${$.escapeHtml(roomName)}</div>
233
+ <div class="room-sub">${peerCount} ${peerCount === 1 ? 'person' : 'people'}</div>
234
+ </div>
235
+ <div class="roster">
236
+ <div class="roster-row me">
237
+ <span class="dot ${micOn ? 'on' : 'off'}"></span>
238
+ ${$.escapeHtml(displayName)} <small>(you)</small>
239
+ </div>
240
+ ${peers.map((p) => `
241
+ <div class="roster-row">
242
+ <span class="dot on"></span>
243
+ ${$.escapeHtml(p.name || p.id)}
244
+ </div>
245
+ `).join('')}
246
+ </div>
247
+ <button class="leave" @click="leave">Leave room</button>
248
+ </aside>
249
+
250
+ <section class="stage">
251
+ <div class="tiles">
252
+ <div class="tile self">
253
+ <video z-stream="localStream" autoplay playsinline muted></video>
254
+ <div class="label">You${sharing ? ' &middot; sharing' : ''}</div>
255
+ ${!camOn && !sharing ? '<div class="camoff">Camera off</div>' : ''}
256
+ </div>
257
+ ${peerTiles}
258
+ </div>
259
+
260
+ <div class="controls">
261
+ <button class="${micOn ? '' : 'off'}" @click="toggleMic">
262
+ ${micOn ? '🎤 Mute' : '🔇 Unmute'}
263
+ </button>
264
+ <button class="${camOn ? '' : 'off'}" @click="toggleCam">
265
+ ${camOn ? '📷 Stop video' : '🚫 Start video'}
266
+ </button>
267
+ <button class="${sharing ? 'active' : ''}" @click="toggleShare">
268
+ ${sharing ? '🛑 Stop share' : '🖥️ Share screen'}
269
+ </button>
270
+ <div class="status-inline ${error ? 'error' : ''}">
271
+ ${error ? $.escapeHtml(error) : $.escapeHtml(status)}
272
+ </div>
273
+ </div>
274
+ </section>
275
+
276
+ <aside class="chat">
277
+ <div class="chat-header">Chat</div>
278
+ <div class="chat-log" id="chat-log">
279
+ ${chatLines || '<div class="empty">No messages yet. Say hi 👋</div>'}
280
+ </div>
281
+ <form class="chat-form" @submit="sendChat">
282
+ <input type="text" value="${$.escapeHtml(draft)}" @input="setDraft" placeholder="Message #${$.escapeHtml(roomName)}" />
283
+ <button type="button" class="primary" @click="sendChat">Send</button>
284
+ </form>
285
+ </aside>
286
+ </div>
287
+ `;
288
+ },
289
+
290
+ updated() {
291
+ // Auto-scroll chat to the latest message after each render.
292
+ const log = this.$el && this.$el.querySelector ? this.$el.querySelector('#chat-log') : null;
293
+ if (log) log.scrollTop = log.scrollHeight;
294
+ },
295
+ });
@@ -0,0 +1,252 @@
1
+ // lib/room.js - Backend-less WebRTC room built on BroadcastChannel.
2
+ //
3
+ // Why no backend?
4
+ // WebRTC needs a signaling channel to swap SDP + ICE between peers. We use
5
+ // BroadcastChannel, which lets every same-origin tab on the same browser
6
+ // talk to every other tab for free. Open the demo in 2+ tabs (or windows)
7
+ // and you have a working mesh room with audio, video, screen share, and
8
+ // text chat - zero servers required.
9
+ //
10
+ // Caveats:
11
+ // - BroadcastChannel is same-origin / same-browser only. To connect across
12
+ // machines, plug in any real signaling transport (WebSocket, SSE, etc.)
13
+ // in place of BroadcastChannel and the rest of this file keeps working.
14
+ // - Mesh topology: every peer has an RTCPeerConnection with every other
15
+ // peer. Works great up to ~6 peers; beyond that, use an SFU.
16
+
17
+ const SIGNAL_VERSION = 1;
18
+
19
+ /**
20
+ * Wrap a BroadcastChannel as a "SignalingClient" that $.Peer can consume.
21
+ * The Peer class expects `.send(type, payload)` and `.on(type, cb)`.
22
+ */
23
+ function makeSignaling(channel, myId) {
24
+ return {
25
+ send(type, payload) {
26
+ channel.postMessage({ v: SIGNAL_VERSION, type, from: myId, ...payload });
27
+ },
28
+ on(type, cb) {
29
+ const handler = (ev) => {
30
+ const msg = ev.data;
31
+ if (!msg || msg.v !== SIGNAL_VERSION) return;
32
+ if (msg.type !== type) return;
33
+ if (msg.from === myId) return; // ignore self
34
+ if (msg.to !== undefined && msg.to !== myId) return; // not addressed to us
35
+ cb(msg);
36
+ };
37
+ channel.addEventListener('message', handler);
38
+ return () => channel.removeEventListener('message', handler);
39
+ },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Backend-less mesh room.
45
+ *
46
+ * Events (subscribe via `.on(type, cb)`):
47
+ * - 'peers' payload: Map<id, PeerInfo> - roster changed
48
+ * - 'chat' payload: { from, name, text, t }
49
+ * - 'status' payload: string - human-readable status line
50
+ * - 'error' payload: Error
51
+ *
52
+ * PeerInfo: { id, name, stream: MediaStream|null, peer: $.Peer, chat: RTCDataChannel|null }
53
+ */
54
+ export class LocalRoom {
55
+ constructor(name, { id, displayName, iceServers = [] } = {}) {
56
+ this.name = name;
57
+ this.id = id || ('p-' + Math.random().toString(36).slice(2, 10));
58
+ this.displayName = displayName || ('User-' + this.id.slice(-4));
59
+ this.iceServers = iceServers;
60
+
61
+ this._channel = null;
62
+ this._signaling = null;
63
+ this._peers = new Map(); // id -> PeerInfo
64
+ this._localStream = null;
65
+ this._videoSenders = new Map(); // peerId -> RTCRtpSender (current video sender)
66
+ this._listeners = new Map();
67
+ this._heartbeat = null;
68
+ this._unsubHello = null;
69
+ this._unsubBye = null;
70
+ this.closed = false;
71
+ }
72
+
73
+ // ---- Pub/sub ---------------------------------------------------------
74
+
75
+ on(type, cb) {
76
+ if (typeof cb !== 'function') return () => {};
77
+ let set = this._listeners.get(type);
78
+ if (!set) { set = new Set(); this._listeners.set(type, set); }
79
+ set.add(cb);
80
+ return () => set.delete(cb);
81
+ }
82
+
83
+ _emit(type, payload) {
84
+ const set = this._listeners.get(type);
85
+ if (!set) return;
86
+ for (const cb of [...set]) { try { cb(payload); } catch (_) {} }
87
+ }
88
+
89
+ get peers() { return this._peers; }
90
+
91
+ // ---- Lifecycle -------------------------------------------------------
92
+
93
+ /**
94
+ * Open the BroadcastChannel, announce presence, and start accepting peers.
95
+ * `localStream` is optional - join as a viewer if you have no camera/mic.
96
+ */
97
+ join(localStream = null) {
98
+ if (this.closed) throw new Error('LocalRoom already closed');
99
+ this._localStream = localStream;
100
+
101
+ this._channel = new BroadcastChannel('zquery-room::' + this.name);
102
+ this._signaling = makeSignaling(this._channel, this.id);
103
+
104
+ // Listen for newcomers' hellos and respond with our own hello so the
105
+ // newcomer learns about us too. Net effect: every pair exchanges
106
+ // hellos exactly once and both sides bring up a peer connection.
107
+ this._unsubHello = this._signaling.on('hello', (m) => {
108
+ // Ignore if we already track this peer.
109
+ if (this._peers.has(m.from)) return;
110
+ this._addPeer(m.from, m.name || 'User');
111
+ // Reply directly so the newcomer adds us.
112
+ this._signaling.send('hello', { to: m.from, name: this.displayName });
113
+ });
114
+
115
+ this._unsubBye = this._signaling.on('bye', (m) => {
116
+ this._removePeer(m.from);
117
+ });
118
+
119
+ // Broadcast hello to the whole room (no `to` field = everyone).
120
+ this._signaling.send('hello', { name: this.displayName });
121
+ this._emit('status', 'Looking for peers in room "' + this.name + '"...');
122
+
123
+ // Periodic hello so late-comers refresh the roster even if they miss
124
+ // the first broadcast (e.g. tab woken from background).
125
+ this._heartbeat = setInterval(() => {
126
+ if (this.closed) return;
127
+ this._signaling.send('hello', { name: this.displayName });
128
+ }, 5000);
129
+
130
+ return this;
131
+ }
132
+
133
+ leave() {
134
+ if (this.closed) return;
135
+ this.closed = true;
136
+
137
+ if (this._heartbeat) { clearInterval(this._heartbeat); this._heartbeat = null; }
138
+ if (this._unsubHello) { this._unsubHello(); this._unsubHello = null; }
139
+ if (this._unsubBye) { this._unsubBye(); this._unsubBye = null; }
140
+
141
+ try { this._signaling && this._signaling.send('bye', {}); } catch (_) {}
142
+
143
+ for (const info of this._peers.values()) {
144
+ try { info.peer.close(); } catch (_) {}
145
+ }
146
+ this._peers.clear();
147
+ this._videoSenders.clear();
148
+ this._emit('peers', this._peers);
149
+
150
+ if (this._channel) {
151
+ try { this._channel.close(); } catch (_) {}
152
+ this._channel = null;
153
+ }
154
+ }
155
+
156
+ // ---- Media control ---------------------------------------------------
157
+
158
+ /**
159
+ * Replace our outgoing video track on every peer (used for screen share).
160
+ * Pass `null` to remove video entirely (camera-off).
161
+ */
162
+ async replaceVideoTrack(newTrack) {
163
+ for (const [id, sender] of this._videoSenders) {
164
+ try { await sender.replaceTrack(newTrack); }
165
+ catch (err) { this._emit('error', err); }
166
+ }
167
+ }
168
+
169
+ /** Broadcast a chat message over every peer's data channel. */
170
+ sendChat(text) {
171
+ const msg = { from: this.id, name: this.displayName, text, t: Date.now() };
172
+ const payload = JSON.stringify(msg);
173
+ for (const info of this._peers.values()) {
174
+ const dc = info.chat;
175
+ if (dc && dc.readyState === 'open') {
176
+ try { dc.send(payload); } catch (_) {}
177
+ }
178
+ }
179
+ // Echo locally so the sender sees their own message.
180
+ this._emit('chat', msg);
181
+ }
182
+
183
+ // ---- Peer plumbing ---------------------------------------------------
184
+
185
+ _addPeer(remoteId, remoteName) {
186
+ // Perfect-negotiation politeness: the peer with the larger id is polite.
187
+ const polite = this.id > remoteId;
188
+ const peer = new window.$.Peer(remoteId, this._signaling, {
189
+ polite,
190
+ iceServers: this.iceServers,
191
+ });
192
+
193
+ /** @type {PeerInfo} */
194
+ const info = { id: remoteId, name: remoteName, stream: null, peer, chat: null };
195
+ this._peers.set(remoteId, info);
196
+
197
+ // Collect remote tracks into a single MediaStream per peer.
198
+ peer.on('track', (ev) => {
199
+ const stream = ev.streams && ev.streams[0]
200
+ ? ev.streams[0]
201
+ : (info.stream || new MediaStream([ev.track]));
202
+ info.stream = stream;
203
+ this._emit('peers', this._peers);
204
+ });
205
+
206
+ // The peer with the smaller id opens the chat channel so we only get
207
+ // one per pair. The other side picks it up via 'datachannel'.
208
+ if (this.id < remoteId) {
209
+ const dc = peer.createDataChannel('chat');
210
+ this._wireChat(info, dc);
211
+ } else {
212
+ peer.on('datachannel', (ev) => this._wireChat(info, ev.channel));
213
+ }
214
+
215
+ peer.on('connectionstatechange', (state) => {
216
+ if (state === 'failed' || state === 'closed') this._removePeer(remoteId);
217
+ });
218
+
219
+ peer.on('error', (err) => this._emit('error', err));
220
+
221
+ // Publish local tracks (camera + mic) so the negotiation fires.
222
+ if (this._localStream) {
223
+ for (const track of this._localStream.getTracks()) {
224
+ const sender = peer.addTrack(track, this._localStream);
225
+ if (track.kind === 'video') this._videoSenders.set(remoteId, sender);
226
+ }
227
+ }
228
+
229
+ this._emit('peers', this._peers);
230
+ this._emit('status', 'Peer joined: ' + remoteName);
231
+ }
232
+
233
+ _wireChat(info, dc) {
234
+ info.chat = dc;
235
+ dc.onmessage = (ev) => {
236
+ try { this._emit('chat', JSON.parse(ev.data)); }
237
+ catch (_) { /* ignore malformed */ }
238
+ };
239
+ dc.onopen = () => this._emit('status', info.name + ' is now connected.');
240
+ dc.onclose = () => { if (info.chat === dc) info.chat = null; };
241
+ }
242
+
243
+ _removePeer(remoteId) {
244
+ const info = this._peers.get(remoteId);
245
+ if (!info) return;
246
+ try { info.peer.close(); } catch (_) {}
247
+ this._peers.delete(remoteId);
248
+ this._videoSenders.delete(remoteId);
249
+ this._emit('peers', this._peers);
250
+ this._emit('status', 'Peer left: ' + info.name);
251
+ }
252
+ }
File without changes