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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/webrtc/observe.js
|
|
3
|
+
*
|
|
4
|
+
* Low-level WebRTC observability helpers built on top of
|
|
5
|
+
* `RTCPeerConnection.getStats()`. The reactive layer
|
|
6
|
+
* (`useConnectionQuality`) is built on top of these — keeping the raw
|
|
7
|
+
* sampler separate makes it easy to plug stats into logging, dev tools,
|
|
8
|
+
* or telemetry without spinning up the reactive runtime.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { WebRtcError } from './errors.js';
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Take a one-shot getStats() snapshot and reduce it to a flat summary
|
|
16
|
+
* suitable for logging, dashboards, or feeding into `classifyStats()`.
|
|
17
|
+
*
|
|
18
|
+
* @param {RTCPeerConnection} pc
|
|
19
|
+
* @returns {Promise<{
|
|
20
|
+
* report: any,
|
|
21
|
+
* inboundRtp: any[],
|
|
22
|
+
* outboundRtp: any[],
|
|
23
|
+
* candidatePair: any | null,
|
|
24
|
+
* summary: { rttMs: number | null, lossPct: number, bytesSent: number, bytesReceived: number }
|
|
25
|
+
* }>}
|
|
26
|
+
*/
|
|
27
|
+
export async function samplePeerStats(pc) {
|
|
28
|
+
if (!pc || typeof pc.getStats !== 'function') {
|
|
29
|
+
throw new WebRtcError('samplePeerStats(pc): RTCPeerConnection required', {
|
|
30
|
+
code: 'ZQ_WEBRTC_OBSERVE_BAD_PC',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
let report;
|
|
34
|
+
try {
|
|
35
|
+
report = await pc.getStats();
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
throw new WebRtcError('samplePeerStats(pc): getStats() failed', {
|
|
38
|
+
code: 'ZQ_WEBRTC_OBSERVE_GETSTATS_FAILED',
|
|
39
|
+
cause,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return _reduce(report);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start a periodic getStats() sampler.
|
|
48
|
+
*
|
|
49
|
+
* @param {RTCPeerConnection} pc
|
|
50
|
+
* @param {{
|
|
51
|
+
* intervalMs?: number,
|
|
52
|
+
* onSample?: (sample: Awaited<ReturnType<typeof samplePeerStats>>) => void,
|
|
53
|
+
* onError?: (err: Error) => void,
|
|
54
|
+
* immediate?: boolean,
|
|
55
|
+
* }} [opts]
|
|
56
|
+
* @returns {{
|
|
57
|
+
* stop: () => void,
|
|
58
|
+
* getLatest: () => Awaited<ReturnType<typeof samplePeerStats>> | null,
|
|
59
|
+
* }}
|
|
60
|
+
*/
|
|
61
|
+
export function createStatsSampler(pc, opts = {}) {
|
|
62
|
+
if (!pc || typeof pc.getStats !== 'function') {
|
|
63
|
+
throw new WebRtcError('createStatsSampler(pc): RTCPeerConnection required', {
|
|
64
|
+
code: 'ZQ_WEBRTC_OBSERVE_BAD_PC',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const intervalMs = typeof opts.intervalMs === 'number' && opts.intervalMs > 0 ? opts.intervalMs : 2000;
|
|
68
|
+
const immediate = opts.immediate !== false;
|
|
69
|
+
const onSample = typeof opts.onSample === 'function' ? opts.onSample : null;
|
|
70
|
+
const onError = typeof opts.onError === 'function' ? opts.onError : null;
|
|
71
|
+
|
|
72
|
+
let latest = null;
|
|
73
|
+
let stopped = false;
|
|
74
|
+
|
|
75
|
+
const tick = async () => {
|
|
76
|
+
if (stopped) return;
|
|
77
|
+
try {
|
|
78
|
+
const s = await samplePeerStats(pc);
|
|
79
|
+
if (stopped) return;
|
|
80
|
+
latest = s;
|
|
81
|
+
if (onSample) {
|
|
82
|
+
try { onSample(s); } catch (_) { /* user callback errors are swallowed */ }
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (onError) {
|
|
86
|
+
try { onError(err); } catch (_) { /* user callback errors are swallowed */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (immediate) tick();
|
|
92
|
+
const timer = setInterval(tick, intervalMs);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
stop() {
|
|
96
|
+
if (stopped) return;
|
|
97
|
+
stopped = true;
|
|
98
|
+
clearInterval(timer);
|
|
99
|
+
},
|
|
100
|
+
getLatest() { return latest; },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Bucket a reduced sample into a coarse quality label.
|
|
107
|
+
*
|
|
108
|
+
* @param {{ summary: { rttMs: number | null, lossPct: number } } | null} sample
|
|
109
|
+
* @returns {'good' | 'fair' | 'poor' | 'unknown'}
|
|
110
|
+
*/
|
|
111
|
+
export function classifyStats(sample) {
|
|
112
|
+
if (!sample || !sample.summary) return 'unknown';
|
|
113
|
+
const { rttMs, lossPct } = sample.summary;
|
|
114
|
+
if (rttMs == null && lossPct === 0) return 'unknown';
|
|
115
|
+
if (lossPct > 5 || (rttMs != null && rttMs > 400)) return 'poor';
|
|
116
|
+
if (lossPct > 1 || (rttMs != null && rttMs > 200)) return 'fair';
|
|
117
|
+
return 'good';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Internals
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function _reduce(report) {
|
|
126
|
+
const inboundRtp = [];
|
|
127
|
+
const outboundRtp = [];
|
|
128
|
+
let candidatePair = null;
|
|
129
|
+
|
|
130
|
+
const visit = (s) => {
|
|
131
|
+
if (!s || typeof s !== 'object') return;
|
|
132
|
+
if (s.type === 'inbound-rtp') inboundRtp.push(s);
|
|
133
|
+
if (s.type === 'outbound-rtp') outboundRtp.push(s);
|
|
134
|
+
if (s.type === 'candidate-pair' && (s.nominated || s.state === 'succeeded')) {
|
|
135
|
+
if (!candidatePair) candidatePair = s;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (report && typeof report.forEach === 'function') {
|
|
140
|
+
report.forEach(visit);
|
|
141
|
+
} else if (report && typeof report === 'object') {
|
|
142
|
+
for (const k of Object.keys(report)) visit(report[k]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let bytesSent = 0;
|
|
146
|
+
let bytesReceived = 0;
|
|
147
|
+
let lostTotal = 0;
|
|
148
|
+
let recvTotal = 0;
|
|
149
|
+
for (const s of outboundRtp) {
|
|
150
|
+
if (typeof s.bytesSent === 'number') bytesSent += s.bytesSent;
|
|
151
|
+
}
|
|
152
|
+
for (const s of inboundRtp) {
|
|
153
|
+
if (typeof s.bytesReceived === 'number') bytesReceived += s.bytesReceived;
|
|
154
|
+
if (typeof s.packetsLost === 'number') lostTotal += s.packetsLost;
|
|
155
|
+
if (typeof s.packetsReceived === 'number') recvTotal += s.packetsReceived;
|
|
156
|
+
}
|
|
157
|
+
const total = lostTotal + recvTotal;
|
|
158
|
+
const lossPct = total > 0 ? (lostTotal / total) * 100 : 0;
|
|
159
|
+
|
|
160
|
+
let rttMs = null;
|
|
161
|
+
if (candidatePair && typeof candidatePair.currentRoundTripTime === 'number') {
|
|
162
|
+
rttMs = candidatePair.currentRoundTripTime * 1000;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
report,
|
|
167
|
+
inboundRtp,
|
|
168
|
+
outboundRtp,
|
|
169
|
+
candidatePair,
|
|
170
|
+
summary: { rttMs, lossPct, bytesSent, bytesReceived },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/webrtc/peer.js - RTCPeerConnection wrapper with perfect negotiation
|
|
3
|
+
*
|
|
4
|
+
* Wraps a browser `RTCPeerConnection` and routes JSEP messages through a
|
|
5
|
+
* `SignalingClient` instance for a single remote peer. Implements the W3C
|
|
6
|
+
* "perfect negotiation" pattern (Jan-Ivar Bruaroey) so that simultaneous
|
|
7
|
+
* `negotiationneeded` events on both ends resolve deterministically based
|
|
8
|
+
* on the locally-assigned `polite` flag - no glare, no manual rollback.
|
|
9
|
+
*
|
|
10
|
+
* Wire-protocol mapping (mirrors @zero-server/webrtc):
|
|
11
|
+
* - outgoing `offer` -> `{ type: 'offer', to, sdp }` (sdp is the string)
|
|
12
|
+
* - outgoing `answer` -> `{ type: 'answer', to, sdp }`
|
|
13
|
+
* - outgoing `ice` -> `{ type: 'ice', to, candidate }` (raw a=candidate: line or null)
|
|
14
|
+
* - incoming filtered by `msg.from === this.id`.
|
|
15
|
+
*
|
|
16
|
+
* Server-side constraints honored here:
|
|
17
|
+
* - at most `maxIceCandidates` trickled candidates per peer (default 30 -
|
|
18
|
+
* the hub's hard cap on `a=candidate:` lines per SDP)
|
|
19
|
+
* - `mDNS` (`.local`) candidates dropped before send
|
|
20
|
+
* - failed `connectionState` automatically calls `pc.restartIce()`.
|
|
21
|
+
*
|
|
22
|
+
* SSR-safe: nothing touches `RTCPeerConnection` at module load - it's only
|
|
23
|
+
* resolved when a `Peer` is constructed. Tests can inject a fake constructor
|
|
24
|
+
* via `options.RTCPeerConnection`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { WebRtcError, SdpError, IceError } from './errors.js';
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Constants
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Default cap on trickled ICE candidates per peer (matches server SDP cap). */
|
|
35
|
+
const DEFAULT_MAX_ICE_CANDIDATES = 30;
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One remote peer over a shared `SignalingClient`. Caller owns the lifetime
|
|
40
|
+
* (construct, attach tracks, eventually call `.close()`).
|
|
41
|
+
*
|
|
42
|
+
* const peer = new Peer('peer_42', signaling, { polite: true });
|
|
43
|
+
* peer.addTrack(localAudio, localStream);
|
|
44
|
+
* peer.on('track', ({ track, streams }) => attachToVideoEl(streams[0]));
|
|
45
|
+
*
|
|
46
|
+
* Lifecycle events:
|
|
47
|
+
* - `track` forwards the underlying `RTCTrackEvent`.
|
|
48
|
+
* - `connectionstatechange` payload is the new `pc.connectionState` string.
|
|
49
|
+
* - `datachannel` forwards `RTCDataChannelEvent`.
|
|
50
|
+
* - `close` fired exactly once when `.close()` runs.
|
|
51
|
+
* - `error` `SdpError` / `IceError` from negotiation.
|
|
52
|
+
*/
|
|
53
|
+
export class Peer {
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} peerId - remote peer id (the `from`/`to` value on the wire).
|
|
56
|
+
* @param {import('./signaling.js').SignalingClient} signaling - shared signaling client.
|
|
57
|
+
* @param {object} [options]
|
|
58
|
+
* @param {boolean} [options.polite=false] - perfect-negotiation polite flag.
|
|
59
|
+
* @param {RTCIceServer[]} [options.iceServers] - STUN/TURN servers.
|
|
60
|
+
* @param {Function} [options.RTCPeerConnection] - constructor override (tests).
|
|
61
|
+
* @param {number} [options.maxIceCandidates=30] - trickled-candidate hard cap.
|
|
62
|
+
* @param {object} [options.rtcConfig] - extra fields merged into `RTCConfiguration`.
|
|
63
|
+
*/
|
|
64
|
+
constructor(peerId, signaling, options = {}) {
|
|
65
|
+
if (typeof peerId !== 'string' || peerId.length === 0) {
|
|
66
|
+
throw new WebRtcError('Peer requires a non-empty peerId', { code: 'ZQ_WEBRTC_PEER_BAD_ID' });
|
|
67
|
+
}
|
|
68
|
+
if (!signaling || typeof signaling.send !== 'function' || typeof signaling.on !== 'function') {
|
|
69
|
+
throw new WebRtcError('Peer requires a SignalingClient-like object', { code: 'ZQ_WEBRTC_PEER_BAD_SIGNALING' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const PCCtor = options.RTCPeerConnection
|
|
73
|
+
|| (typeof globalThis !== 'undefined' && globalThis.RTCPeerConnection)
|
|
74
|
+
|| null;
|
|
75
|
+
if (!PCCtor) {
|
|
76
|
+
throw new WebRtcError(
|
|
77
|
+
'RTCPeerConnection is not available in this environment',
|
|
78
|
+
{ code: 'ZQ_WEBRTC_NO_RTC' }
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rtcConfig = Object.assign(
|
|
83
|
+
{ iceServers: options.iceServers || [] },
|
|
84
|
+
options.rtcConfig || {}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
this.id = peerId;
|
|
88
|
+
this.signaling = signaling;
|
|
89
|
+
this.polite = !!options.polite;
|
|
90
|
+
this.pc = new PCCtor(rtcConfig);
|
|
91
|
+
this.closed = false;
|
|
92
|
+
this.makingOffer = false;
|
|
93
|
+
this.ignoreOffer = false;
|
|
94
|
+
this.srdAnswerPending = false;
|
|
95
|
+
|
|
96
|
+
this._listeners = new Map();
|
|
97
|
+
this._maxIceCandidates = options.maxIceCandidates || DEFAULT_MAX_ICE_CANDIDATES;
|
|
98
|
+
this._sentCandidates = 0;
|
|
99
|
+
this._sigUnsub = [];
|
|
100
|
+
|
|
101
|
+
this._attachPc();
|
|
102
|
+
this._attachSignaling();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// -----------------------------------------------------------------------
|
|
106
|
+
// Event surface
|
|
107
|
+
// -----------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Subscribe to a Peer-level event.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} type
|
|
113
|
+
* @param {Function} cb
|
|
114
|
+
* @returns {Function} unsubscribe
|
|
115
|
+
*/
|
|
116
|
+
on(type, cb) {
|
|
117
|
+
if (typeof cb !== 'function') return () => {};
|
|
118
|
+
let set = this._listeners.get(type);
|
|
119
|
+
if (!set) { set = new Set(); this._listeners.set(type, set); }
|
|
120
|
+
set.add(cb);
|
|
121
|
+
return () => this.off(type, cb);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Remove a previously registered listener.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} type
|
|
128
|
+
* @param {Function} cb
|
|
129
|
+
*/
|
|
130
|
+
off(type, cb) {
|
|
131
|
+
const set = this._listeners.get(type);
|
|
132
|
+
if (set) set.delete(cb);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} type
|
|
137
|
+
* @param {*} payload
|
|
138
|
+
* @private
|
|
139
|
+
*/
|
|
140
|
+
_emit(type, payload) {
|
|
141
|
+
const set = this._listeners.get(type);
|
|
142
|
+
if (!set || set.size === 0) return;
|
|
143
|
+
for (const cb of [...set]) {
|
|
144
|
+
try { cb(payload); }
|
|
145
|
+
catch (_) { /* listener errors must not break negotiation */ }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// -----------------------------------------------------------------------
|
|
150
|
+
// Track / datachannel passthrough
|
|
151
|
+
// -----------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Add a local track to this peer. Returns the `RTCRtpSender` so the caller
|
|
155
|
+
* can later `replaceTrack()` or `removeTrack()` it directly.
|
|
156
|
+
*
|
|
157
|
+
* @param {MediaStreamTrack} track
|
|
158
|
+
* @param {...MediaStream} streams
|
|
159
|
+
* @returns {*}
|
|
160
|
+
*/
|
|
161
|
+
addTrack(track, ...streams) {
|
|
162
|
+
return this.pc.addTrack(track, ...streams);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Remove a previously-added sender from the peer.
|
|
167
|
+
*
|
|
168
|
+
* @param {*} sender
|
|
169
|
+
*/
|
|
170
|
+
removeTrack(sender) {
|
|
171
|
+
return this.pc.removeTrack(sender);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Create a data channel on this peer. The remote side observes a
|
|
176
|
+
* `datachannel` event on its own `Peer`.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} label
|
|
179
|
+
* @param {RTCDataChannelInit} [init]
|
|
180
|
+
* @returns {RTCDataChannel}
|
|
181
|
+
*/
|
|
182
|
+
createDataChannel(label, init) {
|
|
183
|
+
return this.pc.createDataChannel(label, init);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Force ICE restart - useful from app code after detecting a long
|
|
188
|
+
* `disconnected` window. Negotiation kicks off automatically via
|
|
189
|
+
* `negotiationneeded`.
|
|
190
|
+
*/
|
|
191
|
+
restartIce() {
|
|
192
|
+
if (this.closed) return;
|
|
193
|
+
try { this.pc.restartIce(); }
|
|
194
|
+
catch (err) {
|
|
195
|
+
this._emit('error', new IceError(err.message || 'restartIce failed', {
|
|
196
|
+
code: 'ZQ_WEBRTC_ICE_RESTART_FAILED',
|
|
197
|
+
cause: err,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Close the underlying `RTCPeerConnection` and detach signaling listeners.
|
|
204
|
+
* Idempotent.
|
|
205
|
+
*/
|
|
206
|
+
close() {
|
|
207
|
+
if (this.closed) return;
|
|
208
|
+
this.closed = true;
|
|
209
|
+
|
|
210
|
+
for (const off of this._sigUnsub) { try { off(); } catch (_) {} }
|
|
211
|
+
this._sigUnsub.length = 0;
|
|
212
|
+
|
|
213
|
+
try { this.pc.close(); } catch (_) {}
|
|
214
|
+
this._emit('close');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// -----------------------------------------------------------------------
|
|
218
|
+
// Internal: RTCPeerConnection wiring
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/** @private */
|
|
222
|
+
_attachPc() {
|
|
223
|
+
this.pc.onnegotiationneeded = async () => {
|
|
224
|
+
if (this.closed) return;
|
|
225
|
+
try {
|
|
226
|
+
this.makingOffer = true;
|
|
227
|
+
await this.pc.setLocalDescription();
|
|
228
|
+
const desc = this.pc.localDescription;
|
|
229
|
+
if (!desc || !desc.sdp) return;
|
|
230
|
+
this.signaling.send('offer', { to: this.id, sdp: desc.sdp });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
this._emit('error', new SdpError(err.message || 'offer failed', {
|
|
233
|
+
code: 'ZQ_WEBRTC_SDP_OFFER_FAILED',
|
|
234
|
+
cause: err,
|
|
235
|
+
}));
|
|
236
|
+
} finally {
|
|
237
|
+
this.makingOffer = false;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
this.pc.onicecandidate = (event) => {
|
|
242
|
+
if (this.closed) return;
|
|
243
|
+
const candidate = event && event.candidate;
|
|
244
|
+
// End-of-candidates marker (null) -> always forward.
|
|
245
|
+
if (!candidate) {
|
|
246
|
+
this.signaling.send('ice', { to: this.id, candidate: null });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const cand = typeof candidate === 'string' ? candidate : candidate.candidate;
|
|
250
|
+
if (!cand) return;
|
|
251
|
+
// Drop mDNS candidates - servers / non-mDNS peers can't resolve them.
|
|
252
|
+
if (cand.indexOf('.local') !== -1) return;
|
|
253
|
+
if (this._sentCandidates >= this._maxIceCandidates) return;
|
|
254
|
+
this._sentCandidates++;
|
|
255
|
+
this.signaling.send('ice', { to: this.id, candidate: cand });
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
this.pc.ontrack = (event) => {
|
|
259
|
+
if (this.closed) return;
|
|
260
|
+
this._emit('track', event);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
this.pc.ondatachannel = (event) => {
|
|
264
|
+
if (this.closed) return;
|
|
265
|
+
this._emit('datachannel', event);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
this.pc.onconnectionstatechange = () => {
|
|
269
|
+
if (this.closed) return;
|
|
270
|
+
const state = this.pc.connectionState;
|
|
271
|
+
this._emit('connectionstatechange', state);
|
|
272
|
+
if (state === 'failed') {
|
|
273
|
+
try { this.pc.restartIce(); } catch (_) { /* swallow */ }
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** @private */
|
|
279
|
+
_attachSignaling() {
|
|
280
|
+
const guard = (cb) => (msg) => {
|
|
281
|
+
if (this.closed) return;
|
|
282
|
+
if (!msg || msg.from !== this.id) return;
|
|
283
|
+
cb(msg);
|
|
284
|
+
};
|
|
285
|
+
this._sigUnsub.push(
|
|
286
|
+
this.signaling.on('offer', guard((m) => this._onRemoteDescription('offer', m.sdp))),
|
|
287
|
+
this.signaling.on('answer', guard((m) => this._onRemoteDescription('answer', m.sdp))),
|
|
288
|
+
this.signaling.on('ice', guard((m) => this._onRemoteCandidate(m.candidate))),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {'offer'|'answer'} kind
|
|
294
|
+
* @param {string|object} sdp
|
|
295
|
+
* @private
|
|
296
|
+
*/
|
|
297
|
+
async _onRemoteDescription(kind, sdp) {
|
|
298
|
+
// Accept either a full description object (some servers relay it that
|
|
299
|
+
// way) or a bare SDP string; normalize to { type, sdp }.
|
|
300
|
+
const description = typeof sdp === 'string'
|
|
301
|
+
? { type: kind, sdp }
|
|
302
|
+
: sdp;
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const ready = !this.makingOffer
|
|
306
|
+
&& (this.pc.signalingState === 'stable' || this.srdAnswerPending);
|
|
307
|
+
const offerCollision = description.type === 'offer' && !ready;
|
|
308
|
+
this.ignoreOffer = !this.polite && offerCollision;
|
|
309
|
+
if (this.ignoreOffer) return;
|
|
310
|
+
|
|
311
|
+
this.srdAnswerPending = description.type === 'answer';
|
|
312
|
+
await this.pc.setRemoteDescription(description);
|
|
313
|
+
this.srdAnswerPending = false;
|
|
314
|
+
|
|
315
|
+
if (description.type === 'offer') {
|
|
316
|
+
await this.pc.setLocalDescription();
|
|
317
|
+
const local = this.pc.localDescription;
|
|
318
|
+
if (local && local.sdp) {
|
|
319
|
+
this.signaling.send('answer', { to: this.id, sdp: local.sdp });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
this._emit('error', new SdpError(err.message || 'setRemoteDescription failed', {
|
|
324
|
+
code: 'ZQ_WEBRTC_SDP_APPLY_FAILED',
|
|
325
|
+
cause: err,
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {string|null} candidate - raw `a=candidate:` line or `null` for EOC.
|
|
332
|
+
* @private
|
|
333
|
+
*/
|
|
334
|
+
async _onRemoteCandidate(candidate) {
|
|
335
|
+
try {
|
|
336
|
+
if (candidate == null) {
|
|
337
|
+
// End-of-candidates: addIceCandidate(null) is the spec-compliant signal.
|
|
338
|
+
await this.pc.addIceCandidate(null);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await this.pc.addIceCandidate({ candidate });
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// The W3C pattern: suppress errors while we're explicitly ignoring an offer.
|
|
344
|
+
if (this.ignoreOffer) return;
|
|
345
|
+
this._emit('error', new IceError(err.message || 'addIceCandidate failed', {
|
|
346
|
+
code: 'ZQ_WEBRTC_ICE_ADD_FAILED',
|
|
347
|
+
cause: err,
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|