zero-query 1.0.9 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -0
- package/cli/commands/build.js +254 -216
- package/cli/commands/bundle.js +1228 -1183
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -167
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +7264 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6252
- package/dist/zquery.min.js +8 -601
- package/index.d.ts +570 -365
- package/index.js +311 -232
- package/package.json +76 -69
- package/src/component.js +1709 -1454
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -254
- package/src/router.js +843 -773
- package/src/ssr.js +418 -418
- package/src/store.js +318 -272
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1023
- package/tests/compare.test.js +497 -0
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -0
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -0
- package/tests/electron-features.test.js +864 -0
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -145
- package/types/utils.d.ts +245 -245
- package/types/webrtc.d.ts +653 -0
|
@@ -0,0 +1,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
|
+
});
|