zero-query 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -442
  5. package/cli/commands/build.js +254 -247
  6. package/cli/commands/bundle.js +1228 -1224
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -220
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +661 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6614
  81. package/dist/zquery.min.js +8 -631
  82. package/index.d.ts +570 -371
  83. package/index.js +311 -240
  84. package/package.json +76 -70
  85. package/src/component.js +1709 -1691
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -255
  93. package/src/router.js +843 -843
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -318
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1103
  115. package/tests/compare.test.js +497 -486
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -489
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -1650
  121. package/tests/electron-features.test.js +864 -864
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -146
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -0,0 +1,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
+ }