zero-query 1.0.9 → 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.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -0
- package/cli/commands/build.js +254 -216
- package/cli/commands/bundle.js +1228 -1183
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -167
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +7264 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6252
- package/dist/zquery.min.js +8 -601
- package/index.d.ts +570 -365
- package/index.js +311 -232
- package/package.json +76 -69
- package/src/component.js +1709 -1454
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -254
- package/src/router.js +843 -773
- package/src/ssr.js +418 -418
- package/src/store.js +318 -272
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1023
- package/tests/compare.test.js +497 -0
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -0
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -0
- package/tests/electron-features.test.js +864 -0
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -145
- package/types/utils.d.ts +245 -245
- 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
|
+
}
|