zero-query 1.2.0 → 1.2.3
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/cli/commands/build-api.js +0 -1
- package/cli/commands/build.js +0 -1
- package/cli/commands/bundle.js +0 -2
- package/cli/commands/create.js +46 -1
- package/cli/scaffold/webrtc/app/components/video-room.js +276 -130
- package/cli/scaffold/webrtc/package.json +16 -0
- package/cli/scaffold/webrtc/server/index.js +198 -0
- package/dist/API.md +1 -1
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +296 -155
- package/dist/zquery.min.js +7 -7
- package/package.json +3 -2
- package/src/component.js +161 -112
- package/src/core.js +11 -3
- package/src/expression.js +26 -19
- package/src/reactive.js +18 -2
- package/src/router.js +38 -3
- package/src/ssr.js +14 -11
- package/src/store.js +16 -9
- package/src/utils.js +24 -5
- package/tests/audit.test.js +9 -9
- package/tests/bench/render.bench.js +77 -0
- package/tests/cli.test.js +7 -7
- package/tests/component.test.js +107 -0
- package/tests/core.test.js +16 -0
- package/tests/expression.test.js +31 -0
- package/tests/http.test.js +45 -0
- package/tests/reactive.test.js +25 -0
- package/tests/router.test.js +96 -0
- package/tests/ssr.test.js +46 -0
- package/tests/store.test.js +10 -0
- package/tests/utils.test.js +34 -0
- package/cli/scaffold/webrtc/app/lib/room.js +0 -252
package/tests/router.test.js
CHANGED
|
@@ -226,6 +226,21 @@ describe('Router - z-link-params', () => {
|
|
|
226
226
|
link.click();
|
|
227
227
|
expect(window.location.hash).toBe('#/user/fallback');
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
it('rejects z-link-params that parses to an array (must be an object)', () => {
|
|
231
|
+
document.body.innerHTML += '<a z-link="/user/:id" z-link-params=\'["42"]\'>User</a>';
|
|
232
|
+
const link = document.querySelector('a[z-link="/user/:id"]');
|
|
233
|
+
link.click();
|
|
234
|
+
// Interpolation skipped, raw href preserved
|
|
235
|
+
expect(window.location.hash).toBe('#/user/:id');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('rejects z-link-params that parses to null', () => {
|
|
239
|
+
document.body.innerHTML += '<a z-link="/user/:id" z-link-params="null">User</a>';
|
|
240
|
+
const link = document.querySelector('a[z-link="/user/:id"]');
|
|
241
|
+
link.click();
|
|
242
|
+
expect(window.location.hash).toBe('#/user/:id');
|
|
243
|
+
});
|
|
229
244
|
});
|
|
230
245
|
|
|
231
246
|
|
|
@@ -2325,3 +2340,84 @@ describe('Router - z-active-route', () => {
|
|
|
2325
2340
|
expect(docs.classList.contains('active')).toBe(false);
|
|
2326
2341
|
});
|
|
2327
2342
|
});
|
|
2343
|
+
|
|
2344
|
+
|
|
2345
|
+
// ---------------------------------------------------------------------------
|
|
2346
|
+
// Keep-alive LRU cache
|
|
2347
|
+
// ---------------------------------------------------------------------------
|
|
2348
|
+
|
|
2349
|
+
describe('Router - keep-alive LRU', () => {
|
|
2350
|
+
beforeEach(() => {
|
|
2351
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
2352
|
+
window.location.hash = '#/';
|
|
2353
|
+
// Register fresh keep-alive components for each test
|
|
2354
|
+
component('ka-a', { render: () => '<p>a</p>' });
|
|
2355
|
+
component('ka-b', { render: () => '<p>b</p>' });
|
|
2356
|
+
component('ka-c', { render: () => '<p>c</p>' });
|
|
2357
|
+
component('ka-d', { render: () => '<p>d</p>' });
|
|
2358
|
+
});
|
|
2359
|
+
|
|
2360
|
+
it('keepAliveMax evicts oldest cached instance when exceeded', async () => {
|
|
2361
|
+
const router = createRouter({
|
|
2362
|
+
el: '#app',
|
|
2363
|
+
mode: 'hash',
|
|
2364
|
+
keepAliveMax: 2,
|
|
2365
|
+
routes: [
|
|
2366
|
+
{ path: '/a', component: 'ka-a', keepAlive: true },
|
|
2367
|
+
{ path: '/b', component: 'ka-b', keepAlive: true },
|
|
2368
|
+
{ path: '/c', component: 'ka-c', keepAlive: true },
|
|
2369
|
+
],
|
|
2370
|
+
});
|
|
2371
|
+
|
|
2372
|
+
router.navigate('/a'); await router._resolve();
|
|
2373
|
+
router.navigate('/b'); await router._resolve();
|
|
2374
|
+
expect(router._keepAliveCache.size).toBe(2);
|
|
2375
|
+
expect(router._keepAliveCache.has('ka-a')).toBe(true);
|
|
2376
|
+
expect(router._keepAliveCache.has('ka-b')).toBe(true);
|
|
2377
|
+
|
|
2378
|
+
router.navigate('/c'); await router._resolve();
|
|
2379
|
+
// ka-a (oldest, not current) should be evicted
|
|
2380
|
+
expect(router._keepAliveCache.size).toBe(2);
|
|
2381
|
+
expect(router._keepAliveCache.has('ka-a')).toBe(false);
|
|
2382
|
+
expect(router._keepAliveCache.has('ka-b')).toBe(true);
|
|
2383
|
+
expect(router._keepAliveCache.has('ka-c')).toBe(true);
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
it('keepAliveMax default (unbounded) preserves all entries', async () => {
|
|
2387
|
+
const router = createRouter({
|
|
2388
|
+
el: '#app',
|
|
2389
|
+
mode: 'hash',
|
|
2390
|
+
routes: [
|
|
2391
|
+
{ path: '/a', component: 'ka-a', keepAlive: true },
|
|
2392
|
+
{ path: '/b', component: 'ka-b', keepAlive: true },
|
|
2393
|
+
{ path: '/c', component: 'ka-c', keepAlive: true },
|
|
2394
|
+
{ path: '/d', component: 'ka-d', keepAlive: true },
|
|
2395
|
+
],
|
|
2396
|
+
});
|
|
2397
|
+
router.navigate('/a'); await router._resolve();
|
|
2398
|
+
router.navigate('/b'); await router._resolve();
|
|
2399
|
+
router.navigate('/c'); await router._resolve();
|
|
2400
|
+
router.navigate('/d'); await router._resolve();
|
|
2401
|
+
expect(router._keepAliveCache.size).toBe(4);
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2404
|
+
it('revisiting a cached route refreshes LRU order', async () => {
|
|
2405
|
+
const router = createRouter({
|
|
2406
|
+
el: '#app',
|
|
2407
|
+
mode: 'hash',
|
|
2408
|
+
keepAliveMax: 2,
|
|
2409
|
+
routes: [
|
|
2410
|
+
{ path: '/a', component: 'ka-a', keepAlive: true },
|
|
2411
|
+
{ path: '/b', component: 'ka-b', keepAlive: true },
|
|
2412
|
+
{ path: '/c', component: 'ka-c', keepAlive: true },
|
|
2413
|
+
],
|
|
2414
|
+
});
|
|
2415
|
+
router.navigate('/a'); await router._resolve();
|
|
2416
|
+
router.navigate('/b'); await router._resolve();
|
|
2417
|
+
router.navigate('/a'); await router._resolve(); // touch a → now b is oldest
|
|
2418
|
+
router.navigate('/c'); await router._resolve(); // should evict b, not a
|
|
2419
|
+
expect(router._keepAliveCache.has('ka-a')).toBe(true);
|
|
2420
|
+
expect(router._keepAliveCache.has('ka-b')).toBe(false);
|
|
2421
|
+
expect(router._keepAliveCache.has('ka-c')).toBe(true);
|
|
2422
|
+
});
|
|
2423
|
+
});
|
package/tests/ssr.test.js
CHANGED
|
@@ -806,6 +806,52 @@ describe('SSRApp - renderShell', () => {
|
|
|
806
806
|
expect(html).not.toContain('data-zq-ssr');
|
|
807
807
|
});
|
|
808
808
|
|
|
809
|
+
it('replaces meta description regardless of attribute spacing, order, or quoting', async () => {
|
|
810
|
+
const variantShell = `<!DOCTYPE html>
|
|
811
|
+
<html><head>
|
|
812
|
+
<title>T</title>
|
|
813
|
+
<meta name="description" content="orig multi-space">
|
|
814
|
+
</head><body><z-outlet></z-outlet></body></html>`;
|
|
815
|
+
const html = await app.renderShell(variantShell, { component: 'test-page', description: 'New' });
|
|
816
|
+
expect(html).toContain('<meta name="description" content="New">');
|
|
817
|
+
expect(html).not.toContain('orig multi-space');
|
|
818
|
+
|
|
819
|
+
const reversedShell = `<!DOCTYPE html>
|
|
820
|
+
<html><head>
|
|
821
|
+
<title>T</title>
|
|
822
|
+
<meta content="orig reversed" name="description">
|
|
823
|
+
</head><body><z-outlet></z-outlet></body></html>`;
|
|
824
|
+
const html2 = await app.renderShell(reversedShell, { component: 'test-page', description: 'New2' });
|
|
825
|
+
expect(html2).toContain('<meta name="description" content="New2">');
|
|
826
|
+
expect(html2).not.toContain('orig reversed');
|
|
827
|
+
|
|
828
|
+
const selfClosingShell = `<!DOCTYPE html>
|
|
829
|
+
<html><head>
|
|
830
|
+
<title>T</title>
|
|
831
|
+
<meta name='description' content='orig single quoted' />
|
|
832
|
+
</head><body><z-outlet></z-outlet></body></html>`;
|
|
833
|
+
const html3 = await app.renderShell(selfClosingShell, { component: 'test-page', description: 'New3' });
|
|
834
|
+
expect(html3).toContain('<meta name="description" content="New3">');
|
|
835
|
+
expect(html3).not.toContain('orig single quoted');
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('replaces og:* meta tags regardless of attribute order / quoting', async () => {
|
|
839
|
+
const variantShell = `<!DOCTYPE html>
|
|
840
|
+
<html><head>
|
|
841
|
+
<title>T</title>
|
|
842
|
+
<meta content="orig og title" property="og:title">
|
|
843
|
+
<meta property='og:type' content='website' />
|
|
844
|
+
</head><body><z-outlet></z-outlet></body></html>`;
|
|
845
|
+
const html = await app.renderShell(variantShell, {
|
|
846
|
+
component: 'test-page',
|
|
847
|
+
og: { title: 'New OG', type: 'article' },
|
|
848
|
+
});
|
|
849
|
+
expect(html).toContain('<meta property="og:title" content="New OG">');
|
|
850
|
+
expect(html).toContain('<meta property="og:type" content="article">');
|
|
851
|
+
expect(html).not.toContain('orig og title');
|
|
852
|
+
expect(html).not.toContain("content='website'");
|
|
853
|
+
});
|
|
854
|
+
|
|
809
855
|
it('handles a missing component gracefully', async () => {
|
|
810
856
|
const html = await app.renderShell(shell, { component: 'nonexistent' });
|
|
811
857
|
expect(html).toContain('<!-- SSR error:');
|
package/tests/store.test.js
CHANGED
|
@@ -232,6 +232,16 @@ describe('Store - snapshot & replaceState', () => {
|
|
|
232
232
|
expect(store.state.a).toBe(1); // original unchanged
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
it('snapshot({ clone: false }) skips the deep copy (read-only fast path)', () => {
|
|
236
|
+
const store = createStore('snap-noclone', { state: { a: 1, nested: { b: 2 } } });
|
|
237
|
+
const snap = store.snapshot({ clone: false });
|
|
238
|
+
// Same reference to the underlying nested object (no cloning happened)
|
|
239
|
+
expect(snap.nested).toBe((store.state.__raw || store.state).nested);
|
|
240
|
+
// Default still clones
|
|
241
|
+
const cloned = store.snapshot();
|
|
242
|
+
expect(cloned.nested).not.toBe((store.state.__raw || store.state).nested);
|
|
243
|
+
});
|
|
244
|
+
|
|
235
245
|
it('replaceState replaces entire state', () => {
|
|
236
246
|
const store = createStore('replace-1', { state: { x: 1, y: 2 } });
|
|
237
247
|
store.replaceState({ x: 10, z: 30 });
|
package/tests/utils.test.js
CHANGED
|
@@ -62,6 +62,27 @@ describe('debounce', () => {
|
|
|
62
62
|
vi.advanceTimersByTime(20);
|
|
63
63
|
expect(fn).toHaveBeenCalledOnce();
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
it('cancels pending invocation when AbortSignal aborts', () => {
|
|
67
|
+
const fn = vi.fn();
|
|
68
|
+
const ctrl = new AbortController();
|
|
69
|
+
const debounced = debounce(fn, 100, { signal: ctrl.signal });
|
|
70
|
+
debounced('x');
|
|
71
|
+
vi.advanceTimersByTime(50);
|
|
72
|
+
ctrl.abort();
|
|
73
|
+
vi.advanceTimersByTime(200);
|
|
74
|
+
expect(fn).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('ignores calls after AbortSignal aborts', () => {
|
|
78
|
+
const fn = vi.fn();
|
|
79
|
+
const ctrl = new AbortController();
|
|
80
|
+
const debounced = debounce(fn, 100, { signal: ctrl.signal });
|
|
81
|
+
ctrl.abort();
|
|
82
|
+
debounced('x');
|
|
83
|
+
vi.advanceTimersByTime(200);
|
|
84
|
+
expect(fn).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
65
86
|
});
|
|
66
87
|
|
|
67
88
|
|
|
@@ -594,6 +615,19 @@ describe('bus (EventBus)', () => {
|
|
|
594
615
|
// ===========================================================================
|
|
595
616
|
|
|
596
617
|
describe('throttle - edge cases', () => {
|
|
618
|
+
it('cancels trailing call when AbortSignal aborts', () => {
|
|
619
|
+
vi.useFakeTimers();
|
|
620
|
+
const fn = vi.fn();
|
|
621
|
+
const ctrl = new AbortController();
|
|
622
|
+
const throttled = throttle(fn, 100, { signal: ctrl.signal });
|
|
623
|
+
throttled('a');
|
|
624
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
625
|
+
throttled('b');
|
|
626
|
+
ctrl.abort();
|
|
627
|
+
vi.advanceTimersByTime(200);
|
|
628
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
629
|
+
});
|
|
630
|
+
|
|
597
631
|
it('fires trailing call after wait period', async () => {
|
|
598
632
|
vi.useFakeTimers();
|
|
599
633
|
const fn = vi.fn();
|
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
// lib/room.js - Backend-less WebRTC room built on BroadcastChannel.
|
|
2
|
-
//
|
|
3
|
-
// Why no backend?
|
|
4
|
-
// WebRTC needs a signaling channel to swap SDP + ICE between peers. We use
|
|
5
|
-
// BroadcastChannel, which lets every same-origin tab on the same browser
|
|
6
|
-
// talk to every other tab for free. Open the demo in 2+ tabs (or windows)
|
|
7
|
-
// and you have a working mesh room with audio, video, screen share, and
|
|
8
|
-
// text chat - zero servers required.
|
|
9
|
-
//
|
|
10
|
-
// Caveats:
|
|
11
|
-
// - BroadcastChannel is same-origin / same-browser only. To connect across
|
|
12
|
-
// machines, plug in any real signaling transport (WebSocket, SSE, etc.)
|
|
13
|
-
// in place of BroadcastChannel and the rest of this file keeps working.
|
|
14
|
-
// - Mesh topology: every peer has an RTCPeerConnection with every other
|
|
15
|
-
// peer. Works great up to ~6 peers; beyond that, use an SFU.
|
|
16
|
-
|
|
17
|
-
const SIGNAL_VERSION = 1;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Wrap a BroadcastChannel as a "SignalingClient" that $.Peer can consume.
|
|
21
|
-
* The Peer class expects `.send(type, payload)` and `.on(type, cb)`.
|
|
22
|
-
*/
|
|
23
|
-
function makeSignaling(channel, myId) {
|
|
24
|
-
return {
|
|
25
|
-
send(type, payload) {
|
|
26
|
-
channel.postMessage({ v: SIGNAL_VERSION, type, from: myId, ...payload });
|
|
27
|
-
},
|
|
28
|
-
on(type, cb) {
|
|
29
|
-
const handler = (ev) => {
|
|
30
|
-
const msg = ev.data;
|
|
31
|
-
if (!msg || msg.v !== SIGNAL_VERSION) return;
|
|
32
|
-
if (msg.type !== type) return;
|
|
33
|
-
if (msg.from === myId) return; // ignore self
|
|
34
|
-
if (msg.to !== undefined && msg.to !== myId) return; // not addressed to us
|
|
35
|
-
cb(msg);
|
|
36
|
-
};
|
|
37
|
-
channel.addEventListener('message', handler);
|
|
38
|
-
return () => channel.removeEventListener('message', handler);
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Backend-less mesh room.
|
|
45
|
-
*
|
|
46
|
-
* Events (subscribe via `.on(type, cb)`):
|
|
47
|
-
* - 'peers' payload: Map<id, PeerInfo> - roster changed
|
|
48
|
-
* - 'chat' payload: { from, name, text, t }
|
|
49
|
-
* - 'status' payload: string - human-readable status line
|
|
50
|
-
* - 'error' payload: Error
|
|
51
|
-
*
|
|
52
|
-
* PeerInfo: { id, name, stream: MediaStream|null, peer: $.Peer, chat: RTCDataChannel|null }
|
|
53
|
-
*/
|
|
54
|
-
export class LocalRoom {
|
|
55
|
-
constructor(name, { id, displayName, iceServers = [] } = {}) {
|
|
56
|
-
this.name = name;
|
|
57
|
-
this.id = id || ('p-' + Math.random().toString(36).slice(2, 10));
|
|
58
|
-
this.displayName = displayName || ('User-' + this.id.slice(-4));
|
|
59
|
-
this.iceServers = iceServers;
|
|
60
|
-
|
|
61
|
-
this._channel = null;
|
|
62
|
-
this._signaling = null;
|
|
63
|
-
this._peers = new Map(); // id -> PeerInfo
|
|
64
|
-
this._localStream = null;
|
|
65
|
-
this._videoSenders = new Map(); // peerId -> RTCRtpSender (current video sender)
|
|
66
|
-
this._listeners = new Map();
|
|
67
|
-
this._heartbeat = null;
|
|
68
|
-
this._unsubHello = null;
|
|
69
|
-
this._unsubBye = null;
|
|
70
|
-
this.closed = false;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ---- Pub/sub ---------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
on(type, cb) {
|
|
76
|
-
if (typeof cb !== 'function') return () => {};
|
|
77
|
-
let set = this._listeners.get(type);
|
|
78
|
-
if (!set) { set = new Set(); this._listeners.set(type, set); }
|
|
79
|
-
set.add(cb);
|
|
80
|
-
return () => set.delete(cb);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
_emit(type, payload) {
|
|
84
|
-
const set = this._listeners.get(type);
|
|
85
|
-
if (!set) return;
|
|
86
|
-
for (const cb of [...set]) { try { cb(payload); } catch (_) {} }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
get peers() { return this._peers; }
|
|
90
|
-
|
|
91
|
-
// ---- Lifecycle -------------------------------------------------------
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Open the BroadcastChannel, announce presence, and start accepting peers.
|
|
95
|
-
* `localStream` is optional - join as a viewer if you have no camera/mic.
|
|
96
|
-
*/
|
|
97
|
-
join(localStream = null) {
|
|
98
|
-
if (this.closed) throw new Error('LocalRoom already closed');
|
|
99
|
-
this._localStream = localStream;
|
|
100
|
-
|
|
101
|
-
this._channel = new BroadcastChannel('zquery-room::' + this.name);
|
|
102
|
-
this._signaling = makeSignaling(this._channel, this.id);
|
|
103
|
-
|
|
104
|
-
// Listen for newcomers' hellos and respond with our own hello so the
|
|
105
|
-
// newcomer learns about us too. Net effect: every pair exchanges
|
|
106
|
-
// hellos exactly once and both sides bring up a peer connection.
|
|
107
|
-
this._unsubHello = this._signaling.on('hello', (m) => {
|
|
108
|
-
// Ignore if we already track this peer.
|
|
109
|
-
if (this._peers.has(m.from)) return;
|
|
110
|
-
this._addPeer(m.from, m.name || 'User');
|
|
111
|
-
// Reply directly so the newcomer adds us.
|
|
112
|
-
this._signaling.send('hello', { to: m.from, name: this.displayName });
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
this._unsubBye = this._signaling.on('bye', (m) => {
|
|
116
|
-
this._removePeer(m.from);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Broadcast hello to the whole room (no `to` field = everyone).
|
|
120
|
-
this._signaling.send('hello', { name: this.displayName });
|
|
121
|
-
this._emit('status', 'Looking for peers in room "' + this.name + '"...');
|
|
122
|
-
|
|
123
|
-
// Periodic hello so late-comers refresh the roster even if they miss
|
|
124
|
-
// the first broadcast (e.g. tab woken from background).
|
|
125
|
-
this._heartbeat = setInterval(() => {
|
|
126
|
-
if (this.closed) return;
|
|
127
|
-
this._signaling.send('hello', { name: this.displayName });
|
|
128
|
-
}, 5000);
|
|
129
|
-
|
|
130
|
-
return this;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
leave() {
|
|
134
|
-
if (this.closed) return;
|
|
135
|
-
this.closed = true;
|
|
136
|
-
|
|
137
|
-
if (this._heartbeat) { clearInterval(this._heartbeat); this._heartbeat = null; }
|
|
138
|
-
if (this._unsubHello) { this._unsubHello(); this._unsubHello = null; }
|
|
139
|
-
if (this._unsubBye) { this._unsubBye(); this._unsubBye = null; }
|
|
140
|
-
|
|
141
|
-
try { this._signaling && this._signaling.send('bye', {}); } catch (_) {}
|
|
142
|
-
|
|
143
|
-
for (const info of this._peers.values()) {
|
|
144
|
-
try { info.peer.close(); } catch (_) {}
|
|
145
|
-
}
|
|
146
|
-
this._peers.clear();
|
|
147
|
-
this._videoSenders.clear();
|
|
148
|
-
this._emit('peers', this._peers);
|
|
149
|
-
|
|
150
|
-
if (this._channel) {
|
|
151
|
-
try { this._channel.close(); } catch (_) {}
|
|
152
|
-
this._channel = null;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ---- Media control ---------------------------------------------------
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Replace our outgoing video track on every peer (used for screen share).
|
|
160
|
-
* Pass `null` to remove video entirely (camera-off).
|
|
161
|
-
*/
|
|
162
|
-
async replaceVideoTrack(newTrack) {
|
|
163
|
-
for (const [id, sender] of this._videoSenders) {
|
|
164
|
-
try { await sender.replaceTrack(newTrack); }
|
|
165
|
-
catch (err) { this._emit('error', err); }
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Broadcast a chat message over every peer's data channel. */
|
|
170
|
-
sendChat(text) {
|
|
171
|
-
const msg = { from: this.id, name: this.displayName, text, t: Date.now() };
|
|
172
|
-
const payload = JSON.stringify(msg);
|
|
173
|
-
for (const info of this._peers.values()) {
|
|
174
|
-
const dc = info.chat;
|
|
175
|
-
if (dc && dc.readyState === 'open') {
|
|
176
|
-
try { dc.send(payload); } catch (_) {}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
// Echo locally so the sender sees their own message.
|
|
180
|
-
this._emit('chat', msg);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ---- Peer plumbing ---------------------------------------------------
|
|
184
|
-
|
|
185
|
-
_addPeer(remoteId, remoteName) {
|
|
186
|
-
// Perfect-negotiation politeness: the peer with the larger id is polite.
|
|
187
|
-
const polite = this.id > remoteId;
|
|
188
|
-
const peer = new window.$.Peer(remoteId, this._signaling, {
|
|
189
|
-
polite,
|
|
190
|
-
iceServers: this.iceServers,
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
/** @type {PeerInfo} */
|
|
194
|
-
const info = { id: remoteId, name: remoteName, stream: null, peer, chat: null };
|
|
195
|
-
this._peers.set(remoteId, info);
|
|
196
|
-
|
|
197
|
-
// Collect remote tracks into a single MediaStream per peer.
|
|
198
|
-
peer.on('track', (ev) => {
|
|
199
|
-
const stream = ev.streams && ev.streams[0]
|
|
200
|
-
? ev.streams[0]
|
|
201
|
-
: (info.stream || new MediaStream([ev.track]));
|
|
202
|
-
info.stream = stream;
|
|
203
|
-
this._emit('peers', this._peers);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// The peer with the smaller id opens the chat channel so we only get
|
|
207
|
-
// one per pair. The other side picks it up via 'datachannel'.
|
|
208
|
-
if (this.id < remoteId) {
|
|
209
|
-
const dc = peer.createDataChannel('chat');
|
|
210
|
-
this._wireChat(info, dc);
|
|
211
|
-
} else {
|
|
212
|
-
peer.on('datachannel', (ev) => this._wireChat(info, ev.channel));
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
peer.on('connectionstatechange', (state) => {
|
|
216
|
-
if (state === 'failed' || state === 'closed') this._removePeer(remoteId);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
peer.on('error', (err) => this._emit('error', err));
|
|
220
|
-
|
|
221
|
-
// Publish local tracks (camera + mic) so the negotiation fires.
|
|
222
|
-
if (this._localStream) {
|
|
223
|
-
for (const track of this._localStream.getTracks()) {
|
|
224
|
-
const sender = peer.addTrack(track, this._localStream);
|
|
225
|
-
if (track.kind === 'video') this._videoSenders.set(remoteId, sender);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
this._emit('peers', this._peers);
|
|
230
|
-
this._emit('status', 'Peer joined: ' + remoteName);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
_wireChat(info, dc) {
|
|
234
|
-
info.chat = dc;
|
|
235
|
-
dc.onmessage = (ev) => {
|
|
236
|
-
try { this._emit('chat', JSON.parse(ev.data)); }
|
|
237
|
-
catch (_) { /* ignore malformed */ }
|
|
238
|
-
};
|
|
239
|
-
dc.onopen = () => this._emit('status', info.name + ' is now connected.');
|
|
240
|
-
dc.onclose = () => { if (info.chat === dc) info.chat = null; };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
_removePeer(remoteId) {
|
|
244
|
-
const info = this._peers.get(remoteId);
|
|
245
|
-
if (!info) return;
|
|
246
|
-
try { info.peer.close(); } catch (_) {}
|
|
247
|
-
this._peers.delete(remoteId);
|
|
248
|
-
this._videoSenders.delete(remoteId);
|
|
249
|
-
this._emit('peers', this._peers);
|
|
250
|
-
this._emit('status', 'Peer left: ' + info.name);
|
|
251
|
-
}
|
|
252
|
-
}
|