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
package/dist/API.md CHANGED
@@ -169,6 +169,23 @@ Complete API documentation for every module, method, option, and type in zQuery.
169
169
  - [Environment Properties](#environment-properties)
170
170
  - [Platform Detection](#platform-detection)
171
171
  - [Quick Reference](#quick-reference)
172
+ - [WebRTC](#webrtc)
173
+ - [Overview](#overview)
174
+ - [Surface Status](#surface-status)
175
+ - [Quick Start](#quick-start)
176
+ - [SignalingClient](#signalingclient)
177
+ - [Peer (Perfect Negotiation)](#peer-perfect-negotiation)
178
+ - [Room](#room)
179
+ - [Reactive Composables](#reactive-composables)
180
+ - [z-stream Directive](#z-stream-directive)
181
+ - [TURN Credentials](#turn-credentials)
182
+ - [End-to-End Encryption (SFrame)](#end-to-end-encryption-sframe)
183
+ - [SFU Adapters](#sfu-adapters)
184
+ - [Join Tokens](#join-tokens)
185
+ - [Observability (getStats)](#observability-getstats)
186
+ - [SDP + ICE Helpers](#sdp-ice-helpers)
187
+ - [Error Family](#error-family)
188
+ - [Wire Protocol](#wire-protocol)
172
189
 
173
190
  ---
174
191
 
@@ -6517,6 +6534,609 @@ const API_BASE = $.platform === 'electron'
6517
6534
 
6518
6535
  ---
6519
6536
 
6537
+ ## WebRTC
6538
+
6539
+
6540
+ zQuery ships a complete WebRTC client that speaks the wire protocol of `@zero-server/webrtc` — from a low-level `SignalingClient` + perfect-negotiation `Peer` up to a reactive multi-peer `Room` with composables, TURN credential rotation, SFrame end-to-end encryption, mediasoup & LiveKit SFU adapters, join-token decoding, and `getStats()` observability. The `z-stream` directive binds remote `MediaStream`s straight to `` / `` elements — no `URL.createObjectURL` dance.
6541
+
6542
+
6543
+ ### Overview
6544
+
6545
+
6546
+ The WebRTC surface is layered — pick the level you need:
6547
+
6548
+
6549
+ | Layer | API | Use when… |
6550
+ | --- | --- | --- |
6551
+ | **Raw** | `SignalingClient`, `Peer` | You want full control over JSEP and trickle. |
6552
+ | **Mid-level** | `Room`, `$.webrtc.join(url, opts)` | You need a multi-peer container with publish / data channels. |
6553
+ | **Reactive** | `useRoom`, `usePeer`, `useTracks`, `useDataChannel`, `useConnectionQuality` | You want to wire room state straight into components. |
6554
+ | **Pluggable** | `loadSfuAdapter('mediasoup' \| 'livekit')` | You're routing through an SFU instead of mesh. |
6555
+ | **Hardening** | `SFrameContext`, `attachE2ee`, TURN refresher | You need E2EE and rotating TURN credentials. |
6556
+
6557
+
6558
+ > **Tip:** Want a working starting point? Run `npx zero-query create my-app --webrtc-demo` (alias `-w`) to scaffold a one-page video room with local + remote tiles, mic/cam controls, and a status overlay.
6559
+
6560
+
6561
+ ### Surface Status
6562
+
6563
+
6564
+ Everything in the table below is shipping today. Green dots mean stable + tested, blue dots mark optional peer-dependency surfaces (you install the dep yourself).
6565
+
6566
+
6567
+ | Surface | Status | Notes |
6568
+ | --- | --- | --- |
6569
+ | `SignalingClient` | Shipping | Exponential-backoff reconnect, coalesced ICE trickle |
6570
+ | `Peer` | Shipping | Perfect-negotiation polite/impolite collision handling |
6571
+ | `Room` / `$.webrtc.join()` | Shipping | Mesh topology with reactive `peers` map |
6572
+ | `useRoom` / `usePeer` / `useTracks` / `useDataChannel` / `useConnectionQuality` | Shipping | All return disposable handles |
6573
+ | `z-stream` directive | Shipping | SSR-safe; writes `srcObject` directly |
6574
+ | TURN client (`fetchTurnCredentials`, `mergeIceServers`, `createTurnRefresher`) | Shipping | Auto-refresh before TTL |
6575
+ | SFrame E2EE (`SFrameContext`, `attachE2ee`) | Shipping | AES-GCM-128 with epoch rotation |
6576
+ | `loadSfuAdapter('mediasoup')` | Shipping | Install `mediasoup-client` yourself |
6577
+ | `loadSfuAdapter('livekit')` | Shipping | Install `livekit-client` yourself |
6578
+ | `decodeJoinToken` / `isJoinTokenExpired` | Shipping | UX-only — server re-validates every join |
6579
+ | `samplePeerStats` / `createStatsSampler` / `classifyStats` | Shipping | `good` / `fair` / `poor` buckets |
6580
+ | SDP + ICE helpers (`parseSdp`, `validateSdp`, `parseCandidate`, `filterCandidates`) | Shipping | Zero-dep, isomorphic |
6581
+ | `WebRtcError` family (Signaling / Ice / Sdp / Turn / E2ee / Sfu) | Shipping | Stable string codes; flows through `$.onError` |
6582
+
6583
+
6584
+ ### Quick Start
6585
+
6586
+
6587
+ Join a room and render every remote peer's video in three steps:
6588
+
6589
+
6590
+
6591
+ ```javascript
6592
+ // 1) Get local media
6593
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
6594
+
6595
+ // 2) Join the room (opens signaling, completes handshake)
6596
+ const room = await $.webrtc.join('wss://api.example.com/rtc', {
6597
+ room: 'lobby',
6598
+ user: { id: 'me' },
6599
+ tracks: stream.getTracks(),
6600
+ });
6601
+
6602
+ // 3) Render each remote peer with the z-stream directive
6603
+ $.component('video-room', {
6604
+ state: () => ({ peers: [] }),
6605
+ mounted() {
6606
+ this._unsub = room.peers.subscribe((map) => {
6607
+ this.state.peers = [...map.values()];
6608
+ });
6609
+ },
6610
+ destroyed() { this._unsub?.(); room.leave(); },
6611
+ render() {
6612
+ return \`
6613
+ <video z-stream="local" autoplay muted playsinline></video>
6614
+ <div class="tiles">
6615
+ <video z-for="p in peers" z-stream="p.stream" autoplay playsinline></video>
6616
+ </div>
6617
+ \`;
6618
+ },
6619
+ });
6620
+ ```
6621
+
6622
+
6623
+ > **Tip:** `$.webrtc.join()` is the one-liner that bundles `new SignalingClient(…)` + `connect()` + `send('join')` + `Room` wiring. Use it whenever you don't need raw control of the JSEP loop.
6624
+
6625
+
6626
+ ### SignalingClient
6627
+
6628
+
6629
+ Lightweight WebSocket client that handles connect, exponential-backoff reconnect, the server's initial `hello` handshake, and outbound ICE coalescing (so trickle bursts don't trip the server's per-peer rate cap).
6630
+
6631
+
6632
+
6633
+ ```javascript
6634
+ import { SignalingClient } from 'zero-query';
6635
+
6636
+ const client = new SignalingClient('wss://api.example.com/rtc', {
6637
+ // Exponential backoff between reconnect attempts (defaults shown)
6638
+ reconnect: { baseMs: 250, capMs: 8000, maxRetries: 10 },
6639
+ // ICE coalescing window: at most 10 frames per 200ms
6640
+ iceFlushMs: 200,
6641
+ iceBatch: 10,
6642
+ });
6643
+
6644
+ client.on('hello', ({ peerId }) => console.log('I am', peerId));
6645
+ client.on('joined', ({ room, peers }) => console.log(room, peers));
6646
+ client.on('peer-joined', ({ id }) => console.log('peer-joined', id));
6647
+ client.on('offer', ({ from, sdp }) => /* accept + answer */);
6648
+ client.on('ice', ({ from, candidate }) => /* addIceCandidate */);
6649
+ client.on('error', (err) => console.warn(err.code, err.message));
6650
+
6651
+ await client.connect();
6652
+ client.send('join', { room: 'lobby' });
6653
+ // trickle - auto-batched into safe-rate windows
6654
+ client.send('ice', { to: 'peer-x', candidate: '...' });
6655
+ client.close();
6656
+ ```
6657
+
6658
+
6659
+ | Member | Type | Description |
6660
+ | --- | --- | --- |
6661
+ | `new SignalingClient(url, opts?)` | | Construct (does not open the socket) |
6662
+ | `.connect()` | `Promise` | Open the socket; resolves on first `open` |
6663
+ | `.send(type, payload?)` | `void` | Send a frame; `ice` frames are coalesced |
6664
+ | `.on(type, cb)` | `() => void` | Subscribe to a server frame type or lifecycle event (`open`, `close`, `reconnect`, `error`) |
6665
+ | `.off(type, cb)` | `void` | Unsubscribe |
6666
+ | `.close()` | `void` | Send `bye` and stop reconnecting |
6667
+ | `.peerId` | `string \| null` | Server-assigned peer id (set after first `hello`) |
6668
+ | `.connected` | `boolean` | `true` while the underlying WebSocket is open |
6669
+
6670
+
6671
+ > All signaling errors derive from `SignalingError`: `ZQ_WEBRTC_SIGNALING_BAD_URL`, `ZQ_WEBRTC_SIGNALING_BAD_HANDSHAKE`, `ZQ_WEBRTC_SIGNALING_BAD_FRAME`, `ZQ_WEBRTC_SIGNALING_NOT_CONNECTED`, `ZQ_WEBRTC_SIGNALING_CLOSED`.
6672
+
6673
+
6674
+ ### Peer (Perfect Negotiation)
6675
+
6676
+
6677
+ The `Peer` class wraps a single `RTCPeerConnection` and routes JSEP messages through a shared `SignalingClient` for one remote peer. It implements the W3C **perfect-negotiation** pattern, so simultaneous `negotiationneeded` events on both ends resolve deterministically based on the locally-assigned `polite` flag — no glare, no manual rollback.
6678
+
6679
+
6680
+
6681
+ ```javascript
6682
+ import { SignalingClient, Peer } from 'zero-query';
6683
+
6684
+ const signaling = new SignalingClient('wss://api.example.com/rtc');
6685
+ await signaling.connect();
6686
+ signaling.send('join', { room: 'lobby' });
6687
+
6688
+ signaling.on('peer-joined', ({ id }) => {
6689
+ // Decide polite/impolite however you like - lexicographic id is fine
6690
+ const polite = signaling.peerId < id;
6691
+ const peer = new Peer(id, signaling, {
6692
+ polite,
6693
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
6694
+ });
6695
+
6696
+ peer.on('track', (ev) => attachToVideoEl(ev.streams[0]));
6697
+ peer.on('connectionstatechange', (state) => console.log(state));
6698
+ peer.on('error', (err) => console.warn(err.code, err.message));
6699
+
6700
+ // Push local media
6701
+ for (const track of localStream.getTracks()) {
6702
+ peer.addTrack(track, localStream);
6703
+ }
6704
+ });
6705
+
6706
+ signaling.on('peer-left', ({ id }) => peers.get(id)?.close());
6707
+ ```
6708
+
6709
+
6710
+ | Member | Type | Description |
6711
+ | --- | --- | --- |
6712
+ | `new Peer(peerId, signaling, opts?)` | | Construct (creates the underlying `RTCPeerConnection` eagerly) |
6713
+ | `opts.polite` | `boolean` | Perfect-negotiation polite flag. Polite peers yield on collision |
6714
+ | `opts.iceServers` | `RTCIceServer[]` | STUN/TURN servers forwarded to `RTCPeerConnection` |
6715
+ | `opts.maxIceCandidates` | `number` | Hard cap on trickled candidates per peer (default `30`, matching server SDP cap) |
6716
+ | `.addTrack(track, ...streams)` | `RTCRtpSender` | Add a local track. Triggers `negotiationneeded` |
6717
+ | `.createDataChannel(label, init?)` | `RTCDataChannel` | Open a data channel; remote peer observes a `datachannel` event |
6718
+ | `.restartIce()` | `void` | Force ICE restart (also fires automatically on `connectionState = "failed"`) |
6719
+ | `.on(event, cb)` | `() => void` | Subscribe to `track` / `datachannel` / `connectionstatechange` / `close` / `error` |
6720
+ | `.close()` | `void` | Close the underlying connection. Idempotent |
6721
+ | `.pc` | `RTCPeerConnection` | Escape hatch for direct access (stats, sender params, etc.) |
6722
+
6723
+
6724
+ > **Tip:** mDNS (`*.local`) candidates are filtered before send, and trickled candidates are capped per-peer so we stay inside the server's `a=candidate:` ceiling.
6725
+
6726
+
6727
+ ### Room
6728
+
6729
+
6730
+ A `Room` is the multi-peer container above `SignalingClient` + `Peer`. It tracks every remote peer in a reactive `Signal`, fans out local tracks via `publish()` / `unpublish()`, and multiplexes named data channels across every connected peer. `$.webrtc.join(url, opts)` opens the signaling socket, waits for `hello`, sends `join`, and resolves once the server returns `joined`.
6731
+
6732
+
6733
+
6734
+ ```javascript
6735
+ import { join } from 'zero-query';
6736
+
6737
+ const room = await $.webrtc.join('wss://api.example.com/rtc', {
6738
+ room: 'lobby',
6739
+ media: { audio: true, video: true }, // calls getUserMedia automatically
6740
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
6741
+ });
6742
+
6743
+ room.on('peer-joined', (info) => console.log('joined', info.id));
6744
+ room.on('peer-left', (info) => console.log('left', info.id));
6745
+ room.on('error', (err) => console.warn(err.code, err.message));
6746
+
6747
+ // Publish a stream later (e.g. after a screen-share button):
6748
+ const screen = await navigator.mediaDevices.getDisplayMedia();
6749
+ await room.publish(screen);
6750
+
6751
+ // Open a chat channel that every peer joins:
6752
+ const chat = room.dataChannel('chat');
6753
+ chat.on('message', (text, fromPeerId) => console.log(fromPeerId, text));
6754
+ chat.send('hello room');
6755
+
6756
+ await room.leave();
6757
+ ```
6758
+
6759
+
6760
+ | Member | Type | Description |
6761
+ | --- | --- | --- |
6762
+ | `room.id` | `string` | Room identifier passed to `join` |
6763
+ | `room.self` | `string` | Local peer id assigned by the server |
6764
+ | `room.peers` | `Signal>` | Reactive map of every remote peer in the room |
6765
+ | `room.localTracks` | `Signal` | Reactive snapshot of currently-published local tracks |
6766
+ | `room.publish(stream)` | `Promise` | Add every track in the stream to every peer |
6767
+ | `room.unpublish(stream)` | `Promise` | Remove every previously-published track in the stream |
6768
+ | `room.dataChannel(label, opts?)` | `RoomDataChannel` | Multiplexed handle `{ label, send, on, close }`. Same label returns the same handle |
6769
+ | `room.on(event, cb)` | `() => void` | Subscribe to `peer-joined` / `peer-left` / `mute` / `unmute` / `error` |
6770
+ | `room.leave()` | `Promise` | Send `leave`, close every peer and the socket. Idempotent |
6771
+
6772
+
6773
+ ### Reactive Composables
6774
+
6775
+
6776
+ Each composable returns a disposable reactive handle — `{ value, peek, subscribe(cb), dispose() }` — that you can render with `z-text` / `z-html` or wire into component re-renders. Call `dispose()` in your unmount path so listeners and intervals are torn down.
6777
+
6778
+
6779
+
6780
+ ```javascript
6781
+ import { useRoom, usePeer, useTracks, useDataChannel, useConnectionQuality } from 'zero-query';
6782
+
6783
+ const room = await $.useRoom('wss://api.example.com/rtc', { room: 'lobby' });
6784
+
6785
+ // Track a specific remote peer reactively:
6786
+ const peerHandle = $.usePeer(room, 'peer-abc');
6787
+ peerHandle.subscribe((info) => {
6788
+ if (!info) return console.log('peer gone');
6789
+ console.log('connection:', info.connection);
6790
+ });
6791
+
6792
+ // Live track list for that peer (re-emits on addtrack/removetrack):
6793
+ const tracksHandle = $.useTracks(peerHandle.peek());
6794
+
6795
+ // A chat channel with bounded message history:
6796
+ const chat = $.useDataChannel(room, 'chat', { history: 200 });
6797
+ chat.send('hi');
6798
+ chat.messages.subscribe((entries) => render(entries)); // [{data, from, at}]
6799
+
6800
+ // Sampled connection-quality bucket from RTCPeerConnection.getStats():
6801
+ const quality = $.useConnectionQuality(peerHandle.peek(), { intervalMs: 2000 });
6802
+ quality.subscribe((bucket) => console.log(bucket)); // 'good' | 'fair' | 'poor'
6803
+
6804
+ // On unmount:
6805
+ peerHandle.dispose();
6806
+ tracksHandle.dispose();
6807
+ chat.dispose();
6808
+ quality.dispose();
6809
+ ```
6810
+
6811
+
6812
+ | Composable | Returns |
6813
+ | --- | --- |
6814
+ | `useRoom(urlOrRoom, opts?)` | `Promise` — resolves an existing Room or calls `join` |
6815
+ | `usePeer(room, peerId)` | `{ value: PeerInfo \| null, peek, subscribe, dispose }` |
6816
+ | `useTracks(peerInfo)` | `{ value: MediaStreamTrack[], refresh, peek, subscribe, dispose }` |
6817
+ | `useDataChannel(room, label, { history? })` | `{ messages, send, close, dispose }` |
6818
+ | `useConnectionQuality(peerInfo, { intervalMs?, getStats? })` | `{ value: "good" \| "fair" \| "poor", peek, subscribe, dispose }` |
6819
+
6820
+
6821
+ > **Warning:** Forgetting `dispose()` on unmount leaks getStats intervals and signal subscriptions — `useConnectionQuality` in particular keeps a 2-second timer alive until disposed.
6822
+
6823
+
6824
+ ### z-stream Directive
6825
+
6826
+
6827
+ Bind a reactive `MediaStream` (or any object exposing `getTracks()`) to a `` or `` element by setting `srcObject` directly — no `URL.createObjectURL` dance. SSR-safe and writes `null` when the expression is nullish.
6828
+
6829
+
6830
+
6831
+ ```javascript
6832
+ import { component } from 'zero-query';
6833
+
6834
+ component('peer-video', {
6835
+ state: { stream: null },
6836
+ template: () => \`<video z-stream="stream" autoplay playsinline muted></video>\`,
6837
+ });
6838
+
6839
+ // Anywhere you have a PeerInfo (from room.peers or usePeer):
6840
+ const tile = $('peer-video').first();
6841
+ tile.instance.state.stream = peerInfo.stream;
6842
+ ```
6843
+
6844
+
6845
+ > **Tip:** `z-stream` tolerates plain `MediaStream`s, anything with `.stream`, or any object that exposes `getTracks()`. Reassign the expression to `null` to detach without removing the element.
6846
+
6847
+
6848
+ ### TURN Credentials
6849
+
6850
+
6851
+ The backend's `issueTurnCredentials()` handler returns short-lived `{ username, credential, urls, ttl }` bundles. `fetchTurnCredentials()` normalizes the response and validates its shape; `mergeIceServers()` concatenates the TURN entry with your existing STUN list (deduping any overlapping URLs); `createTurnRefresher()` schedules an automatic refetch ahead of expiry so a long-running room never sees a 401 mid-call. Any failure surfaces as a typed `TurnError`.
6852
+
6853
+
6854
+
6855
+ ```javascript
6856
+ import { fetchTurnCredentials, mergeIceServers, createTurnRefresher } from 'zero-query';
6857
+
6858
+ // One-shot fetch (e.g. right before $.webrtc.join):
6859
+ const creds = await $.fetchTurnCredentials('/webrtc/turn-credentials');
6860
+ const iceServers = $.mergeIceServers(
6861
+ [{ urls: 'stun:stun.l.google.com:19302' }],
6862
+ creds
6863
+ );
6864
+
6865
+ const room = await $.webrtc.join('wss://api.example.com/rtc', {
6866
+ room: 'lobby',
6867
+ iceServers,
6868
+ });
6869
+
6870
+ // Long-lived refresher (re-fetches ~30s before ttl expires):
6871
+ const turn = $.createTurnRefresher({
6872
+ url: '/webrtc/turn-credentials',
6873
+ leadMs: 30000,
6874
+ onRefresh: (next) => console.log('TURN rotated', next.ttl),
6875
+ onError: (err) => console.warn('TURN refresh failed', err.code),
6876
+ });
6877
+ await turn.start();
6878
+ console.log(turn.value); // latest credentials
6879
+ turn.stop(); // cancel timer on unmount
6880
+ ```
6881
+
6882
+
6883
+ | Helper | Returns |
6884
+ | --- | --- |
6885
+ | `fetchTurnCredentials(url, { fetch?, ...RequestInit })` | `Promise` |
6886
+ | `mergeIceServers(base?, turn?)` | `RTCIceServer[]` — base entries first, TURN appended, dup URLs dropped |
6887
+ | `createTurnRefresher({ url, fetch?, leadMs?, minIntervalMs?, onRefresh?, onError?, requestInit? })` | `{ value, peek, start, refresh, stop }` |
6888
+
6889
+
6890
+ > All TURN errors derive from `TurnError`: `ZQ_WEBRTC_TURN_BAD_URL`, `ZQ_WEBRTC_TURN_NO_FETCH`, `ZQ_WEBRTC_TURN_NETWORK`, `ZQ_WEBRTC_TURN_HTTP`, `ZQ_WEBRTC_TURN_BAD_JSON`, `ZQ_WEBRTC_TURN_BAD_BODY`.
6891
+
6892
+
6893
+ ### End-to-End Encryption (SFrame)
6894
+
6895
+
6896
+ Optional per-frame AES-GCM-128 encryption that runs after the encoder and before the decoder via `RTCRtpSender.createEncodedStreams()`. Keys are bound to a 1-byte epoch so a room can rotate without dropping in-flight frames; up to `maxEpochs` (default 4) past keys are retained for late decryptors. Each frame is laid out as `[1-byte epoch][12-byte IV][ciphertext + GCM tag]`.
6897
+
6898
+
6899
+
6900
+ ```javascript
6901
+ import { deriveSFrameKey, SFrameContext, attachE2ee, join } from 'zero-query/webrtc';
6902
+
6903
+ // 1) Derive a key everyone in the room shares (passphrase out-of-band).
6904
+ const key = await deriveSFrameKey('correct horse battery', 'room-42');
6905
+
6906
+ // 2) Track it under epoch 0.
6907
+ const ctx = new SFrameContext();
6908
+ ctx.setKey(0, key);
6909
+
6910
+ // 3) Join, then wire transforms onto every sender + receiver.
6911
+ const room = await join('wss://example.com/ws', { room: 'room-42', /* ... */ });
6912
+ room.on('peer', (peer) => {
6913
+ const handle = attachE2ee(peer.pc, ctx);
6914
+ peer.on('track', () => handle.refresh()); // re-walk for late tracks
6915
+ });
6916
+
6917
+ // 4) Rotate by installing a new key under the next epoch.
6918
+ ctx.setKey(1, await deriveSFrameKey('correct horse battery v2', 'room-42'));
6919
+ ```
6920
+
6921
+
6922
+ | Helper | Result |
6923
+ | --- | --- |
6924
+ | `deriveSFrameKey(passphrase, salt)` | `Promise` — PBKDF2-SHA256 (100k) → HKDF-SHA256 → AES-GCM-128 |
6925
+ | `generateSFrameKey()` | `Promise` — random AES-GCM-128 |
6926
+ | `new SFrameContext({ maxEpochs? })` | Tracks `epoch → key`; `setKey(epoch, key)` advances `currentEpoch` |
6927
+ | `encryptFrame(ctx, payload)` | `Promise` — `[epoch][iv][cipher+tag]` |
6928
+ | `decryptFrame(ctx, frame)` | `Promise` — reads epoch byte and decrypts with matching key |
6929
+ | `attachE2ee(pc, ctx)` | `{ refresh(), detach() }` — installs transforms on every sender + receiver |
6930
+
6931
+
6932
+ > **Warning:** SFrame requires `RTCRtpSender.createEncodedStreams()` (Insertable Streams). Browsers without it throw `ZQ_WEBRTC_E2EE_NO_WEBCRYPTO` or fail silently on `attachE2ee` — feature-detect before promising E2EE to users.
6933
+
6934
+
6935
+ > All E2EE errors derive from `E2eeError`: `ZQ_WEBRTC_E2EE_NO_WEBCRYPTO`, `ZQ_WEBRTC_E2EE_NO_RANDOM`, `ZQ_WEBRTC_E2EE_BAD_PASSPHRASE`, `ZQ_WEBRTC_E2EE_BAD_SALT`, `ZQ_WEBRTC_E2EE_BAD_INPUT`, `ZQ_WEBRTC_E2EE_BAD_CTX`, `ZQ_WEBRTC_E2EE_NO_KEY`, `ZQ_WEBRTC_E2EE_SHORT_FRAME`, `ZQ_WEBRTC_E2EE_UNKNOWN_EPOCH`, `ZQ_WEBRTC_E2EE_AUTH_FAILED`.
6936
+
6937
+
6938
+ ### SFU Adapters
6939
+
6940
+
6941
+ `loadSfuAdapter(name, opts?)` dynamic-imports an optional peer dependency and returns an adapter wrapping its native client. Both **mediasoup** and **LiveKit** adapters are shipped today. The peer dependencies are *not* bundled — consuming apps install them themselves; if missing, the adapter throws `ZQ_WEBRTC_SFU_PEER_MISSING` with an actionable message.
6942
+
6943
+
6944
+ | Adapter | Peer dep | Install |
6945
+ | --- | --- | --- |
6946
+ | `'mediasoup'` | `mediasoup-client` | `npm i mediasoup-client` |
6947
+ | `'livekit'` | `livekit-client` | `npm i livekit-client` |
6948
+
6949
+
6950
+
6951
+ ```javascript
6952
+ import { loadSfuAdapter } from 'zero-query/webrtc';
6953
+
6954
+ // mediasoup adapter:
6955
+ const sfu = await loadSfuAdapter('mediasoup');
6956
+ await sfu.load(routerRtpCapabilities); // from your SFU signaling
6957
+ if (sfu.canProduce('audio')) {
6958
+ const sendTransport = sfu.createSendTransport(sendTransportParams);
6959
+ // wire transport.on('connect', ...) / on('produce', ...) to your signaling
6960
+ }
6961
+ const recvTransport = sfu.createRecvTransport(recvTransportParams);
6962
+
6963
+ // LiveKit adapter:
6964
+ const lk = await loadSfuAdapter('livekit');
6965
+ await lk.connect('wss://lk.example', accessToken);
6966
+ // ... use lk.room directly (livekit-client Room) ...
6967
+ await lk.disconnect();
6968
+ ```
6969
+
6970
+
6971
+ > All SFU errors derive from `SfuError`: `ZQ_WEBRTC_SFU_UNKNOWN`, `ZQ_WEBRTC_SFU_PEER_MISSING`, `ZQ_WEBRTC_SFU_BAD_MODULE`, `ZQ_WEBRTC_SFU_DEVICE_FAILED`, `ZQ_WEBRTC_SFU_ROOM_FAILED`, `ZQ_WEBRTC_SFU_BAD_RTP_CAPS`, `ZQ_WEBRTC_SFU_BAD_URL`, `ZQ_WEBRTC_SFU_BAD_TOKEN`, `ZQ_WEBRTC_SFU_LOAD_FAILED`, `ZQ_WEBRTC_SFU_CONNECT_FAILED`, `ZQ_WEBRTC_SFU_NOT_LOADED`, `ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE`.
6972
+
6973
+
6974
+ ### Join Tokens
6975
+
6976
+
6977
+ `decodeJoinToken(token)` is a UX-only helper that base64url-decodes the payload of a server-issued join token (as minted by `signJoinToken({ secret, user, room, exp })` in `@zero-server/webrtc`). The client **never trusts** the payload — the server re-validates the signature on every `join` — but the decoded fields are useful for UI like "expires in 5 minutes" or showing the user's display name before sending. Tokens may be 1-, 2-, or 3-segment (JWT-like).
6978
+
6979
+
6980
+
6981
+ ```javascript
6982
+ import { decodeJoinToken, isJoinTokenExpired } from 'zero-query';
6983
+
6984
+ const t = decodeJoinToken(tokenFromServer);
6985
+ // { user: { id: 'u1', name: 'Ada' }, room: 'lobby', exp: 1700000000, raw: {...} }
6986
+
6987
+ if (isJoinTokenExpired(t, { skewMs: 30_000 })) {
6988
+ // refresh token before calling `join`
6989
+ }
6990
+ ```
6991
+
6992
+
6993
+ | Helper | Returns |
6994
+ | --- | --- |
6995
+ | `decodeJoinToken(token)` | `{ user, room, exp, raw }` |
6996
+ | `isJoinTokenExpired(decoded, { nowMs?, skewMs? })` | `boolean` |
6997
+
6998
+
6999
+ > **Warning:** `decodeJoinToken` performs **no** signature verification — it is a UI helper only. Never gate auth or permissions on its output; the server is the source of truth.
7000
+
7001
+
7002
+ > Errors derive from `WebRtcError`: `ZQ_WEBRTC_TOKEN_BAD_INPUT`, `ZQ_WEBRTC_TOKEN_BAD_SHAPE`, `ZQ_WEBRTC_TOKEN_BAD_PAYLOAD`.
7003
+
7004
+
7005
+ ### Observability (getStats)
7006
+
7007
+
7008
+ Low-level `RTCPeerConnection.getStats()` helpers — useful for dashboards, logging, and feeding the reactive `useConnectionQuality` composable. `samplePeerStats(pc)` reduces a getStats report to a flat summary plus the raw arrays. `createStatsSampler(pc, opts)` polls on an interval. `classifyStats(sample)` buckets a sample into `'good' | 'fair' | 'poor' | 'unknown'`.
7009
+
7010
+
7011
+
7012
+ ```javascript
7013
+ import { samplePeerStats, createStatsSampler, classifyStats } from 'zero-query';
7014
+
7015
+ // One-shot snapshot:
7016
+ const s = await samplePeerStats(pc);
7017
+ console.log(s.summary, classifyStats(s));
7018
+ // { rttMs: 42, lossPct: 0.3, bytesSent: 18234, bytesReceived: 30210 } 'good'
7019
+
7020
+ // Periodic sampler:
7021
+ const sampler = createStatsSampler(pc, {
7022
+ intervalMs: 2000,
7023
+ onSample: (s) => dashboardSignal.value = s.summary,
7024
+ onError: (err) => console.warn('stats failed', err.code, err.message),
7025
+ });
7026
+ // ... later
7027
+ sampler.stop();
7028
+ ```
7029
+
7030
+
7031
+ | Bucket | Heuristic |
7032
+ | --- | --- |
7033
+ | `'good'` | Loss &le; 1% *and* RTT &le; 200 ms |
7034
+ | `'fair'` | Loss &le; 5% *and* RTT &le; 400 ms |
7035
+ | `'poor'` | Loss > 5% *or* RTT > 400 ms |
7036
+ | `'unknown'` | No `candidate-pair` in the report yet (early/closed connection) |
7037
+
7038
+
7039
+ > Errors derive from `WebRtcError`: `ZQ_WEBRTC_OBSERVE_BAD_PC`, `ZQ_WEBRTC_OBSERVE_GETSTATS_FAILED`.
7040
+
7041
+
7042
+ ### SDP + ICE Helpers
7043
+
7044
+
7045
+ Zero-dependency, read-only ports of the parser surface from `@zero-server/webrtc`. Useful for sanity-checking an SDP before sending it through signaling, lifting structured fields out for stats / debugging, and filtering ICE candidates against a local privacy policy. All errors derive from `SdpError` / `IceError`.
7046
+
7047
+
7048
+
7049
+ ```javascript
7050
+ import {
7051
+ parseSdp, validateSdp,
7052
+ parseCandidate, filterCandidates, isMdnsHostname,
7053
+ } from 'zero-query';
7054
+
7055
+ // Parse + validate the SDP server-side rules apply (UDP/TLS/RTP/SAVPF,
7056
+ // ice-ufrag, ice-pwd, fingerprint). Throws SdpError if any required
7057
+ // attribute is missing on a non-rejected m-line.
7058
+ const desc = validateSdp(offer.sdp);
7059
+ console.log(desc.media[0].iceUfrag, desc.media[0].fingerprint);
7060
+
7061
+ // Filter a raw candidate batch against a local privacy policy:
7062
+ const safe = filterCandidates(offer.candidates, {
7063
+ blockPrivate: true,
7064
+ blockMdns: true,
7065
+ allowedTypes: ['srflx', 'relay'],
7066
+ maxCandidates: 30,
7067
+ });
7068
+
7069
+ // Parse a single candidate line:
7070
+ const c = parseCandidate('candidate:1 1 udp 2122194687 1.2.3.4 50001 typ srflx raddr 192.168.1.5 rport 50000');
7071
+ if (c.type === 'relay') console.log('via TURN');
7072
+ if (isMdnsHostname(c.address)) console.log('mDNS - skip');
7073
+ ```
7074
+
7075
+
7076
+ | Helper | Returns | Throws |
7077
+ | --- | --- | --- |
7078
+ | `parseSdp(text, opts?)` | `ParsedSdp` | SdpError |
7079
+ | `validateSdp(text)` | `ParsedSdp` | SdpError |
7080
+ | `parseCandidate(line)` | `IceCandidate` | IceError |
7081
+ | `stringifyCandidate(c)` | `string` | IceError |
7082
+ | `filterCandidates(list, policy?)` | same shape as input | — |
7083
+ | `isPrivateIp / isLoopbackIp / isLinkLocalIp / isMdnsHostname` | `boolean` | — |
7084
+
7085
+
7086
+ ### Error Family
7087
+
7088
+
7089
+ Every WebRTC error derives from `WebRtcError`, which itself derives from the shared `ZQueryError`. They participate in `$.onError(handler)` like any other library error and carry a stable `code` string.
7090
+
7091
+
7092
+ | Class | Default Code | Surface |
7093
+ | --- | --- | --- |
7094
+ | `WebRtcError` | `ZQ_WEBRTC` | Base — token decode, getStats |
7095
+ | `SignalingError` | `ZQ_WEBRTC_SIGNALING` | WebSocket / handshake / framing |
7096
+ | `IceError` | `ZQ_WEBRTC_ICE` | Candidate parsing / validation |
7097
+ | `SdpError` | `ZQ_WEBRTC_SDP` | SDP parsing / validation |
7098
+ | `TurnError` | `ZQ_WEBRTC_TURN` | TURN credential fetch / refresh |
7099
+ | `E2eeError` | `ZQ_WEBRTC_E2EE` | SFrame key + frame transform |
7100
+ | `SfuError` | `ZQ_WEBRTC_SFU` | SFU adapter / peer-dep loader |
7101
+
7102
+
7103
+
7104
+ ```javascript
7105
+ import { WebRtcError, SignalingError } from 'zero-query';
7106
+
7107
+ try {
7108
+ await client.connect();
7109
+ } catch (err) {
7110
+ if (err instanceof SignalingError) {
7111
+ console.warn('signaling failed:', err.code, err.message);
7112
+ } else if (err instanceof WebRtcError) {
7113
+ console.warn('webrtc failed:', err.code, err.message);
7114
+ } else {
7115
+ throw err;
7116
+ }
7117
+ }
7118
+ ```
7119
+
7120
+
7121
+ > **Tip:** Use `$.onError(handler)` to centralize WebRTC error reporting alongside HTTP / router / component errors. Every WebRTC error code starts with `ZQ_WEBRTC_` so a single prefix check picks them all out.
7122
+
7123
+
7124
+ ### Wire Protocol
7125
+
7126
+
7127
+ The `SignalingClient` speaks the JSON-over-WebSocket protocol of `@zero-server/webrtc`. The first server message after a successful connect is always `{ type: 'hello', peerId }`; everything else is type-tagged.
7128
+
7129
+
7130
+ | Direction | Frame types |
7131
+ | --- | --- |
7132
+ | **Client → Server** | `join`, `leave`, `offer`, `answer`, `ice`, `mute`, `unmute`, `bye`, `e2ee-key` |
7133
+ | **Server → Client** | `hello`, `joined`, `peer-joined`, `peer-left`, `offer`, `answer`, `ice`, `mute`, `unmute`, `e2ee-key`, `error` |
7134
+
7135
+
7136
+ > Frames missing the required `type` field, the initial `hello`, or with malformed JSON raise a `SignalingError` on the `error` event — matching the server's own validation contract.
7137
+
7138
+ ---
7139
+
6520
7140
  ## ES Module Exports (for npm/bundler usage)
6521
7141
 
6522
7142
  When used as an ES module (not the built bundle), the library provides named exports for every public API:
@@ -6560,6 +7180,47 @@ import {
6560
7180
  guardAsync,
6561
7181
  validate,
6562
7182
  formatError,
7183
+ webrtc,
7184
+ SignalingClient,
7185
+ Peer,
7186
+ Room,
7187
+ webrtcJoin,
7188
+ useRoom,
7189
+ usePeer,
7190
+ useTracks,
7191
+ useDataChannel,
7192
+ useConnectionQuality,
7193
+ fetchTurnCredentials,
7194
+ mergeIceServers,
7195
+ createTurnRefresher,
7196
+ deriveSFrameKey,
7197
+ generateSFrameKey,
7198
+ SFrameContext,
7199
+ encryptFrame,
7200
+ decryptFrame,
7201
+ attachE2ee,
7202
+ loadSfuAdapter,
7203
+ SfuError,
7204
+ decodeJoinToken,
7205
+ isJoinTokenExpired,
7206
+ samplePeerStats,
7207
+ createStatsSampler,
7208
+ classifyStats,
7209
+ parseSdp,
7210
+ validateSdp,
7211
+ parseCandidate,
7212
+ stringifyCandidate,
7213
+ filterCandidates,
7214
+ isPrivateIp,
7215
+ isLoopbackIp,
7216
+ isLinkLocalIp,
7217
+ isMdnsHostname,
7218
+ WebRtcError,
7219
+ SignalingError,
7220
+ IceError,
7221
+ SdpError,
7222
+ TurnError,
7223
+ E2eeError,
6563
7224
  debounce,
6564
7225
  throttle,
6565
7226
  pipe,
Binary file