zero-query 1.0.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 -0
  5. package/cli/commands/build.js +254 -216
  6. package/cli/commands/bundle.js +1228 -1183
  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 -167
  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 +7264 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6252
  81. package/dist/zquery.min.js +8 -601
  82. package/index.d.ts +570 -365
  83. package/index.js +311 -232
  84. package/package.json +76 -69
  85. package/src/component.js +1709 -1454
  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 -254
  93. package/src/router.js +843 -773
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -272
  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 -1023
  115. package/tests/compare.test.js +497 -0
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -0
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -0
  121. package/tests/electron-features.test.js +864 -0
  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 -145
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * tests/webrtc/sfu.test.js
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { loadSfuAdapter, createMediasoupAdapter, SfuError } from '../../src/webrtc/index.js';
7
+
8
+
9
+ function makeFakeMediasoupClient({ loaded = false, canProduce = true } = {}) {
10
+ const transports = [];
11
+ class FakeDevice {
12
+ constructor(opts) {
13
+ this.opts = opts;
14
+ this.loaded = loaded;
15
+ this.loadCalls = [];
16
+ }
17
+ async load({ routerRtpCapabilities }) {
18
+ this.loadCalls.push(routerRtpCapabilities);
19
+ this.loaded = true;
20
+ }
21
+ canProduce(kind) { return canProduce && (kind === 'audio' || kind === 'video'); }
22
+ createSendTransport(params) {
23
+ const t = { kind: 'send', params };
24
+ transports.push(t);
25
+ return t;
26
+ }
27
+ createRecvTransport(params) {
28
+ const t = { kind: 'recv', params };
29
+ transports.push(t);
30
+ return t;
31
+ }
32
+ }
33
+ return { mod: { Device: FakeDevice }, transports };
34
+ }
35
+
36
+
37
+ describe('loadSfuAdapter', () => {
38
+ it('rejects unknown adapter names', async () => {
39
+ await expect(loadSfuAdapter('nope')).rejects.toMatchObject({
40
+ code: 'ZQ_WEBRTC_SFU_UNKNOWN',
41
+ });
42
+ });
43
+
44
+ it('rejects livekit when livekit-client is not installed (peer-dep missing)', async () => {
45
+ await expect(loadSfuAdapter('livekit')).rejects.toMatchObject({
46
+ code: 'ZQ_WEBRTC_SFU_PEER_MISSING',
47
+ });
48
+ });
49
+
50
+ it('throws ZQ_WEBRTC_SFU_PEER_MISSING when mediasoup-client is not installed', async () => {
51
+ // mediasoup-client is genuinely not in this project's deps, so the
52
+ // dynamic import will fail.
53
+ await expect(loadSfuAdapter('mediasoup')).rejects.toMatchObject({
54
+ code: 'ZQ_WEBRTC_SFU_PEER_MISSING',
55
+ });
56
+ });
57
+
58
+ it('builds a mediasoup adapter with an injected client mock', async () => {
59
+ const { mod } = makeFakeMediasoupClient();
60
+ const adapter = await loadSfuAdapter('mediasoup', { client: mod, deviceOptions: { handlerName: 'TestHandler' } });
61
+ expect(adapter.name).toBe('mediasoup');
62
+ expect(adapter.device).toBeDefined();
63
+ expect(adapter.device.opts).toEqual({ handlerName: 'TestHandler' });
64
+ expect(adapter.loaded).toBe(false);
65
+ });
66
+ });
67
+
68
+
69
+ describe('createMediasoupAdapter (with mock)', () => {
70
+ it('throws ZQ_WEBRTC_SFU_BAD_MODULE when Device is missing', async () => {
71
+ await expect(createMediasoupAdapter({ client: {} })).rejects.toMatchObject({
72
+ code: 'ZQ_WEBRTC_SFU_BAD_MODULE',
73
+ });
74
+ });
75
+
76
+ it('also accepts a `default` export wrapper', async () => {
77
+ const { mod } = makeFakeMediasoupClient();
78
+ const adapter = await createMediasoupAdapter({ client: { default: mod } });
79
+ expect(adapter.name).toBe('mediasoup');
80
+ });
81
+
82
+ it('wraps Device-constructor exceptions in SfuError', async () => {
83
+ class BoomDevice { constructor() { throw new Error('boom'); } }
84
+ await expect(createMediasoupAdapter({ client: { Device: BoomDevice } })).rejects.toMatchObject({
85
+ code: 'ZQ_WEBRTC_SFU_DEVICE_FAILED',
86
+ });
87
+ });
88
+
89
+ it('load() requires routerRtpCapabilities', async () => {
90
+ const { mod } = makeFakeMediasoupClient();
91
+ const a = await createMediasoupAdapter({ client: mod });
92
+ await expect(a.load()).rejects.toMatchObject({ code: 'ZQ_WEBRTC_SFU_BAD_RTP_CAPS' });
93
+ await expect(a.load(null)).rejects.toMatchObject({ code: 'ZQ_WEBRTC_SFU_BAD_RTP_CAPS' });
94
+ });
95
+
96
+ it('load() forwards routerRtpCapabilities to the Device once', async () => {
97
+ const { mod } = makeFakeMediasoupClient();
98
+ const a = await createMediasoupAdapter({ client: mod });
99
+ const caps = { codecs: [], headerExtensions: [] };
100
+ await a.load(caps);
101
+ expect(a.loaded).toBe(true);
102
+ expect(a.device.loadCalls).toEqual([caps]);
103
+ // Calling again is a no-op (device.loaded short-circuits).
104
+ await a.load(caps);
105
+ expect(a.device.loadCalls).toEqual([caps]);
106
+ });
107
+
108
+ it('wraps device.load() failures', async () => {
109
+ class FailingDevice {
110
+ constructor() { this.loaded = false; }
111
+ async load() { throw new Error('rtp parse failed'); }
112
+ }
113
+ const a = await createMediasoupAdapter({ client: { Device: FailingDevice } });
114
+ await expect(a.load({ codecs: [] })).rejects.toMatchObject({
115
+ code: 'ZQ_WEBRTC_SFU_LOAD_FAILED',
116
+ });
117
+ });
118
+
119
+ it('canProduce / createSendTransport / createRecvTransport require load()', async () => {
120
+ const { mod } = makeFakeMediasoupClient();
121
+ const a = await createMediasoupAdapter({ client: mod });
122
+ expect(() => a.canProduce('audio')).toThrow(/not loaded/);
123
+ expect(() => a.createSendTransport({})).toThrow(/not loaded/);
124
+ expect(() => a.createRecvTransport({})).toThrow(/not loaded/);
125
+ });
126
+
127
+ it('after load(), forwards transport creation to the device', async () => {
128
+ const { mod, transports } = makeFakeMediasoupClient();
129
+ const a = await createMediasoupAdapter({ client: mod });
130
+ await a.load({ codecs: [] });
131
+
132
+ expect(a.canProduce('audio')).toBe(true);
133
+ expect(a.canProduce('video')).toBe(true);
134
+
135
+ const send = a.createSendTransport({ id: 'send-1' });
136
+ const recv = a.createRecvTransport({ id: 'recv-1' });
137
+ expect(send).toEqual({ kind: 'send', params: { id: 'send-1' } });
138
+ expect(recv).toEqual({ kind: 'recv', params: { id: 'recv-1' } });
139
+ expect(transports).toHaveLength(2);
140
+ });
141
+
142
+ it('join() throws ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE (not yet wired)', async () => {
143
+ const { mod } = makeFakeMediasoupClient();
144
+ const a = await createMediasoupAdapter({ client: mod });
145
+ await expect(a.join({})).rejects.toMatchObject({
146
+ code: 'ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE',
147
+ });
148
+ });
149
+
150
+ it('SfuError is exported and instanceof WebRtcError', async () => {
151
+ const { mod } = makeFakeMediasoupClient();
152
+ const a = await createMediasoupAdapter({ client: mod });
153
+ try {
154
+ await a.join({});
155
+ } catch (err) {
156
+ expect(err).toBeInstanceOf(SfuError);
157
+ expect(err.code).toBe('ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE');
158
+ }
159
+ });
160
+ });
@@ -0,0 +1,251 @@
1
+ /**
2
+ * tests/webrtc/signaling.test.js
3
+ *
4
+ * Covers the low-level `SignalingClient`:
5
+ * - connect + hello → peerId
6
+ * - reconnect on abrupt close with exponential backoff
7
+ * - protocol error on missing `type` field
8
+ * - ICE coalescing batches outbound `ice` frames
9
+ * - SSR-safe import (no globals required at module load)
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
13
+
14
+ import { SignalingClient, SignalingError } from '../../src/webrtc/index.js';
15
+ import { FakeWebSocket, fakeSockets, resetFakeSockets } from '../_helpers/webrtcFakes.js';
16
+
17
+
18
+ function makeClient(overrides = {}) {
19
+ return new SignalingClient('wss://example.test/rtc', Object.assign({
20
+ WebSocket: FakeWebSocket,
21
+ reconnect: { baseMs: 10, capMs: 80, maxRetries: 3 },
22
+ iceFlushMs: 20,
23
+ iceBatch: 3,
24
+ }, overrides));
25
+ }
26
+
27
+
28
+ describe('SignalingClient', () => {
29
+ beforeEach(() => {
30
+ resetFakeSockets();
31
+ vi.useFakeTimers();
32
+ });
33
+
34
+ afterEach(() => {
35
+ vi.useRealTimers();
36
+ });
37
+
38
+ it('connect → hello → peerId', async () => {
39
+ const client = makeClient();
40
+ const p = client.connect();
41
+
42
+ // First (and only) socket created so far
43
+ expect(fakeSockets.length).toBe(1);
44
+ const ws = fakeSockets[0];
45
+ ws.fakeOpen();
46
+ await p;
47
+
48
+ expect(client.connected).toBe(true);
49
+ expect(client.peerId).toBe(null);
50
+
51
+ ws.fakeMessage({ type: 'hello', peerId: 'peer-abc' });
52
+ expect(client.peerId).toBe('peer-abc');
53
+ });
54
+
55
+ it('dispatches server frames to typed listeners', async () => {
56
+ const client = makeClient();
57
+ const p = client.connect();
58
+ fakeSockets[0].fakeOpen();
59
+ await p;
60
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: 'me' });
61
+
62
+ const seen = [];
63
+ client.on('joined', (frame) => seen.push(frame));
64
+ fakeSockets[0].fakeMessage({ type: 'joined', room: 'lobby', peerId: 'me', peers: ['x', 'y'] });
65
+
66
+ expect(seen.length).toBe(1);
67
+ expect(seen[0].room).toBe('lobby');
68
+ expect(seen[0].peers).toEqual(['x', 'y']);
69
+ });
70
+
71
+ it('emits a SignalingError when first frame is not a hello', async () => {
72
+ const client = makeClient();
73
+ const errors = [];
74
+ client.on('error', (e) => errors.push(e));
75
+ const p = client.connect();
76
+ fakeSockets[0].fakeOpen();
77
+ await p;
78
+
79
+ fakeSockets[0].fakeMessage({ type: 'joined', room: 'x' });
80
+ expect(errors.length).toBeGreaterThanOrEqual(1);
81
+ const err = errors[errors.length - 1];
82
+ expect(err).toBeInstanceOf(SignalingError);
83
+ expect(err.code).toBe('ZQ_WEBRTC_SIGNALING_NO_HELLO');
84
+ });
85
+
86
+ it('emits a SignalingError on a frame missing a `type`', async () => {
87
+ const client = makeClient();
88
+ const errors = [];
89
+ client.on('error', (e) => errors.push(e));
90
+ const p = client.connect();
91
+ fakeSockets[0].fakeOpen();
92
+ await p;
93
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: 'me' });
94
+
95
+ fakeSockets[0].fakeMessage({ room: 'lobby' });
96
+ const err = errors[errors.length - 1];
97
+ expect(err).toBeInstanceOf(SignalingError);
98
+ expect(err.code).toBe('ZQ_WEBRTC_SIGNALING_BAD_FRAME');
99
+ });
100
+
101
+ it('emits a SignalingError on malformed JSON', async () => {
102
+ const client = makeClient();
103
+ const errors = [];
104
+ client.on('error', (e) => errors.push(e));
105
+ const p = client.connect();
106
+ fakeSockets[0].fakeOpen();
107
+ await p;
108
+
109
+ fakeSockets[0].fakeMessage('not-json-at-all');
110
+ const err = errors[errors.length - 1];
111
+ expect(err).toBeInstanceOf(SignalingError);
112
+ expect(err.code).toBe('ZQ_WEBRTC_SIGNALING_BAD_JSON');
113
+ });
114
+
115
+ it('throws SignalingError when send() is called without a type', async () => {
116
+ const client = makeClient();
117
+ const p = client.connect();
118
+ fakeSockets[0].fakeOpen();
119
+ await p;
120
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: 'me' });
121
+
122
+ expect(() => client.send('')).toThrow(SignalingError);
123
+ });
124
+
125
+ it('reconnects with exponential backoff on abrupt close', async () => {
126
+ const client = makeClient();
127
+ const reconnects = [];
128
+ client.on('reconnect', (e) => reconnects.push(e));
129
+
130
+ const p = client.connect();
131
+ fakeSockets[0].fakeOpen();
132
+ await p;
133
+
134
+ // First abrupt close → schedules attempt 1 at baseMs * 2^0 = 10
135
+ fakeSockets[0].fakeClose(1006);
136
+ expect(reconnects.length).toBe(1);
137
+ expect(reconnects[0]).toEqual({ attempt: 1, delayMs: 10 });
138
+
139
+ // Drive the reconnect WITHOUT firing onopen so backoff continues to grow
140
+ vi.advanceTimersByTime(10);
141
+ expect(fakeSockets.length).toBe(2);
142
+
143
+ // Second close (still no successful open) → attempt 2 at 20ms
144
+ fakeSockets[1].fakeClose(1006);
145
+ expect(reconnects[1]).toEqual({ attempt: 2, delayMs: 20 });
146
+
147
+ vi.advanceTimersByTime(20);
148
+ expect(fakeSockets.length).toBe(3);
149
+
150
+ // Third close → attempt 3 at 40ms
151
+ fakeSockets[2].fakeClose(1006);
152
+ expect(reconnects[2]).toEqual({ attempt: 3, delayMs: 40 });
153
+ });
154
+
155
+ it('does not reconnect after .close()', async () => {
156
+ const client = makeClient();
157
+ const p = client.connect();
158
+ fakeSockets[0].fakeOpen();
159
+ await p;
160
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: 'me' });
161
+
162
+ client.close();
163
+ // close() called close(1000) synchronously - no reconnect should be scheduled
164
+ vi.advanceTimersByTime(500);
165
+ expect(fakeSockets.length).toBe(1);
166
+ expect(client.closed).toBe(true);
167
+ });
168
+
169
+ it('coalesces outbound ICE frames into batches', async () => {
170
+ const client = makeClient({ iceFlushMs: 50, iceBatch: 3 });
171
+ const p = client.connect();
172
+ const ws = fakeSockets[0];
173
+ ws.fakeOpen();
174
+ await p;
175
+ ws.fakeMessage({ type: 'hello', peerId: 'me' });
176
+
177
+ // Send 5 ice frames in quick succession
178
+ for (let i = 0; i < 5; i++) {
179
+ client.send('ice', { to: 'peer-x', candidate: `cand-${i}` });
180
+ }
181
+
182
+ // Nothing flushed yet
183
+ const iceSentBefore = ws.sentFrames.filter(f => f.type === 'ice');
184
+ expect(iceSentBefore.length).toBe(0);
185
+
186
+ // First window → first 3 flushed
187
+ vi.advanceTimersByTime(50);
188
+ const after1 = ws.sentFrames.filter(f => f.type === 'ice');
189
+ expect(after1.length).toBe(3);
190
+ expect(after1.map(f => f.candidate)).toEqual(['cand-0', 'cand-1', 'cand-2']);
191
+
192
+ // Second window → remaining 2 flushed
193
+ vi.advanceTimersByTime(50);
194
+ const after2 = ws.sentFrames.filter(f => f.type === 'ice');
195
+ expect(after2.length).toBe(5);
196
+ expect(after2.map(f => f.candidate)).toEqual(['cand-0', 'cand-1', 'cand-2', 'cand-3', 'cand-4']);
197
+ });
198
+
199
+ it('sends non-ICE frames immediately (no coalescing)', async () => {
200
+ const client = makeClient();
201
+ const p = client.connect();
202
+ fakeSockets[0].fakeOpen();
203
+ await p;
204
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: 'me' });
205
+
206
+ client.send('join', { room: 'lobby' });
207
+ const sent = fakeSockets[0].sentFrames;
208
+ expect(sent.length).toBe(1);
209
+ expect(sent[0]).toEqual({ type: 'join', room: 'lobby' });
210
+ });
211
+
212
+ it('gives up after maxRetries reconnect attempts', async () => {
213
+ const client = makeClient({ reconnect: { baseMs: 5, capMs: 100, maxRetries: 2 } });
214
+ const errors = [];
215
+ client.on('error', (e) => errors.push(e));
216
+ const p = client.connect();
217
+ fakeSockets[0].fakeOpen();
218
+ await p;
219
+
220
+ // Three consecutive failed connects (no fakeOpen on the reconnect sockets)
221
+ fakeSockets[0].fakeClose(1006);
222
+ vi.advanceTimersByTime(5);
223
+ fakeSockets[1].fakeClose(1006);
224
+ vi.advanceTimersByTime(10);
225
+ fakeSockets[2].fakeClose(1006);
226
+
227
+ // No more reconnects scheduled - giveup error emitted instead
228
+ const giveup = errors.find(e => e.code === 'ZQ_WEBRTC_SIGNALING_GIVEUP');
229
+ expect(giveup).toBeDefined();
230
+ expect(giveup).toBeInstanceOf(SignalingError);
231
+ });
232
+
233
+ it('is SSR-safe: importing the module does not require WebSocket on globalThis', async () => {
234
+ // Snapshot + delete the global WebSocket binding (jsdom provides one)
235
+ const hadWs = 'WebSocket' in globalThis;
236
+ const prev = hadWs ? globalThis.WebSocket : undefined;
237
+ try {
238
+
239
+ delete globalThis.WebSocket;
240
+ // Re-import via dynamic import to prove module init doesn't read it
241
+ const mod = await import('../../src/webrtc/signaling.js?ssrSafe=1');
242
+ expect(typeof mod.SignalingClient).toBe('function');
243
+ // Constructing also must not touch the global
244
+ const c = new mod.SignalingClient('wss://x/', { WebSocket: FakeWebSocket });
245
+ expect(c.connected).toBe(false);
246
+ expect(c.peerId).toBe(null);
247
+ } finally {
248
+ if (hadWs) globalThis.WebSocket = prev;
249
+ }
250
+ });
251
+ });
@@ -0,0 +1,256 @@
1
+ /**
2
+ * tests/webrtc/turn.test.js - TURN credential client
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6
+ import {
7
+ fetchTurnCredentials,
8
+ mergeIceServers,
9
+ createTurnRefresher,
10
+ TurnError,
11
+ } from '../../src/webrtc/index.js';
12
+
13
+
14
+ const validBody = () => ({
15
+ username: 'u1',
16
+ credential: 'secret',
17
+ urls: ['turn:host:3478?transport=udp', 'turn:host:3478?transport=tcp'],
18
+ ttl: 600,
19
+ });
20
+
21
+
22
+ function mockFetch(impl) {
23
+ return vi.fn(impl);
24
+ }
25
+
26
+ function jsonResponse(body, { ok = true, status = 200 } = {}) {
27
+ return {
28
+ ok,
29
+ status,
30
+ json: async () => body,
31
+ };
32
+ }
33
+
34
+
35
+ describe('fetchTurnCredentials', () => {
36
+ it('returns normalized credentials on a 200 JSON body', async () => {
37
+ const fetchImpl = mockFetch(async () => jsonResponse(validBody()));
38
+ const creds = await fetchTurnCredentials('https://x/turn', { fetch: fetchImpl });
39
+ expect(creds.username).toBe('u1');
40
+ expect(creds.credential).toBe('secret');
41
+ expect(creds.urls).toEqual([
42
+ 'turn:host:3478?transport=udp',
43
+ 'turn:host:3478?transport=tcp',
44
+ ]);
45
+ expect(creds.ttl).toBe(600);
46
+ });
47
+
48
+ it('clones the urls array (mutating the result does not mutate the source)', async () => {
49
+ const body = validBody();
50
+ const fetchImpl = mockFetch(async () => jsonResponse(body));
51
+ const creds = await fetchTurnCredentials('https://x/turn', { fetch: fetchImpl });
52
+ creds.urls.push('turn:other:3478');
53
+ expect(body.urls).toHaveLength(2);
54
+ });
55
+
56
+ it('strips the `fetch` option before forwarding to fetch', async () => {
57
+ const fetchImpl = mockFetch(async (_url, init) => {
58
+ expect(init).toBeDefined();
59
+ expect(init.fetch).toBeUndefined();
60
+ expect(init.method).toBe('GET');
61
+ return jsonResponse(validBody());
62
+ });
63
+ await fetchTurnCredentials('https://x/turn', { fetch: fetchImpl, method: 'GET' });
64
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it('rejects an empty url', async () => {
68
+ await expect(fetchTurnCredentials('', { fetch: mockFetch() })).rejects.toMatchObject({
69
+ name: 'TurnError',
70
+ code: 'ZQ_WEBRTC_TURN_BAD_URL',
71
+ });
72
+ });
73
+
74
+ it('rejects when no fetch implementation is available', async () => {
75
+ const originalFetch = globalThis.fetch;
76
+ try {
77
+
78
+ globalThis.fetch = undefined;
79
+ await expect(fetchTurnCredentials('https://x/turn')).rejects.toMatchObject({
80
+ code: 'ZQ_WEBRTC_TURN_NO_FETCH',
81
+ });
82
+ } finally {
83
+ globalThis.fetch = originalFetch;
84
+ }
85
+ });
86
+
87
+ it('wraps network errors as TurnError(ZQ_WEBRTC_TURN_NETWORK)', async () => {
88
+ const fetchImpl = mockFetch(async () => { throw new Error('boom'); });
89
+ await expect(fetchTurnCredentials('https://x/turn', { fetch: fetchImpl })).rejects.toMatchObject({
90
+ code: 'ZQ_WEBRTC_TURN_NETWORK',
91
+ message: expect.stringContaining('boom'),
92
+ });
93
+ });
94
+
95
+ it('rejects non-OK HTTP responses', async () => {
96
+ const fetchImpl = mockFetch(async () => jsonResponse({}, { ok: false, status: 503 }));
97
+ await expect(fetchTurnCredentials('https://x/turn', { fetch: fetchImpl })).rejects.toMatchObject({
98
+ code: 'ZQ_WEBRTC_TURN_HTTP',
99
+ context: { status: 503 },
100
+ });
101
+ });
102
+
103
+ it('rejects malformed JSON bodies', async () => {
104
+ const fetchImpl = mockFetch(async () => ({
105
+ ok: true, status: 200,
106
+ json: async () => { throw new Error('bad json'); },
107
+ }));
108
+ await expect(fetchTurnCredentials('https://x/turn', { fetch: fetchImpl })).rejects.toMatchObject({
109
+ code: 'ZQ_WEBRTC_TURN_BAD_JSON',
110
+ });
111
+ });
112
+
113
+ it.each([
114
+ ['missing username', { ...validBody(), username: '' }],
115
+ ['missing credential', { ...validBody(), credential: '' }],
116
+ ['missing urls', { ...validBody(), urls: [] }],
117
+ ['urls wrong type', { ...validBody(), urls: [42] }],
118
+ ['ttl zero', { ...validBody(), ttl: 0 }],
119
+ ['ttl NaN', { ...validBody(), ttl: 'soon' }],
120
+ ])('rejects invalid body shape (%s)', async (_label, body) => {
121
+ const fetchImpl = mockFetch(async () => jsonResponse(body));
122
+ await expect(fetchTurnCredentials('https://x/turn', { fetch: fetchImpl })).rejects.toMatchObject({
123
+ code: 'ZQ_WEBRTC_TURN_BAD_BODY',
124
+ });
125
+ });
126
+
127
+ it('throws a TurnError instance (instanceof check)', async () => {
128
+ const fetchImpl = mockFetch(async () => jsonResponse({}, { ok: false, status: 500 }));
129
+ await fetchTurnCredentials('https://x/turn', { fetch: fetchImpl }).catch((err) => {
130
+ expect(err).toBeInstanceOf(TurnError);
131
+ });
132
+ });
133
+ });
134
+
135
+
136
+ describe('mergeIceServers', () => {
137
+ it('appends a TURN entry to the base list', () => {
138
+ const out = mergeIceServers(
139
+ [{ urls: 'stun:stun.l.google.com:19302' }],
140
+ { username: 'u', credential: 'c', urls: ['turn:host:3478?transport=udp'] }
141
+ );
142
+ expect(out).toEqual([
143
+ { urls: ['stun:stun.l.google.com:19302'] },
144
+ { urls: ['turn:host:3478?transport=udp'], username: 'u', credential: 'c' },
145
+ ]);
146
+ });
147
+
148
+ it('dedupes duplicate urls (first occurrence wins)', () => {
149
+ const out = mergeIceServers(
150
+ [{ urls: ['stun:a', 'turn:host:3478'] }],
151
+ { username: 'u', credential: 'c', urls: ['turn:host:3478', 'turn:host:5349'] }
152
+ );
153
+ expect(out[0].urls).toEqual(['stun:a', 'turn:host:3478']);
154
+ expect(out[1].urls).toEqual(['turn:host:5349']);
155
+ });
156
+
157
+ it('drops an entry whose urls are all duplicates', () => {
158
+ const out = mergeIceServers(
159
+ [{ urls: ['turn:host:3478'] }],
160
+ { username: 'u', credential: 'c', urls: ['turn:host:3478'] }
161
+ );
162
+ expect(out).toHaveLength(1);
163
+ });
164
+
165
+ it('handles missing base and missing turn', () => {
166
+ expect(mergeIceServers()).toEqual([]);
167
+ expect(mergeIceServers([{ urls: 'stun:a' }])).toEqual([{ urls: ['stun:a'] }]);
168
+ expect(mergeIceServers(undefined, { username: 'u', credential: 'c', urls: ['turn:a'] }))
169
+ .toEqual([{ urls: ['turn:a'], username: 'u', credential: 'c' }]);
170
+ });
171
+
172
+ it('ignores non-string urls', () => {
173
+ const out = mergeIceServers([{ urls: ['stun:a', '', null, 42] }]);
174
+ expect(out).toEqual([{ urls: ['stun:a'] }]);
175
+ });
176
+ });
177
+
178
+
179
+ describe('createTurnRefresher', () => {
180
+ beforeEach(() => { vi.useFakeTimers(); });
181
+ afterEach(() => { vi.useRealTimers(); });
182
+
183
+ it('rejects construction without a url', () => {
184
+ expect(() => createTurnRefresher({ url: '' })).toThrow(/url is required/);
185
+ });
186
+
187
+ it('fetches once on start() and reschedules ahead of expiry', async () => {
188
+ const fetchImpl = mockFetch(async () => jsonResponse(validBody()));
189
+ const refresher = createTurnRefresher({ url: 'https://x', fetch: fetchImpl, leadMs: 30000 });
190
+
191
+ const first = await refresher.start();
192
+ expect(first.username).toBe('u1');
193
+ expect(refresher.value).toEqual(first);
194
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
195
+
196
+ // ttl=600s, leadMs=30s -> next refresh in 570s
197
+ await vi.advanceTimersByTimeAsync(570_000);
198
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
199
+
200
+ refresher.stop();
201
+ });
202
+
203
+ it('floors the schedule at minIntervalMs', async () => {
204
+ const fetchImpl = mockFetch(async () => jsonResponse({ ...validBody(), ttl: 1 }));
205
+ const refresher = createTurnRefresher({
206
+ url: 'https://x',
207
+ fetch: fetchImpl,
208
+ leadMs: 30000,
209
+ minIntervalMs: 5000,
210
+ });
211
+ await refresher.start();
212
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
213
+
214
+ await vi.advanceTimersByTimeAsync(4999);
215
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
216
+ await vi.advanceTimersByTimeAsync(1);
217
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
218
+
219
+ refresher.stop();
220
+ });
221
+
222
+ it('invokes onRefresh for every successful fetch', async () => {
223
+ const fetchImpl = mockFetch(async () => jsonResponse(validBody()));
224
+ const onRefresh = vi.fn();
225
+ const refresher = createTurnRefresher({ url: 'https://x', fetch: fetchImpl, onRefresh });
226
+
227
+ await refresher.start();
228
+ expect(onRefresh).toHaveBeenCalledTimes(1);
229
+ await refresher.refresh();
230
+ expect(onRefresh).toHaveBeenCalledTimes(2);
231
+
232
+ refresher.stop();
233
+ });
234
+
235
+ it('invokes onError on failure and retries later', async () => {
236
+ const fetchImpl = mockFetch(async () => jsonResponse({}, { ok: false, status: 500 }));
237
+ const onError = vi.fn();
238
+ const refresher = createTurnRefresher({ url: 'https://x', fetch: fetchImpl, onError });
239
+
240
+ await expect(refresher.start()).rejects.toMatchObject({ code: 'ZQ_WEBRTC_TURN_HTTP' });
241
+ expect(onError).toHaveBeenCalledTimes(1);
242
+
243
+ refresher.stop();
244
+ });
245
+
246
+ it('stop() cancels pending timers and prevents further refreshes', async () => {
247
+ const fetchImpl = mockFetch(async () => jsonResponse(validBody()));
248
+ const refresher = createTurnRefresher({ url: 'https://x', fetch: fetchImpl });
249
+
250
+ await refresher.start();
251
+ refresher.stop();
252
+
253
+ await vi.advanceTimersByTimeAsync(10 * 60 * 1000);
254
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
255
+ });
256
+ });