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,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/webrtc/signaling.js - WebSocket signaling client
|
|
3
|
+
*
|
|
4
|
+
* Speaks the wire protocol of `@zero-server/webrtc` over a WebSocket
|
|
5
|
+
* transport. Handles connect / reconnect with exponential backoff,
|
|
6
|
+
* stores the `peerId` assigned by the server's initial `hello` frame,
|
|
7
|
+
* provides a tiny `on`/`off`/`send` event surface, and coalesces
|
|
8
|
+
* outbound trickle `ice` frames so we don't trip the hub's per-peer
|
|
9
|
+
* rate limit (default 30 msg/sec, 10/200ms here gives plenty of headroom).
|
|
10
|
+
*
|
|
11
|
+
* SSR-safe: nothing touches `WebSocket` at module load - the connection
|
|
12
|
+
* (and any timers) only spin up when `.connect()` is called.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { SignalingError } from './errors.js';
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Default base backoff between reconnect attempts (ms). */
|
|
23
|
+
const DEFAULT_BACKOFF_BASE_MS = 250;
|
|
24
|
+
|
|
25
|
+
/** Default cap on the per-attempt backoff (ms). */
|
|
26
|
+
const DEFAULT_BACKOFF_CAP_MS = 8000;
|
|
27
|
+
|
|
28
|
+
/** Default maximum number of reconnect attempts before giving up. */
|
|
29
|
+
const DEFAULT_MAX_RETRIES = 10;
|
|
30
|
+
|
|
31
|
+
/** Default ICE-coalescing window length (ms). */
|
|
32
|
+
const DEFAULT_ICE_FLUSH_MS = 200;
|
|
33
|
+
|
|
34
|
+
/** Default max ICE frames flushed per coalesce window. */
|
|
35
|
+
const DEFAULT_ICE_BATCH = 10;
|
|
36
|
+
|
|
37
|
+
/** WebSocket close codes treated as "do not reconnect" (client-initiated bye). */
|
|
38
|
+
const CLOSE_CODE_NORMAL = 1000;
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Tiny WebSocket signaling client for the zQuery WebRTC stack.
|
|
43
|
+
*
|
|
44
|
+
* const client = new SignalingClient('wss://api.example.com/rtc');
|
|
45
|
+
* client.on('hello', ({ peerId }) => console.log('I am', peerId));
|
|
46
|
+
* client.on('joined', ({ room, peers }) => ...);
|
|
47
|
+
* await client.connect();
|
|
48
|
+
* client.send('join', { room: 'lobby' });
|
|
49
|
+
*
|
|
50
|
+
* Lifecycle events (in addition to server frame types):
|
|
51
|
+
* - `open` fired on every successful socket open (incl. reconnects).
|
|
52
|
+
* - `close` fired on every socket close, payload `{ code, reason, wasClean }`.
|
|
53
|
+
* - `reconnect` fired before each reconnect attempt, payload `{ attempt, delayMs }`.
|
|
54
|
+
* - `error` fired on protocol errors with a `SignalingError` payload.
|
|
55
|
+
*/
|
|
56
|
+
export class SignalingClient {
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} url - WebSocket URL (`ws://` or `wss://`).
|
|
59
|
+
* @param {object} [options]
|
|
60
|
+
* @param {object} [options.reconnect] - reconnect tuning (set `false` to disable).
|
|
61
|
+
* @param {number} [options.reconnect.baseMs=250] - base backoff per attempt.
|
|
62
|
+
* @param {number} [options.reconnect.capMs=8000] - cap on per-attempt backoff.
|
|
63
|
+
* @param {number} [options.reconnect.maxRetries=10] - hard cap on reconnect attempts.
|
|
64
|
+
* @param {number} [options.iceFlushMs=200] - ICE coalesce window length (ms).
|
|
65
|
+
* @param {number} [options.iceBatch=10] - max ICE frames flushed per window.
|
|
66
|
+
* @param {Function} [options.WebSocket] - WebSocket constructor (defaults to global; useful for tests).
|
|
67
|
+
*/
|
|
68
|
+
constructor(url, options = {}) {
|
|
69
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
70
|
+
throw new SignalingError('SignalingClient requires a non-empty url', { code: 'ZQ_WEBRTC_SIGNALING_BAD_URL' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const reconnect = options.reconnect === false
|
|
74
|
+
? null
|
|
75
|
+
: Object.assign(
|
|
76
|
+
{
|
|
77
|
+
baseMs: DEFAULT_BACKOFF_BASE_MS,
|
|
78
|
+
capMs: DEFAULT_BACKOFF_CAP_MS,
|
|
79
|
+
maxRetries: DEFAULT_MAX_RETRIES,
|
|
80
|
+
},
|
|
81
|
+
options.reconnect || {}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
this.url = url;
|
|
85
|
+
this.options = {
|
|
86
|
+
reconnect,
|
|
87
|
+
iceFlushMs: options.iceFlushMs || DEFAULT_ICE_FLUSH_MS,
|
|
88
|
+
iceBatch: options.iceBatch || DEFAULT_ICE_BATCH,
|
|
89
|
+
WebSocket: options.WebSocket || null,
|
|
90
|
+
};
|
|
91
|
+
this.peerId = null;
|
|
92
|
+
this.ws = null;
|
|
93
|
+
this.connected = false;
|
|
94
|
+
this.closed = false;
|
|
95
|
+
this._attempts = 0;
|
|
96
|
+
this._listeners = new Map();
|
|
97
|
+
this._iceQueue = [];
|
|
98
|
+
this._iceTimer = null;
|
|
99
|
+
this._reconnectTimer = null;
|
|
100
|
+
this._helloReceived = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -----------------------------------------------------------------------
|
|
104
|
+
// Event surface
|
|
105
|
+
// -----------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Register a listener for a server frame type or lifecycle event.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} type
|
|
111
|
+
* @param {Function} cb
|
|
112
|
+
* @returns {Function} unsubscribe function.
|
|
113
|
+
*/
|
|
114
|
+
on(type, cb) {
|
|
115
|
+
if (typeof cb !== 'function') return () => {};
|
|
116
|
+
let set = this._listeners.get(type);
|
|
117
|
+
if (!set) { set = new Set(); this._listeners.set(type, set); }
|
|
118
|
+
set.add(cb);
|
|
119
|
+
return () => this.off(type, cb);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove a previously registered listener.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} type
|
|
126
|
+
* @param {Function} cb
|
|
127
|
+
*/
|
|
128
|
+
off(type, cb) {
|
|
129
|
+
const set = this._listeners.get(type);
|
|
130
|
+
if (set) set.delete(cb);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Internal: emit to every registered listener for `type`.
|
|
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 the socket loop */ }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// -----------------------------------------------------------------------
|
|
150
|
+
// Lifecycle
|
|
151
|
+
// -----------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Open the socket. Resolves on first successful `open` event; rejects with
|
|
155
|
+
* a `SignalingError` if the very first connection attempt fails.
|
|
156
|
+
* Subsequent reconnects happen transparently and do not reject this promise.
|
|
157
|
+
*
|
|
158
|
+
* @returns {Promise<void>}
|
|
159
|
+
*/
|
|
160
|
+
connect() {
|
|
161
|
+
if (this.connected) return Promise.resolve();
|
|
162
|
+
this.closed = false;
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const onceOpen = () => {
|
|
165
|
+
this.off('open', onceOpen);
|
|
166
|
+
this.off('error', onceErr);
|
|
167
|
+
resolve();
|
|
168
|
+
};
|
|
169
|
+
const onceErr = (err) => {
|
|
170
|
+
if (this._attempts === 0) {
|
|
171
|
+
this.off('open', onceOpen);
|
|
172
|
+
this.off('error', onceErr);
|
|
173
|
+
reject(err);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
this.on('open', onceOpen);
|
|
177
|
+
this.on('error', onceErr);
|
|
178
|
+
this._open();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Send a frame `{ type, ...payload }` to the server. `ice` frames are
|
|
184
|
+
* coalesced and flushed in batches of `iceBatch` per `iceFlushMs`.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} type
|
|
187
|
+
* @param {object} [payload]
|
|
188
|
+
*/
|
|
189
|
+
send(type, payload = {}) {
|
|
190
|
+
if (typeof type !== 'string' || type.length === 0) {
|
|
191
|
+
throw new SignalingError('SignalingClient.send requires a frame type', { code: 'ZQ_WEBRTC_SIGNALING_BAD_FRAME' });
|
|
192
|
+
}
|
|
193
|
+
const frame = Object.assign({ type }, payload);
|
|
194
|
+
if (type === 'ice') {
|
|
195
|
+
this._iceQueue.push(frame);
|
|
196
|
+
this._scheduleIceFlush();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this._sendRaw(frame);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Gracefully close the socket. Sends a `bye` frame (best-effort), cancels
|
|
204
|
+
* any pending reconnect, and never reconnects again until `.connect()` is
|
|
205
|
+
* called explicitly.
|
|
206
|
+
*/
|
|
207
|
+
close() {
|
|
208
|
+
this.closed = true;
|
|
209
|
+
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
|
|
210
|
+
if (this._iceTimer) { clearTimeout(this._iceTimer); this._iceTimer = null; }
|
|
211
|
+
this._iceQueue.length = 0;
|
|
212
|
+
if (this.ws) {
|
|
213
|
+
try { this._sendRaw({ type: 'bye' }); } catch (_) { /* socket may be dead */ }
|
|
214
|
+
try { this.ws.close(CLOSE_CODE_NORMAL, 'client-bye'); } catch (_) { /* */ }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// -----------------------------------------------------------------------
|
|
219
|
+
// Internals
|
|
220
|
+
// -----------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Open the underlying WebSocket and wire its event handlers. Defers all
|
|
224
|
+
* access to the global `WebSocket` so SSR consumers can import this
|
|
225
|
+
* module without a polyfill.
|
|
226
|
+
*
|
|
227
|
+
* @private
|
|
228
|
+
*/
|
|
229
|
+
_open() {
|
|
230
|
+
const WS = this.options.WebSocket
|
|
231
|
+
|| (typeof WebSocket !== 'undefined' ? WebSocket : null);
|
|
232
|
+
if (!WS) {
|
|
233
|
+
const err = new SignalingError('No WebSocket implementation available (SSR? pass options.WebSocket)', { code: 'ZQ_WEBRTC_SIGNALING_NO_WS' });
|
|
234
|
+
this._emit('error', err);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this._helloReceived = false;
|
|
239
|
+
let ws;
|
|
240
|
+
try { ws = new WS(this.url); }
|
|
241
|
+
catch (cause) {
|
|
242
|
+
const err = new SignalingError('Failed to construct WebSocket', { code: 'ZQ_WEBRTC_SIGNALING_OPEN', cause });
|
|
243
|
+
this._emit('error', err);
|
|
244
|
+
this._scheduleReconnect();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
this.ws = ws;
|
|
248
|
+
|
|
249
|
+
ws.onopen = () => {
|
|
250
|
+
this.connected = true;
|
|
251
|
+
this._attempts = 0;
|
|
252
|
+
this._emit('open', { url: this.url });
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
ws.onmessage = (event) => this._onMessage(event);
|
|
256
|
+
|
|
257
|
+
ws.onerror = (event) => {
|
|
258
|
+
const err = new SignalingError('WebSocket error', { code: 'ZQ_WEBRTC_SIGNALING_WS_ERROR', context: { event } });
|
|
259
|
+
this._emit('error', err);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
ws.onclose = (event) => {
|
|
263
|
+
this.connected = false;
|
|
264
|
+
this.ws = null;
|
|
265
|
+
const payload = { code: event && event.code, reason: event && event.reason, wasClean: event && event.wasClean };
|
|
266
|
+
this._emit('close', payload);
|
|
267
|
+
if (this.closed) return;
|
|
268
|
+
if (payload.code === CLOSE_CODE_NORMAL) return;
|
|
269
|
+
this._scheduleReconnect();
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Parse + validate an incoming frame and dispatch to listeners. The first
|
|
275
|
+
* message after `open` must be `{ type: 'hello', peerId }`; anything else
|
|
276
|
+
* (or a malformed JSON payload) raises a `SignalingError`.
|
|
277
|
+
*
|
|
278
|
+
* @param {MessageEvent} event
|
|
279
|
+
* @private
|
|
280
|
+
*/
|
|
281
|
+
_onMessage(event) {
|
|
282
|
+
let frame;
|
|
283
|
+
try { frame = JSON.parse(event.data); }
|
|
284
|
+
catch (cause) {
|
|
285
|
+
this._emit('error', new SignalingError('Malformed JSON from server', { code: 'ZQ_WEBRTC_SIGNALING_BAD_JSON', cause }));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (!frame || typeof frame !== 'object' || typeof frame.type !== 'string') {
|
|
289
|
+
this._emit('error', new SignalingError('Frame missing required "type" field', { code: 'ZQ_WEBRTC_SIGNALING_BAD_FRAME', context: { frame } }));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!this._helloReceived) {
|
|
294
|
+
if (frame.type !== 'hello' || typeof frame.peerId !== 'string') {
|
|
295
|
+
this._emit('error', new SignalingError('First frame must be a hello with peerId', { code: 'ZQ_WEBRTC_SIGNALING_NO_HELLO', context: { frame } }));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
this._helloReceived = true;
|
|
299
|
+
this.peerId = frame.peerId;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this._emit(frame.type, frame);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Send a frame immediately (no coalescing). Buffers a `SignalingError`
|
|
307
|
+
* to listeners if the socket is not currently open.
|
|
308
|
+
*
|
|
309
|
+
* @param {object} frame
|
|
310
|
+
* @private
|
|
311
|
+
*/
|
|
312
|
+
_sendRaw(frame) {
|
|
313
|
+
if (!this.ws || !this.connected) {
|
|
314
|
+
this._emit('error', new SignalingError('Cannot send frame: socket not open', { code: 'ZQ_WEBRTC_SIGNALING_NOT_OPEN', context: { type: frame && frame.type } }));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try { this.ws.send(JSON.stringify(frame)); }
|
|
318
|
+
catch (cause) {
|
|
319
|
+
this._emit('error', new SignalingError('socket.send threw', { code: 'ZQ_WEBRTC_SIGNALING_SEND_FAIL', cause }));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Schedule a coalesced ICE flush. Multiple `send('ice', ...)` calls within
|
|
325
|
+
* `iceFlushMs` of each other are drained together (up to `iceBatch` per
|
|
326
|
+
* window), keeping us well under the server's per-peer message-rate cap.
|
|
327
|
+
*
|
|
328
|
+
* @private
|
|
329
|
+
*/
|
|
330
|
+
_scheduleIceFlush() {
|
|
331
|
+
if (this._iceTimer) return;
|
|
332
|
+
this._iceTimer = setTimeout(() => {
|
|
333
|
+
this._iceTimer = null;
|
|
334
|
+
this._flushIce();
|
|
335
|
+
if (this._iceQueue.length > 0) this._scheduleIceFlush();
|
|
336
|
+
}, this.options.iceFlushMs);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Drain up to `iceBatch` ICE frames from the queue, sending each
|
|
341
|
+
* individually. We intentionally do not concatenate them into a single
|
|
342
|
+
* wire frame - the server's protocol expects one `ice` frame per candidate.
|
|
343
|
+
*
|
|
344
|
+
* @private
|
|
345
|
+
*/
|
|
346
|
+
_flushIce() {
|
|
347
|
+
const batch = this._iceQueue.splice(0, this.options.iceBatch);
|
|
348
|
+
for (const frame of batch) this._sendRaw(frame);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Schedule the next reconnect attempt using exponential backoff with the
|
|
353
|
+
* configured cap, bailing out once `maxRetries` is reached.
|
|
354
|
+
*
|
|
355
|
+
* @private
|
|
356
|
+
*/
|
|
357
|
+
_scheduleReconnect() {
|
|
358
|
+
const cfg = this.options.reconnect;
|
|
359
|
+
if (!cfg) return;
|
|
360
|
+
if (this._attempts >= cfg.maxRetries) {
|
|
361
|
+
this._emit('error', new SignalingError('Max reconnect attempts exceeded', { code: 'ZQ_WEBRTC_SIGNALING_GIVEUP', context: { attempts: this._attempts } }));
|
|
362
|
+
this.closed = true;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const attempt = this._attempts++;
|
|
366
|
+
const delayMs = Math.min(cfg.capMs, cfg.baseMs * Math.pow(2, attempt));
|
|
367
|
+
this._emit('reconnect', { attempt: attempt + 1, delayMs });
|
|
368
|
+
this._reconnectTimer = setTimeout(() => {
|
|
369
|
+
this._reconnectTimer = null;
|
|
370
|
+
if (!this.closed) this._open();
|
|
371
|
+
}, delayMs);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/webrtc/turn.js - TURN credential client
|
|
3
|
+
*
|
|
4
|
+
* Tiny HTTP helper for the `@zero-server/webrtc` TURN-credential endpoint
|
|
5
|
+
* (`issueTurnCredentials`). Fetches `{ username, credential, urls, ttl }`
|
|
6
|
+
* and exposes an `RTCIceServer[]` for direct injection into
|
|
7
|
+
* `RTCPeerConnection({ iceServers })`. A `createTurnRefresher` factory
|
|
8
|
+
* schedules an automatic refresh before the credentials expire.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { TurnError } from './errors.js';
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fetch a TURN credential bundle from `url`.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} url
|
|
18
|
+
* @param {RequestInit} [opts]
|
|
19
|
+
* @returns {Promise<{username: string, credential: string, urls: string[], ttl: number}>}
|
|
20
|
+
*/
|
|
21
|
+
export async function fetchTurnCredentials(url, opts) {
|
|
22
|
+
if (typeof url !== 'string' || !url) {
|
|
23
|
+
throw new TurnError('fetchTurnCredentials: url must be a non-empty string', {
|
|
24
|
+
code: 'ZQ_WEBRTC_TURN_BAD_URL',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const fetchImpl = (opts && opts.fetch) || (typeof fetch !== 'undefined' ? fetch : null);
|
|
28
|
+
if (!fetchImpl) {
|
|
29
|
+
throw new TurnError('fetchTurnCredentials: no fetch implementation available', {
|
|
30
|
+
code: 'ZQ_WEBRTC_TURN_NO_FETCH',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const init = { ...(opts || {}) };
|
|
35
|
+
delete init.fetch;
|
|
36
|
+
|
|
37
|
+
let res;
|
|
38
|
+
try {
|
|
39
|
+
res = await fetchImpl(url, init);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new TurnError(`fetchTurnCredentials: network error - ${err && err.message ? err.message : err}`, {
|
|
42
|
+
code: 'ZQ_WEBRTC_TURN_NETWORK',
|
|
43
|
+
cause: err instanceof Error ? err : undefined,
|
|
44
|
+
context: { url },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!res || !res.ok) {
|
|
49
|
+
const status = res ? res.status : 0;
|
|
50
|
+
throw new TurnError(`fetchTurnCredentials: HTTP ${status}`, {
|
|
51
|
+
code: 'ZQ_WEBRTC_TURN_HTTP',
|
|
52
|
+
context: { url, status },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let body;
|
|
57
|
+
try {
|
|
58
|
+
body = await res.json();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new TurnError('fetchTurnCredentials: response is not valid JSON', {
|
|
61
|
+
code: 'ZQ_WEBRTC_TURN_BAD_JSON',
|
|
62
|
+
cause: err instanceof Error ? err : undefined,
|
|
63
|
+
context: { url },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return _validateCredentials(body, url);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merge TURN credentials with an optional base `iceServers` array, producing
|
|
73
|
+
* the final list to pass to `RTCPeerConnection`. The base list is preserved
|
|
74
|
+
* unchanged; the TURN bundle is appended as a single entry. Duplicate URL
|
|
75
|
+
* entries are dropped (first occurrence wins).
|
|
76
|
+
*
|
|
77
|
+
* @param {RTCIceServer[]} [base]
|
|
78
|
+
* @param {{username: string, credential: string, urls: string[]}} [turn]
|
|
79
|
+
* @returns {RTCIceServer[]}
|
|
80
|
+
*/
|
|
81
|
+
export function mergeIceServers(base, turn) {
|
|
82
|
+
const list = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const pushServer = (server) => {
|
|
85
|
+
if (!server || !server.urls) return;
|
|
86
|
+
const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
|
|
87
|
+
const fresh = urls.filter((u) => {
|
|
88
|
+
if (typeof u !== 'string' || !u) return false;
|
|
89
|
+
if (seen.has(u)) return false;
|
|
90
|
+
seen.add(u);
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
93
|
+
if (fresh.length === 0) return;
|
|
94
|
+
const next = { ...server, urls: fresh };
|
|
95
|
+
list.push(next);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (Array.isArray(base)) {
|
|
99
|
+
for (const s of base) pushServer(s);
|
|
100
|
+
}
|
|
101
|
+
if (turn && Array.isArray(turn.urls) && turn.urls.length > 0) {
|
|
102
|
+
pushServer({
|
|
103
|
+
urls: turn.urls,
|
|
104
|
+
username: turn.username,
|
|
105
|
+
credential: turn.credential,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return list;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Schedule automatic TURN-credential refresh ahead of expiry.
|
|
114
|
+
*
|
|
115
|
+
* Returns a handle:
|
|
116
|
+
* - `start()` — fetch once immediately, then auto-refresh.
|
|
117
|
+
* - `refresh()` — force an immediate refresh.
|
|
118
|
+
* - `stop()` — cancel any pending timer.
|
|
119
|
+
* - `peek()` / `value` — last successfully fetched credentials (or `null`).
|
|
120
|
+
*
|
|
121
|
+
* @param {{
|
|
122
|
+
* url: string,
|
|
123
|
+
* fetch?: typeof fetch,
|
|
124
|
+
* leadMs?: number,
|
|
125
|
+
* minIntervalMs?: number,
|
|
126
|
+
* onRefresh?: (creds: {username: string, credential: string, urls: string[], ttl: number}) => void,
|
|
127
|
+
* onError?: (err: Error) => void,
|
|
128
|
+
* requestInit?: RequestInit,
|
|
129
|
+
* }} opts
|
|
130
|
+
*/
|
|
131
|
+
export function createTurnRefresher(opts) {
|
|
132
|
+
if (!opts || typeof opts.url !== 'string' || !opts.url) {
|
|
133
|
+
throw new TurnError('createTurnRefresher: opts.url is required', {
|
|
134
|
+
code: 'ZQ_WEBRTC_TURN_REFRESHER_BAD_URL',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const url = opts.url;
|
|
138
|
+
const fetchImpl = opts.fetch || null;
|
|
139
|
+
const leadMs = Number.isFinite(opts.leadMs) ? opts.leadMs : 30000;
|
|
140
|
+
const minIntervalMs = Number.isFinite(opts.minIntervalMs) ? opts.minIntervalMs : 5000;
|
|
141
|
+
const onRefresh = typeof opts.onRefresh === 'function' ? opts.onRefresh : null;
|
|
142
|
+
const onError = typeof opts.onError === 'function' ? opts.onError : null;
|
|
143
|
+
const requestInit = opts.requestInit || undefined;
|
|
144
|
+
|
|
145
|
+
let timer = null;
|
|
146
|
+
let stopped = false;
|
|
147
|
+
let current = null;
|
|
148
|
+
|
|
149
|
+
const handle = {
|
|
150
|
+
get value() { return current; },
|
|
151
|
+
peek() { return current; },
|
|
152
|
+
async refresh() {
|
|
153
|
+
if (stopped) return null;
|
|
154
|
+
try {
|
|
155
|
+
const init = fetchImpl ? { ...(requestInit || {}), fetch: fetchImpl } : requestInit;
|
|
156
|
+
const creds = await fetchTurnCredentials(url, init);
|
|
157
|
+
if (stopped) return creds;
|
|
158
|
+
current = creds;
|
|
159
|
+
_schedule(creds.ttl);
|
|
160
|
+
if (onRefresh) onRefresh(creds);
|
|
161
|
+
return creds;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (!stopped && onError) onError(err);
|
|
164
|
+
if (!stopped) _schedule(60); // retry in 60s on failure
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
async start() {
|
|
169
|
+
if (stopped) return null;
|
|
170
|
+
return handle.refresh();
|
|
171
|
+
},
|
|
172
|
+
stop() {
|
|
173
|
+
stopped = true;
|
|
174
|
+
if (timer) {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
timer = null;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function _schedule(ttlSeconds) {
|
|
182
|
+
if (timer) {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
timer = null;
|
|
185
|
+
}
|
|
186
|
+
const ms = Math.max(minIntervalMs, ttlSeconds * 1000 - leadMs);
|
|
187
|
+
timer = setTimeout(() => {
|
|
188
|
+
timer = null;
|
|
189
|
+
handle.refresh().catch(() => {});
|
|
190
|
+
}, ms);
|
|
191
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return handle;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
function _validateCredentials(body, url) {
|
|
199
|
+
if (!body || typeof body !== 'object') {
|
|
200
|
+
throw new TurnError('fetchTurnCredentials: response is not an object', {
|
|
201
|
+
code: 'ZQ_WEBRTC_TURN_BAD_BODY',
|
|
202
|
+
context: { url },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const { username, credential, urls, ttl } = body;
|
|
206
|
+
if (typeof username !== 'string' || !username) {
|
|
207
|
+
throw new TurnError('fetchTurnCredentials: response.username missing', {
|
|
208
|
+
code: 'ZQ_WEBRTC_TURN_BAD_BODY',
|
|
209
|
+
context: { url, field: 'username' },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (typeof credential !== 'string' || !credential) {
|
|
213
|
+
throw new TurnError('fetchTurnCredentials: response.credential missing', {
|
|
214
|
+
code: 'ZQ_WEBRTC_TURN_BAD_BODY',
|
|
215
|
+
context: { url, field: 'credential' },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (!Array.isArray(urls) || urls.length === 0 || !urls.every((u) => typeof u === 'string' && u)) {
|
|
219
|
+
throw new TurnError('fetchTurnCredentials: response.urls must be a non-empty string array', {
|
|
220
|
+
code: 'ZQ_WEBRTC_TURN_BAD_BODY',
|
|
221
|
+
context: { url, field: 'urls' },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const ttlNum = Number(ttl);
|
|
225
|
+
if (!Number.isFinite(ttlNum) || ttlNum <= 0) {
|
|
226
|
+
throw new TurnError('fetchTurnCredentials: response.ttl must be a positive number', {
|
|
227
|
+
code: 'ZQ_WEBRTC_TURN_BAD_BODY',
|
|
228
|
+
context: { url, field: 'ttl' },
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
username,
|
|
233
|
+
credential,
|
|
234
|
+
urls: urls.slice(),
|
|
235
|
+
ttl: ttlNum,
|
|
236
|
+
};
|
|
237
|
+
}
|