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,283 @@
1
+ /**
2
+ * tests/webrtc/e2ee.test.js
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import {
7
+ deriveSFrameKey,
8
+ generateSFrameKey,
9
+ SFrameContext,
10
+ encryptFrame,
11
+ decryptFrame,
12
+ attachE2ee,
13
+ E2eeError,
14
+ } from '../../src/webrtc/index.js';
15
+
16
+
17
+ const enc = new TextEncoder();
18
+ const dec = new TextDecoder();
19
+
20
+
21
+ describe('deriveSFrameKey', () => {
22
+ it('derives a deterministic key from the same passphrase + salt', async () => {
23
+ const k1 = await deriveSFrameKey('correct horse battery', 'room-a');
24
+ const k2 = await deriveSFrameKey('correct horse battery', 'room-a');
25
+ // CryptoKey identity differs but encrypt/decrypt with one should
26
+ // decrypt with the other (same underlying material).
27
+ const ctx1 = new SFrameContext(); ctx1.setKey(0, k1);
28
+ const ctx2 = new SFrameContext(); ctx2.setKey(0, k2);
29
+ const enc1 = await encryptFrame(ctx1, enc.encode('hello'));
30
+ const out = await decryptFrame(ctx2, enc1);
31
+ expect(dec.decode(out)).toBe('hello');
32
+ });
33
+
34
+ it('different passphrases produce non-interoperable keys', async () => {
35
+ const k1 = await deriveSFrameKey('one', 'room-a');
36
+ const k2 = await deriveSFrameKey('two', 'room-a');
37
+ const ctx1 = new SFrameContext(); ctx1.setKey(0, k1);
38
+ const ctx2 = new SFrameContext(); ctx2.setKey(0, k2);
39
+ const frame = await encryptFrame(ctx1, enc.encode('x'));
40
+ await expect(decryptFrame(ctx2, frame)).rejects.toMatchObject({
41
+ code: 'ZQ_WEBRTC_E2EE_AUTH_FAILED',
42
+ });
43
+ });
44
+
45
+ it('different salts produce non-interoperable keys', async () => {
46
+ const k1 = await deriveSFrameKey('pw', 'room-a');
47
+ const k2 = await deriveSFrameKey('pw', 'room-b');
48
+ const ctx1 = new SFrameContext(); ctx1.setKey(0, k1);
49
+ const ctx2 = new SFrameContext(); ctx2.setKey(0, k2);
50
+ const frame = await encryptFrame(ctx1, enc.encode('x'));
51
+ await expect(decryptFrame(ctx2, frame)).rejects.toMatchObject({
52
+ code: 'ZQ_WEBRTC_E2EE_AUTH_FAILED',
53
+ });
54
+ });
55
+
56
+ it('rejects empty passphrase / salt', async () => {
57
+ await expect(deriveSFrameKey('', 's')).rejects.toMatchObject({ code: 'ZQ_WEBRTC_E2EE_BAD_PASSPHRASE' });
58
+ await expect(deriveSFrameKey('p', '')).rejects.toMatchObject({ code: 'ZQ_WEBRTC_E2EE_BAD_SALT' });
59
+ });
60
+ });
61
+
62
+
63
+ describe('SFrameContext', () => {
64
+ it('rejects invalid epochs', async () => {
65
+ const ctx = new SFrameContext();
66
+ const key = await generateSFrameKey();
67
+ expect(() => ctx.setKey(-1, key)).toThrow(/epoch/);
68
+ expect(() => ctx.setKey(256, key)).toThrow(/epoch/);
69
+ expect(() => ctx.setKey(1.5, key)).toThrow(/epoch/);
70
+ });
71
+
72
+ it('rejects setKey without a key', async () => {
73
+ const ctx = new SFrameContext();
74
+ expect(() => ctx.setKey(0, null)).toThrow(/key required/);
75
+ });
76
+
77
+ it('tracks current epoch on every setKey', async () => {
78
+ const ctx = new SFrameContext();
79
+ ctx.setKey(0, await generateSFrameKey());
80
+ expect(ctx.currentEpoch).toBe(0);
81
+ ctx.setKey(7, await generateSFrameKey());
82
+ expect(ctx.currentEpoch).toBe(7);
83
+ });
84
+
85
+ it('evicts oldest epoch beyond maxEpochs', async () => {
86
+ const ctx = new SFrameContext({ maxEpochs: 2 });
87
+ ctx.setKey(0, await generateSFrameKey());
88
+ ctx.setKey(1, await generateSFrameKey());
89
+ ctx.setKey(2, await generateSFrameKey());
90
+ expect(ctx.epochCount).toBe(2);
91
+ expect(ctx.getKey(0)).toBeNull();
92
+ expect(ctx.getKey(1)).not.toBeNull();
93
+ expect(ctx.getKey(2)).not.toBeNull();
94
+ });
95
+
96
+ it('removeEpoch drops a tracked key', async () => {
97
+ const ctx = new SFrameContext();
98
+ ctx.setKey(3, await generateSFrameKey());
99
+ expect(ctx.getKey(3)).not.toBeNull();
100
+ ctx.removeEpoch(3);
101
+ expect(ctx.getKey(3)).toBeNull();
102
+ });
103
+ });
104
+
105
+
106
+ describe('encryptFrame / decryptFrame', () => {
107
+ it('round-trips an arbitrary payload', async () => {
108
+ const ctx = new SFrameContext();
109
+ ctx.setKey(0, await generateSFrameKey());
110
+ const payload = enc.encode('the quick brown fox');
111
+ const frame = await encryptFrame(ctx, payload);
112
+
113
+ // Header layout: 1-byte epoch + 12-byte IV
114
+ expect(frame[0]).toBe(0);
115
+ expect(frame.byteLength).toBe(13 + payload.byteLength + 16);
116
+
117
+ const plain = await decryptFrame(ctx, frame);
118
+ expect(dec.decode(plain)).toBe('the quick brown fox');
119
+ });
120
+
121
+ it('accepts ArrayBuffer payloads', async () => {
122
+ const ctx = new SFrameContext();
123
+ ctx.setKey(0, await generateSFrameKey());
124
+ const ab = enc.encode('hi').buffer;
125
+ const frame = await encryptFrame(ctx, ab);
126
+ const out = await decryptFrame(ctx, frame.buffer);
127
+ expect(dec.decode(out)).toBe('hi');
128
+ });
129
+
130
+ it('writes the current epoch into the frame header', async () => {
131
+ const ctx = new SFrameContext();
132
+ ctx.setKey(0, await generateSFrameKey());
133
+ ctx.setKey(5, await generateSFrameKey());
134
+ const frame = await encryptFrame(ctx, enc.encode('x'));
135
+ expect(frame[0]).toBe(5);
136
+ });
137
+
138
+ it('encryptFrame fails without a key for the current epoch', async () => {
139
+ const ctx = new SFrameContext();
140
+ await expect(encryptFrame(ctx, enc.encode('x'))).rejects.toMatchObject({
141
+ code: 'ZQ_WEBRTC_E2EE_NO_KEY',
142
+ });
143
+ });
144
+
145
+ it('decryptFrame fails on a too-short frame', async () => {
146
+ const ctx = new SFrameContext();
147
+ ctx.setKey(0, await generateSFrameKey());
148
+ await expect(decryptFrame(ctx, new Uint8Array(5))).rejects.toMatchObject({
149
+ code: 'ZQ_WEBRTC_E2EE_SHORT_FRAME',
150
+ });
151
+ });
152
+
153
+ it('decryptFrame fails when the epoch is unknown', async () => {
154
+ const enc1 = new SFrameContext();
155
+ enc1.setKey(2, await generateSFrameKey());
156
+ const frame = await encryptFrame(enc1, enc.encode('x'));
157
+
158
+ const dec1 = new SFrameContext();
159
+ dec1.setKey(7, await generateSFrameKey());
160
+ await expect(decryptFrame(dec1, frame)).rejects.toMatchObject({
161
+ code: 'ZQ_WEBRTC_E2EE_UNKNOWN_EPOCH',
162
+ context: { epoch: 2 },
163
+ });
164
+ });
165
+
166
+ it('decryptFrame fails when the key is wrong (AES-GCM auth fails)', async () => {
167
+ const ctx1 = new SFrameContext(); ctx1.setKey(0, await generateSFrameKey());
168
+ const ctx2 = new SFrameContext(); ctx2.setKey(0, await generateSFrameKey());
169
+ const frame = await encryptFrame(ctx1, enc.encode('x'));
170
+ await expect(decryptFrame(ctx2, frame)).rejects.toMatchObject({
171
+ code: 'ZQ_WEBRTC_E2EE_AUTH_FAILED',
172
+ });
173
+ });
174
+
175
+ it('decryptFrame fails when the ciphertext is tampered with', async () => {
176
+ const ctx = new SFrameContext();
177
+ ctx.setKey(0, await generateSFrameKey());
178
+ const frame = await encryptFrame(ctx, enc.encode('original'));
179
+ frame[frame.byteLength - 1] ^= 0x01;
180
+ await expect(decryptFrame(ctx, frame)).rejects.toMatchObject({
181
+ code: 'ZQ_WEBRTC_E2EE_AUTH_FAILED',
182
+ });
183
+ });
184
+
185
+ it('throws on non-BufferSource payloads', async () => {
186
+ const ctx = new SFrameContext();
187
+ ctx.setKey(0, await generateSFrameKey());
188
+ await expect(encryptFrame(ctx, 'not bytes')).rejects.toMatchObject({
189
+ code: 'ZQ_WEBRTC_E2EE_BAD_INPUT',
190
+ });
191
+ });
192
+
193
+ it('encrypt/decrypt require an SFrameContext instance', async () => {
194
+ await expect(encryptFrame({}, new Uint8Array(1))).rejects.toMatchObject({ code: 'ZQ_WEBRTC_E2EE_BAD_CTX' });
195
+ await expect(decryptFrame({}, new Uint8Array(20))).rejects.toMatchObject({ code: 'ZQ_WEBRTC_E2EE_BAD_CTX' });
196
+ });
197
+ });
198
+
199
+
200
+ describe('epoch rotation', () => {
201
+ it('decryptor with both epochs decodes frames from either', async () => {
202
+ const k0 = await generateSFrameKey();
203
+ const k1 = await generateSFrameKey();
204
+
205
+ const enc0 = new SFrameContext(); enc0.setKey(0, k0);
206
+ const enc1 = new SFrameContext(); enc1.setKey(1, k1);
207
+
208
+ const decoder = new SFrameContext();
209
+ decoder.setKey(0, k0);
210
+ decoder.setKey(1, k1);
211
+
212
+ const f0 = await encryptFrame(enc0, enc.encode('old'));
213
+ const f1 = await encryptFrame(enc1, enc.encode('new'));
214
+
215
+ expect(dec.decode(await decryptFrame(decoder, f0))).toBe('old');
216
+ expect(dec.decode(await decryptFrame(decoder, f1))).toBe('new');
217
+ });
218
+
219
+ it('after rotation, frames from the evicted epoch fail to decrypt', async () => {
220
+ const k0 = await generateSFrameKey();
221
+ const k1 = await generateSFrameKey();
222
+
223
+ const sender = new SFrameContext();
224
+ sender.setKey(0, k0);
225
+ const stale = await encryptFrame(sender, enc.encode('stale'));
226
+
227
+ const receiver = new SFrameContext({ maxEpochs: 1 });
228
+ receiver.setKey(0, k0);
229
+ receiver.setKey(1, k1); // evicts epoch 0
230
+
231
+ await expect(decryptFrame(receiver, stale)).rejects.toMatchObject({
232
+ code: 'ZQ_WEBRTC_E2EE_UNKNOWN_EPOCH',
233
+ });
234
+ });
235
+ });
236
+
237
+
238
+ describe('attachE2ee', () => {
239
+ it('rejects something that is not an RTCPeerConnection', () => {
240
+ const ctx = new SFrameContext();
241
+ expect(() => attachE2ee({}, ctx)).toThrow(/RTCPeerConnection/);
242
+ });
243
+
244
+ it('rejects a non-SFrameContext', () => {
245
+ const fakePc = { getSenders: () => [], getReceivers: () => [] };
246
+ expect(() => attachE2ee(fakePc, {})).toThrow(/SFrameContext/);
247
+ });
248
+
249
+ it('returns a refresh/detach handle and walks senders + receivers', async () => {
250
+ const ctx = new SFrameContext();
251
+ ctx.setKey(0, await generateSFrameKey());
252
+
253
+ const senderCalls = [];
254
+ const receiverCalls = [];
255
+ const fakeSender = { createEncodedStreams: () => { senderCalls.push(1); return null; } };
256
+ const fakeReceiver = { createEncodedStreams: () => { receiverCalls.push(1); return null; } };
257
+ const fakePc = {
258
+ getSenders: () => [fakeSender],
259
+ getReceivers: () => [fakeReceiver],
260
+ };
261
+
262
+ const handle = attachE2ee(fakePc, ctx);
263
+ expect(typeof handle.refresh).toBe('function');
264
+ expect(typeof handle.detach).toBe('function');
265
+ expect(senderCalls).toHaveLength(1);
266
+ expect(receiverCalls).toHaveLength(1);
267
+
268
+ // refresh() is idempotent per sender / receiver (WeakSet dedupe).
269
+ handle.refresh();
270
+ expect(senderCalls).toHaveLength(1);
271
+
272
+ handle.detach();
273
+ });
274
+
275
+ it('survives a sender without createEncodedStreams', () => {
276
+ const ctx = new SFrameContext();
277
+ const fakePc = {
278
+ getSenders: () => [{}],
279
+ getReceivers: () => [{}],
280
+ };
281
+ expect(() => attachE2ee(fakePc, ctx)).not.toThrow();
282
+ });
283
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * tests/webrtc/ice.test.js
3
+ *
4
+ * Coverage for `src/webrtc/ice.js`: parse, stringify (round-trip),
5
+ * address classifiers, and `filterCandidates` policy combinations.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import {
10
+ parseCandidate, stringifyCandidate,
11
+ isPrivateIp, isLoopbackIp, isLinkLocalIp, isMdnsHostname,
12
+ filterCandidates, CANDIDATE_TYPES,
13
+ } from '../../src/webrtc/ice.js';
14
+ import { IceError } from '../../src/webrtc/errors.js';
15
+
16
+
17
+ describe('ice.parseCandidate', () => {
18
+ const HOST = 'candidate:842163049 1 udp 1677729535 192.168.1.5 50000 typ host';
19
+ const SRFLX = 'candidate:1 1 udp 2122194687 1.2.3.4 50001 typ srflx raddr 192.168.1.5 rport 50000';
20
+ const TCP = 'candidate:2 1 tcp 1518280447 10.0.0.1 9 typ host tcptype active';
21
+
22
+ it('parses a simple host candidate', () => {
23
+ const c = parseCandidate(HOST);
24
+ expect(c.foundation).toBe('842163049');
25
+ expect(c.component).toBe(1);
26
+ expect(c.transport).toBe('udp');
27
+ expect(c.priority).toBe(1677729535);
28
+ expect(c.address).toBe('192.168.1.5');
29
+ expect(c.port).toBe(50000);
30
+ expect(c.type).toBe('host');
31
+ });
32
+
33
+ it('lifts raddr/rport onto named fields for srflx', () => {
34
+ const c = parseCandidate(SRFLX);
35
+ expect(c.type).toBe('srflx');
36
+ expect(c.relatedAddress).toBe('192.168.1.5');
37
+ expect(c.relatedPort).toBe(50000);
38
+ });
39
+
40
+ it('lifts tcptype for TCP candidates', () => {
41
+ const c = parseCandidate(TCP);
42
+ expect(c.transport).toBe('tcp');
43
+ expect(c.tcpType).toBe('active');
44
+ });
45
+
46
+ it('accepts the `a=` SDP-attribute prefix', () => {
47
+ const c = parseCandidate(`a=${HOST}`);
48
+ expect(c.address).toBe('192.168.1.5');
49
+ });
50
+
51
+ it('captures unknown key/value pairs as extensions', () => {
52
+ const c = parseCandidate(`${HOST} generation 0 ufrag abcd network-id 1`);
53
+ expect(c.extensions.generation).toBe('0');
54
+ expect(c.extensions.ufrag).toBe('abcd');
55
+ expect(c.extensions['network-id']).toBe('1');
56
+ });
57
+
58
+ it('throws IceError on non-string input', () => {
59
+ expect(() => parseCandidate(null)).toThrowError(IceError);
60
+ expect(() => parseCandidate(123)).toThrowError(IceError);
61
+ });
62
+
63
+ it('throws IceError on missing candidate: prefix', () => {
64
+ expect(() => parseCandidate('842163049 1 udp ...')).toThrowError(IceError);
65
+ });
66
+
67
+ it('throws IceError on bad type keyword or unknown type', () => {
68
+ expect(() => parseCandidate('candidate:1 1 udp 1 1.2.3.4 1 nope host')).toThrowError(IceError);
69
+ expect(() => parseCandidate('candidate:1 1 udp 1 1.2.3.4 1 typ moon')).toThrowError(IceError);
70
+ });
71
+
72
+ it('throws IceError on out-of-range port', () => {
73
+ expect(() => parseCandidate('candidate:1 1 udp 1 1.2.3.4 99999 typ host')).toThrowError(IceError);
74
+ });
75
+ });
76
+
77
+
78
+ describe('ice.stringifyCandidate', () => {
79
+ it('round-trips parseCandidate output exactly', () => {
80
+ const line = 'candidate:1 1 udp 2122194687 1.2.3.4 50001 typ srflx raddr 10.0.0.1 rport 50000 generation 0';
81
+ const reser = stringifyCandidate(parseCandidate(line));
82
+ // generation lands in extensions; order is preserved.
83
+ expect(reser).toBe(line);
84
+ });
85
+
86
+ it('throws on missing required fields', () => {
87
+ expect(() => stringifyCandidate({ foundation: '1' })).toThrowError(IceError);
88
+ expect(() => stringifyCandidate(null)).toThrowError(IceError);
89
+ });
90
+ });
91
+
92
+
93
+ describe('ice address classifiers', () => {
94
+ it('isPrivateIp covers RFC 1918 / 6598 / ULA', () => {
95
+ expect(isPrivateIp('10.0.0.1')).toBe(true);
96
+ expect(isPrivateIp('172.16.0.1')).toBe(true);
97
+ expect(isPrivateIp('172.32.0.1')).toBe(false);
98
+ expect(isPrivateIp('192.168.1.1')).toBe(true);
99
+ expect(isPrivateIp('100.64.0.1')).toBe(true);
100
+ expect(isPrivateIp('1.2.3.4')).toBe(false);
101
+ expect(isPrivateIp('fc00::1')).toBe(true);
102
+ expect(isPrivateIp('2001:db8::1')).toBe(false);
103
+ });
104
+
105
+ it('isLoopbackIp covers 127.0.0.0/8 and ::1', () => {
106
+ expect(isLoopbackIp('127.0.0.1')).toBe(true);
107
+ expect(isLoopbackIp('127.1.2.3')).toBe(true);
108
+ expect(isLoopbackIp('::1')).toBe(true);
109
+ expect(isLoopbackIp('1.2.3.4')).toBe(false);
110
+ });
111
+
112
+ it('isLinkLocalIp covers 169.254/16 and fe80::/10', () => {
113
+ expect(isLinkLocalIp('169.254.1.1')).toBe(true);
114
+ expect(isLinkLocalIp('169.255.1.1')).toBe(false);
115
+ expect(isLinkLocalIp('fe80::1')).toBe(true);
116
+ expect(isLinkLocalIp('fec0::1')).toBe(false);
117
+ });
118
+
119
+ it('isMdnsHostname is strict about hostnames vs IPs', () => {
120
+ expect(isMdnsHostname('abcd1234.local')).toBe(true);
121
+ expect(isMdnsHostname('Abcd1234.LOCAL')).toBe(true);
122
+ expect(isMdnsHostname('1.2.3.4')).toBe(false);
123
+ expect(isMdnsHostname('fe80::1')).toBe(false);
124
+ expect(isMdnsHostname('example.com')).toBe(false);
125
+ expect(isMdnsHostname(null)).toBe(false);
126
+ });
127
+ });
128
+
129
+
130
+ describe('ice.filterCandidates', () => {
131
+ const lines = [
132
+ 'candidate:1 1 udp 1 1.2.3.4 5000 typ host', // public
133
+ 'candidate:2 1 udp 1 10.0.0.1 5000 typ host', // private
134
+ 'candidate:3 1 udp 1 abc123.local 5000 typ host', // mDNS
135
+ 'candidate:4 1 udp 1 5.6.7.8 5000 typ srflx', // srflx public
136
+ 'candidate:5 1 tcp 1 5.6.7.8 9 typ host tcptype active', // TCP
137
+ 'candidate:6 1 udp 1 fe80::1 5000 typ host', // IPv6 link-local
138
+ 'garbage', // unparseable
139
+ ];
140
+
141
+ it('blockMdns drops .local hostnames only', () => {
142
+ const out = filterCandidates(lines, { blockMdns: true });
143
+ expect(out.find((l) => l.includes('.local'))).toBeUndefined();
144
+ expect(out.find((l) => l.includes('1.2.3.4'))).toBeDefined();
145
+ });
146
+
147
+ it('blockPrivate drops RFC 1918 addresses', () => {
148
+ const out = filterCandidates(lines, { blockPrivate: true });
149
+ expect(out.find((l) => l.includes('10.0.0.1'))).toBeUndefined();
150
+ });
151
+
152
+ it('blockTcp drops TCP transports', () => {
153
+ const out = filterCandidates(lines, { blockTcp: true });
154
+ expect(out.find((l) => l.startsWith('candidate:5 '))).toBeUndefined();
155
+ });
156
+
157
+ it('blockLinkLocal drops fe80:: addresses', () => {
158
+ const out = filterCandidates(lines, { blockLinkLocal: true });
159
+ expect(out.find((l) => l.includes('fe80::1'))).toBeUndefined();
160
+ });
161
+
162
+ it('allowedTypes whitelist filters out other types', () => {
163
+ const out = filterCandidates(lines, { allowedTypes: ['srflx'] });
164
+ expect(out).toHaveLength(1);
165
+ expect(out[0]).toContain('typ srflx');
166
+ });
167
+
168
+ it('maxCandidates caps the result', () => {
169
+ const out = filterCandidates(lines, { maxCandidates: 2 });
170
+ expect(out).toHaveLength(2);
171
+ });
172
+
173
+ it('predicate hook can drop individual candidates', () => {
174
+ const out = filterCandidates(lines, { predicate: (c) => c.priority !== 1 || c.address === '1.2.3.4' });
175
+ expect(out.find((l) => l.includes('1.2.3.4'))).toBeDefined();
176
+ expect(out.find((l) => l.includes('10.0.0.1'))).toBeUndefined();
177
+ });
178
+
179
+ it('silently skips unparseable lines', () => {
180
+ const out = filterCandidates(lines);
181
+ expect(out.includes('garbage')).toBe(false);
182
+ });
183
+
184
+ it('returns parsed objects when input is parsed objects', () => {
185
+ const parsed = lines.slice(0, 4).map(parseCandidate);
186
+ const out = filterCandidates(parsed, { blockMdns: true });
187
+ expect(out.every((c) => typeof c === 'object')).toBe(true);
188
+ });
189
+
190
+ it('returns [] when input is not an array', () => {
191
+ expect(filterCandidates(null)).toEqual([]);
192
+ expect(filterCandidates('x')).toEqual([]);
193
+ });
194
+ });
195
+
196
+
197
+ describe('ice module constants', () => {
198
+ it('exports CANDIDATE_TYPES frozen', () => {
199
+ expect(CANDIDATE_TYPES).toEqual(['host', 'srflx', 'prflx', 'relay']);
200
+ expect(Object.isFrozen(CANDIDATE_TYPES)).toBe(true);
201
+ });
202
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * tests/webrtc/joinToken.test.js
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { decodeJoinToken, isJoinTokenExpired } from '../../src/webrtc/index.js';
7
+
8
+
9
+ function b64urlJson(obj) {
10
+ const json = JSON.stringify(obj);
11
+ // base64url-encode in a Node-friendly way
12
+ const b64 = Buffer.from(json, 'utf8').toString('base64');
13
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
14
+ }
15
+
16
+
17
+ describe('decodeJoinToken', () => {
18
+ it('decodes a JWT-like 3-segment token', () => {
19
+ const header = b64urlJson({ alg: 'HS256', typ: 'JWT' });
20
+ const payload = b64urlJson({ user: { id: 'u1', name: 'Ada' }, room: 'lobby', exp: 1_700_000_000 });
21
+ const token = `${header}.${payload}.signature`;
22
+ const d = decodeJoinToken(token);
23
+ expect(d.user).toEqual({ id: 'u1', name: 'Ada' });
24
+ expect(d.room).toBe('lobby');
25
+ expect(d.exp).toBe(1_700_000_000);
26
+ });
27
+
28
+ it('decodes a 2-segment token (payload.sig)', () => {
29
+ const token = `${b64urlJson({ user: { id: 'u2' }, room: 'r' })}.sig`;
30
+ expect(decodeJoinToken(token).user).toEqual({ id: 'u2' });
31
+ });
32
+
33
+ it('decodes a 1-segment payload-only token', () => {
34
+ const token = b64urlJson({ user: { id: 'u3' }, room: 'r' });
35
+ expect(decodeJoinToken(token).user.id).toBe('u3');
36
+ });
37
+
38
+ it('falls back to `sub` for user id', () => {
39
+ const token = `${b64urlJson({ sub: 'subject-1', room: 'r' })}.sig`;
40
+ expect(decodeJoinToken(token).user).toEqual({ id: 'subject-1' });
41
+ });
42
+
43
+ it('returns null user/room/exp when payload lacks them', () => {
44
+ const d = decodeJoinToken(b64urlJson({ foo: 1 }));
45
+ expect(d.user).toBeNull();
46
+ expect(d.room).toBeNull();
47
+ expect(d.exp).toBeNull();
48
+ expect(d.raw).toEqual({ foo: 1 });
49
+ });
50
+
51
+ it('rejects empty / non-string input', () => {
52
+ expect(() => decodeJoinToken('')).toThrow(/non-empty string/);
53
+ expect(() => decodeJoinToken(null)).toThrow(/non-empty string/);
54
+ });
55
+
56
+ it('rejects malformed shape', () => {
57
+ expect(() => decodeJoinToken('a.b.c.d')).toMatchObject; // throws below
58
+ try { decodeJoinToken('a.b.c.d'); throw new Error('want throw'); }
59
+ catch (err) { expect(err.code).toBe('ZQ_WEBRTC_TOKEN_BAD_SHAPE'); }
60
+ });
61
+
62
+ it('rejects bad base64url payload', () => {
63
+ try { decodeJoinToken('not!base64.sig'); throw new Error('want throw'); }
64
+ catch (err) { expect(err.code).toBe('ZQ_WEBRTC_TOKEN_BAD_PAYLOAD'); }
65
+ });
66
+ });
67
+
68
+
69
+ describe('isJoinTokenExpired', () => {
70
+ it('returns false when exp is missing', () => {
71
+ expect(isJoinTokenExpired({ exp: null })).toBe(false);
72
+ expect(isJoinTokenExpired({})).toBe(false);
73
+ });
74
+
75
+ it('returns true when exp is in the past', () => {
76
+ const expSec = Math.floor(Date.now() / 1000) - 60;
77
+ expect(isJoinTokenExpired({ exp: expSec })).toBe(true);
78
+ });
79
+
80
+ it('returns false when exp is in the future', () => {
81
+ const expSec = Math.floor(Date.now() / 1000) + 60;
82
+ expect(isJoinTokenExpired({ exp: expSec })).toBe(false);
83
+ });
84
+
85
+ it('respects nowMs override', () => {
86
+ expect(isJoinTokenExpired({ exp: 1000 }, { nowMs: 2_000_000 })).toBe(true);
87
+ expect(isJoinTokenExpired({ exp: 1000 }, { nowMs: 0 })).toBe(false);
88
+ });
89
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * tests/webrtc/observe.test.js
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { samplePeerStats, createStatsSampler, classifyStats } from '../../src/webrtc/index.js';
7
+
8
+
9
+ function makeReport(entries) {
10
+ const map = new Map();
11
+ for (const e of entries) map.set(e.id || e.type, e);
12
+ return map;
13
+ }
14
+
15
+ function makePc(report) {
16
+ return { getStats: vi.fn(async () => report) };
17
+ }
18
+
19
+
20
+ describe('samplePeerStats', () => {
21
+ it('reduces an iterable getStats report to a flat summary', async () => {
22
+ const pc = makePc(makeReport([
23
+ { id: 'in1', type: 'inbound-rtp', bytesReceived: 1000, packetsReceived: 100, packetsLost: 2 },
24
+ { id: 'out1', type: 'outbound-rtp', bytesSent: 5000 },
25
+ { id: 'cp1', type: 'candidate-pair', state: 'succeeded', nominated: true, currentRoundTripTime: 0.05 },
26
+ ]));
27
+ const s = await samplePeerStats(pc);
28
+ expect(s.inboundRtp.length).toBe(1);
29
+ expect(s.outboundRtp.length).toBe(1);
30
+ expect(s.candidatePair).not.toBeNull();
31
+ expect(s.summary.bytesSent).toBe(5000);
32
+ expect(s.summary.bytesReceived).toBe(1000);
33
+ expect(s.summary.rttMs).toBeCloseTo(50);
34
+ expect(s.summary.lossPct).toBeCloseTo((2 / 102) * 100);
35
+ });
36
+
37
+ it('handles a plain-object report', async () => {
38
+ const pc = makePc({
39
+ in1: { type: 'inbound-rtp', bytesReceived: 10, packetsReceived: 1, packetsLost: 0 },
40
+ out1: { type: 'outbound-rtp', bytesSent: 20 },
41
+ });
42
+ const s = await samplePeerStats(pc);
43
+ expect(s.summary.bytesSent).toBe(20);
44
+ expect(s.summary.bytesReceived).toBe(10);
45
+ expect(s.candidatePair).toBeNull();
46
+ });
47
+
48
+ it('rejects a non-RTCPeerConnection', async () => {
49
+ await expect(samplePeerStats({})).rejects.toMatchObject({ code: 'ZQ_WEBRTC_OBSERVE_BAD_PC' });
50
+ });
51
+
52
+ it('wraps getStats() failures', async () => {
53
+ const pc = { getStats: async () => { throw new Error('boom'); } };
54
+ await expect(samplePeerStats(pc)).rejects.toMatchObject({ code: 'ZQ_WEBRTC_OBSERVE_GETSTATS_FAILED' });
55
+ });
56
+ });
57
+
58
+
59
+ describe('classifyStats', () => {
60
+ it('returns "unknown" for empty samples', () => {
61
+ expect(classifyStats(null)).toBe('unknown');
62
+ expect(classifyStats({ summary: { rttMs: null, lossPct: 0 } })).toBe('unknown');
63
+ });
64
+
65
+ it('returns "good" for low rtt/loss', () => {
66
+ expect(classifyStats({ summary: { rttMs: 50, lossPct: 0.2 } })).toBe('good');
67
+ });
68
+
69
+ it('returns "fair" for moderate rtt/loss', () => {
70
+ expect(classifyStats({ summary: { rttMs: 250, lossPct: 0.5 } })).toBe('fair');
71
+ expect(classifyStats({ summary: { rttMs: 50, lossPct: 2 } })).toBe('fair');
72
+ });
73
+
74
+ it('returns "poor" for high rtt/loss', () => {
75
+ expect(classifyStats({ summary: { rttMs: 500, lossPct: 0 } })).toBe('poor');
76
+ expect(classifyStats({ summary: { rttMs: 50, lossPct: 10 } })).toBe('poor');
77
+ });
78
+ });
79
+
80
+
81
+ describe('createStatsSampler', () => {
82
+ it('samples immediately and reports via onSample', async () => {
83
+ const pc = makePc(makeReport([
84
+ { id: 'in1', type: 'inbound-rtp', bytesReceived: 1, packetsReceived: 1, packetsLost: 0 },
85
+ ]));
86
+ const samples = [];
87
+ const sampler = createStatsSampler(pc, {
88
+ intervalMs: 10_000,
89
+ onSample: (s) => samples.push(s),
90
+ });
91
+ // Yield so the immediate getStats() resolves.
92
+ await new Promise((r) => setTimeout(r, 5));
93
+ sampler.stop();
94
+ expect(samples.length).toBe(1);
95
+ expect(sampler.getLatest()).toBe(samples[0]);
96
+ });
97
+
98
+ it('forwards getStats() failures to onError', async () => {
99
+ const pc = { getStats: async () => { throw new Error('nope'); } };
100
+ const errs = [];
101
+ const sampler = createStatsSampler(pc, { intervalMs: 10_000, onError: (e) => errs.push(e) });
102
+ await new Promise((r) => setTimeout(r, 5));
103
+ sampler.stop();
104
+ expect(errs.length).toBe(1);
105
+ expect(errs[0].code).toBe('ZQ_WEBRTC_OBSERVE_GETSTATS_FAILED');
106
+ });
107
+
108
+ it('rejects a non-RTCPeerConnection', () => {
109
+ expect(() => createStatsSampler({})).toThrow(/RTCPeerConnection required/);
110
+ });
111
+ });