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,625 @@
1
+ /**
2
+ * src/webrtc/room.js - high-level Room handle and `join()` orchestrator
3
+ *
4
+ * A `Room` owns a mesh of `Peer` instances around a single `SignalingClient`,
5
+ * exposing reactive `peers` / `localTracks` `Signal`s, plus an imperative
6
+ * `publish` / `unpublish` / `dataChannel` / `leave` surface and a small
7
+ * `peer-joined` / `peer-left` / `error` event bus.
8
+ *
9
+ * Created by `webrtc.join(url, opts)` (see `index.js`). Direct construction
10
+ * is private API - callers should always go through `join()` so the
11
+ * connect / hello / join handshake completes before they get the handle.
12
+ *
13
+ * SSR-safe by reflection: `webrtc.join` defers all browser globals
14
+ * (`WebSocket`, `RTCPeerConnection`, `navigator.mediaDevices`) until called.
15
+ */
16
+
17
+ import { signal } from '../reactive.js';
18
+ import { SignalingClient } from './signaling.js';
19
+ import { Peer } from './peer.js';
20
+ import { WebRtcError, SignalingError } from './errors.js';
21
+
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Room
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * High-level handle around a joined room.
29
+ *
30
+ * Do not call `new Room(...)` directly - use `webrtc.join()`. The
31
+ * constructor is exported for type-checking and testing only.
32
+ */
33
+ export class Room {
34
+ /**
35
+ * @param {object} args
36
+ * @param {string} args.id - Room id (the `room` argument passed to `webrtc.join`).
37
+ * @param {string} args.self - Server-assigned local peer id (from the `hello` frame).
38
+ * @param {SignalingClient} args.signaling - Live signaling client.
39
+ * @param {object} [args.peerOptions] - Forwarded to each `new Peer(id, sig, opts)`.
40
+ */
41
+ constructor({ id, self, signaling, peerOptions = {} }) {
42
+ if (typeof id !== 'string' || id.length === 0) {
43
+ throw new WebRtcError('Room: id must be a non-empty string', { code: 'ZQ_WEBRTC_ROOM_BAD_ID' });
44
+ }
45
+ if (typeof self !== 'string' || self.length === 0) {
46
+ throw new WebRtcError('Room: self must be a non-empty string', { code: 'ZQ_WEBRTC_ROOM_BAD_SELF' });
47
+ }
48
+ if (!signaling || typeof signaling.send !== 'function') {
49
+ throw new WebRtcError('Room: signaling must be a SignalingClient', { code: 'ZQ_WEBRTC_ROOM_BAD_SIGNALING' });
50
+ }
51
+
52
+ this.id = id;
53
+ this.self = self;
54
+ this.signaling = signaling;
55
+ this.peerOptions = peerOptions;
56
+ this.closed = false;
57
+
58
+ /** Reactive map of remote peers, keyed by peer id. */
59
+ this.peers = signal(new Map());
60
+ /** Reactive list of local tracks currently being published. */
61
+ this.localTracks = signal([]);
62
+
63
+ // Event bus (peer-joined, peer-left, mute, unmute, error)
64
+ /** @type {Map<string, Set<Function>>} */
65
+ this._listeners = new Map();
66
+
67
+ // Track every (stream, track) pair we're publishing so a peer that
68
+ // joins later automatically receives the same set of tracks.
69
+ /** @type {Array<{ track: MediaStreamTrack, stream: MediaStream }>} */
70
+ this._publishedTracks = [];
71
+
72
+ // Per-peer sender bookkeeping so unpublish() can remove cleanly.
73
+ // _peerSenders : Map<peerId, Map<track, sender>>
74
+ /** @type {Map<string, Map<MediaStreamTrack, any>>} */
75
+ this._peerSenders = new Map();
76
+
77
+ // Multiplexed data channels, keyed by label. Each entry owns the
78
+ // per-peer underlying RTCDataChannel map plus the broadcast wrapper.
79
+ /** @type {Map<string, _RoomDataChannel>} */
80
+ this._channels = new Map();
81
+
82
+ this._signalingUnsubs = [];
83
+ this._attachSignaling();
84
+ }
85
+
86
+
87
+ // ---- Mesh management ---------------------------------------------------
88
+
89
+ /**
90
+ * Add a remote peer to the mesh. Idempotent.
91
+ * @param {string} peerId
92
+ */
93
+ _addPeer(peerId) {
94
+ if (this.closed) return;
95
+ if (peerId === this.self) return;
96
+ const map = this.peers.peek();
97
+ if (map.has(peerId)) return;
98
+
99
+ // Perfect-negotiation polite flag - both ends agree deterministically.
100
+ const polite = this.self > peerId;
101
+ const peer = new Peer(peerId, this.signaling, Object.assign({ polite }, this.peerOptions));
102
+
103
+ /** @type {{ id: string, peer: Peer, pc: RTCPeerConnection, stream: MediaStream, audio: boolean, video: boolean, connection: string }} */
104
+ const info = {
105
+ id: peerId,
106
+ peer,
107
+ pc: peer.pc,
108
+ stream: _newMediaStream(),
109
+ audio: false,
110
+ video: false,
111
+ connection: 'new',
112
+ };
113
+
114
+ peer.on('track', (evt) => {
115
+ // Prefer the first event-supplied stream so MediaStream identity is
116
+ // shared with what the remote sent; fall back to our local synthetic.
117
+ const incoming = evt && evt.streams && evt.streams[0];
118
+ if (incoming && incoming !== info.stream) {
119
+ info.stream = incoming;
120
+ } else if (evt && evt.track && typeof info.stream.addTrack === 'function') {
121
+ info.stream.addTrack(evt.track);
122
+ }
123
+ if (evt && evt.track) {
124
+ if (evt.track.kind === 'audio') info.audio = true;
125
+ if (evt.track.kind === 'video') info.video = true;
126
+ }
127
+ this._touchPeer(peerId);
128
+ });
129
+
130
+ peer.on('connectionstatechange', (state) => {
131
+ info.connection = state;
132
+ this._touchPeer(peerId);
133
+ if (state === 'failed') this._emit('error', new WebRtcError(
134
+ `Room: peer "${peerId}" connection failed`,
135
+ { code: 'ZQ_WEBRTC_PEER_FAILED', context: { peerId } }
136
+ ));
137
+ });
138
+
139
+ peer.on('datachannel', (evt) => {
140
+ const dc = evt && evt.channel;
141
+ if (!dc) return;
142
+ // Surface the incoming channel through the matching multiplex
143
+ // wrapper so callers see remote-opened channels alongside their own.
144
+ const wrap = this._channels.get(dc.label);
145
+ if (wrap) wrap._adoptIncoming(peerId, dc);
146
+ });
147
+
148
+ peer.on('error', (err) => this._emit('error', err));
149
+
150
+ // Mirror the new peer into the reactive Map.
151
+ const next = new Map(map);
152
+ next.set(peerId, info);
153
+ this.peers.value = next;
154
+
155
+ // Pre-existing local tracks: republish to the fresh peer.
156
+ if (this._publishedTracks.length > 0) {
157
+ const senders = new Map();
158
+ for (const { track, stream } of this._publishedTracks) {
159
+ try {
160
+ const sender = peer.addTrack(track, stream);
161
+ senders.set(track, sender);
162
+ } catch (err) {
163
+ this._emit('error', err);
164
+ }
165
+ }
166
+ this._peerSenders.set(peerId, senders);
167
+ }
168
+
169
+ // Pre-existing data channels: open the same label on the new peer.
170
+ for (const wrap of this._channels.values()) {
171
+ try { wrap._openOnPeer(peerId, peer); }
172
+ catch (err) { this._emit('error', err); }
173
+ }
174
+
175
+ this._emit('peer-joined', info);
176
+ }
177
+
178
+ /**
179
+ * Drop a peer from the mesh.
180
+ * @param {string} peerId
181
+ */
182
+ _removePeer(peerId) {
183
+ const map = this.peers.peek();
184
+ const info = map.get(peerId);
185
+ if (!info) return;
186
+
187
+ try { info.peer.close(); }
188
+ catch (_) { /* idempotent */ }
189
+
190
+ for (const wrap of this._channels.values()) wrap._dropPeer(peerId);
191
+ this._peerSenders.delete(peerId);
192
+
193
+ const next = new Map(map);
194
+ next.delete(peerId);
195
+ this.peers.value = next;
196
+
197
+ this._emit('peer-left', info);
198
+ }
199
+
200
+ /** Re-emit a `peers` notification (used when PeerInfo internals mutate in place). */
201
+ _touchPeer(peerId) {
202
+ const map = this.peers.peek();
203
+ if (!map.has(peerId)) return;
204
+ // Replace with a fresh Map so the signal notifies subscribers.
205
+ this.peers.value = new Map(map);
206
+ }
207
+
208
+
209
+ // ---- Imperative surface ------------------------------------------------
210
+
211
+ /**
212
+ * Add every track in `stream` to every existing peer (and remember the
213
+ * pair so peers that join later also receive them).
214
+ *
215
+ * @param {MediaStream} stream
216
+ * @returns {Promise<void>}
217
+ */
218
+ async publish(stream) {
219
+ if (this.closed) throw new WebRtcError('Room.publish: room is closed', { code: 'ZQ_WEBRTC_ROOM_CLOSED' });
220
+ if (!stream || typeof stream.getTracks !== 'function') {
221
+ throw new WebRtcError('Room.publish: stream must be a MediaStream', { code: 'ZQ_WEBRTC_ROOM_BAD_STREAM' });
222
+ }
223
+ const tracks = stream.getTracks();
224
+ for (const track of tracks) {
225
+ // Skip duplicates.
226
+ if (this._publishedTracks.some((p) => p.track === track)) continue;
227
+ this._publishedTracks.push({ track, stream });
228
+
229
+ for (const [peerId, info] of this.peers.peek()) {
230
+ const senders = this._peerSenders.get(peerId) || new Map();
231
+ try {
232
+ const sender = info.peer.addTrack(track, stream);
233
+ senders.set(track, sender);
234
+ } catch (err) {
235
+ this._emit('error', err);
236
+ }
237
+ this._peerSenders.set(peerId, senders);
238
+ }
239
+ }
240
+ // Notify localTracks subscribers.
241
+ this.localTracks.value = this._publishedTracks.map((p) => p.track);
242
+ }
243
+
244
+ /**
245
+ * Remove every track in `stream` from every peer.
246
+ *
247
+ * @param {MediaStream} stream
248
+ * @returns {Promise<void>}
249
+ */
250
+ async unpublish(stream) {
251
+ if (this.closed) return;
252
+ if (!stream || typeof stream.getTracks !== 'function') {
253
+ throw new WebRtcError('Room.unpublish: stream must be a MediaStream', { code: 'ZQ_WEBRTC_ROOM_BAD_STREAM' });
254
+ }
255
+ const tracks = stream.getTracks();
256
+ for (const track of tracks) {
257
+ const idx = this._publishedTracks.findIndex((p) => p.track === track);
258
+ if (idx === -1) continue;
259
+ this._publishedTracks.splice(idx, 1);
260
+ for (const [peerId, info] of this.peers.peek()) {
261
+ const senders = this._peerSenders.get(peerId);
262
+ if (!senders) continue;
263
+ const sender = senders.get(track);
264
+ if (!sender) continue;
265
+ try { info.peer.removeTrack(sender); }
266
+ catch (err) { this._emit('error', err); }
267
+ senders.delete(track);
268
+ }
269
+ }
270
+ this.localTracks.value = this._publishedTracks.map((p) => p.track);
271
+ }
272
+
273
+ /**
274
+ * Open (or look up) a multiplexed data channel on this room. The same
275
+ * `label` returns the same wrapper across calls. `send()` broadcasts to
276
+ * every peer; `on('message', cb)` fires once per inbound frame from any
277
+ * peer with `(data, peerId)` as the arguments.
278
+ *
279
+ * @param {string} label
280
+ * @param {RTCDataChannelInit} [opts]
281
+ */
282
+ dataChannel(label, opts) {
283
+ if (this.closed) throw new WebRtcError('Room.dataChannel: room is closed', { code: 'ZQ_WEBRTC_ROOM_CLOSED' });
284
+ if (typeof label !== 'string' || label.length === 0) {
285
+ throw new WebRtcError('Room.dataChannel: label must be a non-empty string', { code: 'ZQ_WEBRTC_ROOM_BAD_LABEL' });
286
+ }
287
+ const existing = this._channels.get(label);
288
+ if (existing) return existing;
289
+
290
+ const wrap = new _RoomDataChannel(label, opts || {});
291
+ this._channels.set(label, wrap);
292
+
293
+ for (const [peerId, info] of this.peers.peek()) {
294
+ try { wrap._openOnPeer(peerId, info.peer); }
295
+ catch (err) { this._emit('error', err); }
296
+ }
297
+ return wrap;
298
+ }
299
+
300
+ /**
301
+ * Leave the room - closes every peer, tells the server, and disposes
302
+ * the signaling subscriptions. The underlying `SignalingClient` is left
303
+ * open so the caller can join another room without reconnecting.
304
+ */
305
+ async leave() {
306
+ if (this.closed) return;
307
+ this.closed = true;
308
+
309
+ for (const unsub of this._signalingUnsubs) {
310
+ try { unsub(); } catch (_) { /* idempotent */ }
311
+ }
312
+ this._signalingUnsubs = [];
313
+
314
+ for (const wrap of this._channels.values()) wrap._closeAll();
315
+ this._channels.clear();
316
+
317
+ for (const [, info] of this.peers.peek()) {
318
+ try { info.peer.close(); } catch (_) { /* idempotent */ }
319
+ }
320
+ this.peers.value = new Map();
321
+
322
+ try { this.signaling.send('leave', {}); }
323
+ catch (_) { /* socket may already be closed */ }
324
+
325
+ this._listeners.clear();
326
+ }
327
+
328
+
329
+ // ---- Tiny event bus ---------------------------------------------------
330
+
331
+ /**
332
+ * Subscribe to a room-level event.
333
+ * @param {'peer-joined'|'peer-left'|'mute'|'unmute'|'error'} event
334
+ * @param {Function} cb
335
+ * @returns {() => void}
336
+ */
337
+ on(event, cb) {
338
+ if (typeof cb !== 'function') return () => {};
339
+ let set = this._listeners.get(event);
340
+ if (!set) { set = new Set(); this._listeners.set(event, set); }
341
+ set.add(cb);
342
+ return () => this.off(event, cb);
343
+ }
344
+
345
+ /** Remove a previously registered listener. */
346
+ off(event, cb) {
347
+ const set = this._listeners.get(event);
348
+ if (set) set.delete(cb);
349
+ }
350
+
351
+ /** @private */
352
+ _emit(event, payload) {
353
+ const set = this._listeners.get(event);
354
+ if (!set) return;
355
+ for (const cb of [...set]) {
356
+ try { cb(payload); }
357
+ catch (_) { /* listeners must not break the room */ }
358
+ }
359
+ }
360
+
361
+
362
+ // ---- Signaling glue ---------------------------------------------------
363
+
364
+ /** @private */
365
+ _attachSignaling() {
366
+ this._signalingUnsubs.push(this.signaling.on('peer-joined', (msg) => {
367
+ if (msg && typeof msg.id === 'string') this._addPeer(msg.id);
368
+ }));
369
+ this._signalingUnsubs.push(this.signaling.on('peer-left', (msg) => {
370
+ if (msg && typeof msg.id === 'string') this._removePeer(msg.id);
371
+ }));
372
+ this._signalingUnsubs.push(this.signaling.on('mute', (msg) => {
373
+ this._emit('mute', msg);
374
+ }));
375
+ this._signalingUnsubs.push(this.signaling.on('unmute', (msg) => {
376
+ this._emit('unmute', msg);
377
+ }));
378
+ }
379
+ }
380
+
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // _RoomDataChannel - multiplex wrapper around per-peer RTCDataChannels
384
+ // ---------------------------------------------------------------------------
385
+
386
+ class _RoomDataChannel {
387
+ constructor(label, opts) {
388
+ this.label = label;
389
+ this.opts = opts;
390
+ this.closed = false;
391
+ /** @type {Map<string, RTCDataChannel>} */
392
+ this._byPeer = new Map();
393
+ /** @type {Set<Function>} */
394
+ this._onMessage = new Set();
395
+ /** @type {Set<Function>} */
396
+ this._onOpen = new Set();
397
+ }
398
+
399
+ /** Open the channel on a freshly-joined peer. */
400
+ _openOnPeer(peerId, peer) {
401
+ if (this.closed) return;
402
+ if (this._byPeer.has(peerId)) return;
403
+ const dc = peer.createDataChannel(this.label, this.opts);
404
+ this._attach(peerId, dc);
405
+ }
406
+
407
+ /** Adopt an incoming channel that the remote opened first. */
408
+ _adoptIncoming(peerId, dc) {
409
+ if (this.closed) return;
410
+ // If we already created one for this peer, prefer the existing.
411
+ if (this._byPeer.has(peerId)) return;
412
+ this._attach(peerId, dc);
413
+ }
414
+
415
+ _attach(peerId, dc) {
416
+ this._byPeer.set(peerId, dc);
417
+ const fanOpen = () => {
418
+ for (const cb of [...this._onOpen]) {
419
+ try { cb(peerId); } catch (_) { /* swallow */ }
420
+ }
421
+ };
422
+ const fanMsg = (evt) => {
423
+ const data = evt && 'data' in evt ? evt.data : evt;
424
+ for (const cb of [...this._onMessage]) {
425
+ try { cb(data, peerId); } catch (_) { /* swallow */ }
426
+ }
427
+ };
428
+ if (typeof dc.addEventListener === 'function') {
429
+ dc.addEventListener('open', fanOpen);
430
+ dc.addEventListener('message', fanMsg);
431
+ } else {
432
+ dc.onopen = fanOpen;
433
+ dc.onmessage = fanMsg;
434
+ }
435
+ }
436
+
437
+ /** Drop a peer's underlying channel (peer-left). */
438
+ _dropPeer(peerId) {
439
+ const dc = this._byPeer.get(peerId);
440
+ if (dc) { try { dc.close(); } catch (_) {} }
441
+ this._byPeer.delete(peerId);
442
+ }
443
+
444
+ /** Close every underlying channel. */
445
+ _closeAll() {
446
+ this.closed = true;
447
+ for (const dc of this._byPeer.values()) {
448
+ try { dc.close(); } catch (_) {}
449
+ }
450
+ this._byPeer.clear();
451
+ this._onMessage.clear();
452
+ this._onOpen.clear();
453
+ }
454
+
455
+
456
+ // -- Public surface ------------------------------------------------------
457
+
458
+ /** Broadcast a payload to every peer's underlying channel. */
459
+ send(data) {
460
+ if (this.closed) return;
461
+ for (const dc of this._byPeer.values()) {
462
+ try { dc.send(data); }
463
+ catch (_) { /* skip dead channels */ }
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Subscribe to one of two events:
469
+ * - `'message'` (data, peerId) - fires per inbound frame from any peer
470
+ * - `'open'` (peerId) - fires when a per-peer channel reaches `'open'`
471
+ *
472
+ * @param {'message'|'open'} event
473
+ * @param {Function} cb
474
+ * @returns {() => void}
475
+ */
476
+ on(event, cb) {
477
+ if (typeof cb !== 'function') return () => {};
478
+ const set = event === 'open' ? this._onOpen : this._onMessage;
479
+ set.add(cb);
480
+ return () => set.delete(cb);
481
+ }
482
+
483
+ /** Close every underlying channel (alias for `_closeAll`). */
484
+ close() { this._closeAll(); }
485
+ }
486
+
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // join()
490
+ // ---------------------------------------------------------------------------
491
+
492
+ /**
493
+ * Connect to the signaling URL, join a room, and resolve with a `Room`.
494
+ *
495
+ * Browser globals are looked up at call time (never at module load) so the
496
+ * library stays SSR-safe. Tests can inject a fake `WebSocket` and
497
+ * `RTCPeerConnection` via the options bag.
498
+ *
499
+ * @param {string} url - WebSocket URL of a `@zero-server/webrtc` hub.
500
+ * @param {object} opts
501
+ * @param {string} opts.room
502
+ * @param {string} [opts.token]
503
+ * @param {RTCIceServer[]} [opts.iceServers]
504
+ * @param {boolean|MediaStreamConstraints} [opts.media] - If truthy, calls `getUserMedia` and `publish()` the result.
505
+ * @param {boolean|'auto'} [opts.polite] - Forced polite flag override (rarely useful - the default lexicographic rule is correct for symmetric meshes).
506
+ * @param {number} [opts.signalingTimeoutMs] - Max time to wait for `hello` + `joined` frames. Default `15000`.
507
+ * @param {false|object} [opts.reconnect] - Forwarded to `SignalingClient`.
508
+ * @param {typeof WebSocket} [opts.WebSocket] - Override (tests / SSR).
509
+ * @param {typeof RTCPeerConnection} [opts.RTCPeerConnection] - Override (tests / SSR).
510
+ * @param {{ mediaDevices: { getUserMedia(c): Promise<MediaStream> } }} [opts.navigator] - Override `navigator.mediaDevices` for tests.
511
+ * @returns {Promise<Room>}
512
+ */
513
+ export async function join(url, opts) {
514
+ if (typeof url !== 'string' || url.length === 0) {
515
+ throw new WebRtcError('webrtc.join: url must be a non-empty string', { code: 'ZQ_WEBRTC_JOIN_BAD_URL' });
516
+ }
517
+ if (!opts || typeof opts.room !== 'string' || opts.room.length === 0) {
518
+ throw new WebRtcError('webrtc.join: opts.room must be a non-empty string', { code: 'ZQ_WEBRTC_JOIN_BAD_ROOM' });
519
+ }
520
+
521
+ const sigOpts = {};
522
+ if (opts.reconnect !== undefined) sigOpts.reconnect = opts.reconnect;
523
+ if (opts.WebSocket) sigOpts.WebSocket = opts.WebSocket;
524
+
525
+ const signaling = new SignalingClient(url, sigOpts);
526
+
527
+ const peerOptions = {};
528
+ if (opts.iceServers) peerOptions.iceServers = opts.iceServers;
529
+ if (opts.RTCPeerConnection) peerOptions.RTCPeerConnection = opts.RTCPeerConnection;
530
+ if (opts.polite !== undefined && opts.polite !== 'auto') peerOptions.polite = !!opts.polite;
531
+
532
+ const timeoutMs = typeof opts.signalingTimeoutMs === 'number' ? opts.signalingTimeoutMs : 15_000;
533
+
534
+ const helloPromise = _waitFor(signaling, 'hello', timeoutMs);
535
+ const joinedPromise = _waitFor(signaling, 'joined', timeoutMs);
536
+ // Avoid an unhandled rejection if `hello` fails first and we never
537
+ // reach the `await joinedPromise` site.
538
+ joinedPromise.catch(() => {});
539
+
540
+ try {
541
+ await signaling.connect();
542
+ const hello = await helloPromise;
543
+ const selfId = hello && hello.peerId;
544
+ if (typeof selfId !== 'string' || selfId.length === 0) {
545
+ throw new SignalingError('webrtc.join: hello frame missing peerId', { code: 'ZQ_WEBRTC_JOIN_NO_PEER_ID' });
546
+ }
547
+
548
+ signaling.send('join', { room: opts.room, token: opts.token });
549
+ const joined = await joinedPromise;
550
+ const initialPeers = (joined && Array.isArray(joined.peers)) ? joined.peers : [];
551
+
552
+ const room = new Room({ id: opts.room, self: selfId, signaling, peerOptions });
553
+ for (const peerId of initialPeers) room._addPeer(peerId);
554
+
555
+ if (opts.media) {
556
+ const constraints = opts.media === true ? { audio: true, video: true } : opts.media;
557
+ const nav = opts.navigator
558
+ || (typeof navigator !== 'undefined' ? navigator : null);
559
+ const md = nav && nav.mediaDevices;
560
+ if (!md || typeof md.getUserMedia !== 'function') {
561
+ throw new WebRtcError(
562
+ 'webrtc.join: navigator.mediaDevices.getUserMedia is unavailable',
563
+ { code: 'ZQ_WEBRTC_JOIN_NO_MEDIA_DEVICES' }
564
+ );
565
+ }
566
+ const stream = await md.getUserMedia(constraints);
567
+ await room.publish(stream);
568
+ }
569
+
570
+ return room;
571
+ } catch (err) {
572
+ try { signaling.close(); } catch (_) {}
573
+ if (err instanceof WebRtcError) throw err;
574
+ throw new WebRtcError(
575
+ `webrtc.join: ${err && err.message ? err.message : 'failed'}`,
576
+ { code: 'ZQ_WEBRTC_JOIN_FAILED', cause: err }
577
+ );
578
+ }
579
+ }
580
+
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // Internals
584
+ // ---------------------------------------------------------------------------
585
+
586
+ /** Resolve on the first matching frame, reject after `timeoutMs`. */
587
+ function _waitFor(signaling, type, timeoutMs) {
588
+ return new Promise((resolve, reject) => {
589
+ let done = false;
590
+ const off = signaling.on(type, (msg) => {
591
+ if (done) return;
592
+ done = true;
593
+ try { off(); } catch (_) {}
594
+ clearTimeout(timer);
595
+ resolve(msg);
596
+ });
597
+ const timer = setTimeout(() => {
598
+ if (done) return;
599
+ done = true;
600
+ try { off(); } catch (_) {}
601
+ reject(new SignalingError(
602
+ `webrtc.join: timed out waiting for "${type}" after ${timeoutMs}ms`,
603
+ { code: 'ZQ_WEBRTC_JOIN_TIMEOUT', context: { type, timeoutMs } }
604
+ ));
605
+ }, timeoutMs);
606
+ });
607
+ }
608
+
609
+ /** Try to instantiate a real MediaStream; fall back to a tiny stub for environments that lack it. */
610
+ function _newMediaStream() {
611
+ if (typeof MediaStream === 'function') {
612
+ try { return new MediaStream(); }
613
+ catch (_) { /* fall through */ }
614
+ }
615
+ const tracks = [];
616
+ return {
617
+ id: `stream_${Math.random().toString(36).slice(2, 10)}`,
618
+ getTracks: () => tracks.slice(),
619
+ addTrack: (t) => { tracks.push(t); },
620
+ removeTrack: (t) => {
621
+ const i = tracks.indexOf(t);
622
+ if (i >= 0) tracks.splice(i, 1);
623
+ },
624
+ };
625
+ }