zero-query 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -442
- package/cli/commands/build.js +254 -247
- package/cli/commands/bundle.js +1228 -1224
- 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 -220
- 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 +661 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6614
- package/dist/zquery.min.js +8 -631
- package/index.d.ts +570 -371
- package/index.js +311 -240
- package/package.json +76 -70
- package/src/component.js +1709 -1691
- 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 -255
- package/src/router.js +843 -843
- package/src/ssr.js +418 -418
- package/src/store.js +318 -318
- 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 -1103
- package/tests/compare.test.js +497 -486
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -489
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -1650
- package/tests/electron-features.test.js +864 -864
- 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 -146
- package/types/utils.d.ts +245 -245
- package/types/webrtc.d.ts +653 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// video-room.js - Mini-Discord style room: video tiles + screen share + chat.
|
|
2
|
+
//
|
|
3
|
+
// Backed by app/lib/room.js (BroadcastChannel signaling) so multiple tabs
|
|
4
|
+
// on the same browser form a working mesh room with no server at all.
|
|
5
|
+
|
|
6
|
+
import { LocalRoom } from '../lib/room.js';
|
|
7
|
+
|
|
8
|
+
$.component('video-room', {
|
|
9
|
+
state: () => ({
|
|
10
|
+
// Pre-join form ----------------------------------------------------
|
|
11
|
+
roomName: 'lobby',
|
|
12
|
+
displayName: 'User-' + Math.random().toString(36).slice(2, 6),
|
|
13
|
+
// Live state -------------------------------------------------------
|
|
14
|
+
joined: false,
|
|
15
|
+
status: 'Pick a room + name and click Join. Open this URL in a second tab to see a peer appear.',
|
|
16
|
+
error: '',
|
|
17
|
+
// Local media ------------------------------------------------------
|
|
18
|
+
localStream: null,
|
|
19
|
+
micOn: true,
|
|
20
|
+
camOn: true,
|
|
21
|
+
sharing: false,
|
|
22
|
+
// Roster + chat ----------------------------------------------------
|
|
23
|
+
peers: [], // [{ id, name, stream }]
|
|
24
|
+
messages: [], // [{ from, name, text, t, mine }]
|
|
25
|
+
draft: '',
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
mounted() {
|
|
29
|
+
this._room = null;
|
|
30
|
+
this._cameraTrack = null; // original camera video track (kept while screen sharing)
|
|
31
|
+
this._screenStream = null; // current display-media stream (cleared on stop)
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async destroyed() {
|
|
35
|
+
await this._teardown();
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// ---- Pre-join form bindings -----------------------------------------
|
|
39
|
+
|
|
40
|
+
setRoom(e) { this.setState({ roomName: e.target.value }); },
|
|
41
|
+
setName(e) { this.setState({ displayName: e.target.value }); },
|
|
42
|
+
|
|
43
|
+
// ---- Join / leave ----------------------------------------------------
|
|
44
|
+
|
|
45
|
+
async join() {
|
|
46
|
+
if (this.state.joined) return;
|
|
47
|
+
this.setState({ status: 'Requesting camera + microphone...', error: '' });
|
|
48
|
+
|
|
49
|
+
let stream = null;
|
|
50
|
+
try {
|
|
51
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// Joining without media is still useful (viewer + chat only).
|
|
54
|
+
this.setState({ status: 'No camera/mic - joining as viewer.', error: '' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cam = stream && stream.getVideoTracks()[0];
|
|
58
|
+
if (cam) this._cameraTrack = cam;
|
|
59
|
+
|
|
60
|
+
this._room = new LocalRoom(this.state.roomName, { displayName: this.state.displayName });
|
|
61
|
+
this._room.on('peers', (peers) => this._onPeers(peers));
|
|
62
|
+
this._room.on('chat', (msg) => this._onChat(msg));
|
|
63
|
+
this._room.on('status', (status) => this.setState({ status }));
|
|
64
|
+
this._room.on('error', (err) => this.setState({ error: String(err && err.message || err) }));
|
|
65
|
+
this._room.join(stream);
|
|
66
|
+
|
|
67
|
+
this.setState({
|
|
68
|
+
joined: true,
|
|
69
|
+
localStream: stream,
|
|
70
|
+
micOn: !!(stream && stream.getAudioTracks()[0]),
|
|
71
|
+
camOn: !!cam,
|
|
72
|
+
sharing: false,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async leave() {
|
|
77
|
+
await this._teardown();
|
|
78
|
+
this.setState({
|
|
79
|
+
joined: false,
|
|
80
|
+
localStream: null,
|
|
81
|
+
peers: [],
|
|
82
|
+
messages: [],
|
|
83
|
+
sharing: false,
|
|
84
|
+
status: 'Left the room. Click Join to reconnect.',
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async _teardown() {
|
|
89
|
+
if (this._room) { try { this._room.leave(); } catch (_) {} this._room = null; }
|
|
90
|
+
if (this._screenStream) {
|
|
91
|
+
for (const t of this._screenStream.getTracks()) { try { t.stop(); } catch (_) {} }
|
|
92
|
+
this._screenStream = null;
|
|
93
|
+
}
|
|
94
|
+
if (this.state.localStream) {
|
|
95
|
+
for (const t of this.state.localStream.getTracks()) { try { t.stop(); } catch (_) {} }
|
|
96
|
+
}
|
|
97
|
+
this._cameraTrack = null;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// ---- Mic / cam toggles ----------------------------------------------
|
|
101
|
+
|
|
102
|
+
toggleMic() {
|
|
103
|
+
const stream = this.state.localStream;
|
|
104
|
+
if (!stream) return;
|
|
105
|
+
const next = !this.state.micOn;
|
|
106
|
+
for (const t of stream.getAudioTracks()) t.enabled = next;
|
|
107
|
+
this.setState({ micOn: next });
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
toggleCam() {
|
|
111
|
+
const stream = this.state.localStream;
|
|
112
|
+
if (!stream) return;
|
|
113
|
+
const next = !this.state.camOn;
|
|
114
|
+
for (const t of stream.getVideoTracks()) t.enabled = next;
|
|
115
|
+
this.setState({ camOn: next });
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
// ---- Screen share ----------------------------------------------------
|
|
119
|
+
|
|
120
|
+
async toggleShare() {
|
|
121
|
+
if (!this._room) return;
|
|
122
|
+
if (this.state.sharing) {
|
|
123
|
+
// Stop sharing: revert every peer to the camera track.
|
|
124
|
+
if (this._screenStream) {
|
|
125
|
+
for (const t of this._screenStream.getTracks()) { try { t.stop(); } catch (_) {} }
|
|
126
|
+
this._screenStream = null;
|
|
127
|
+
}
|
|
128
|
+
await this._room.replaceVideoTrack(this._cameraTrack || null);
|
|
129
|
+
this.setState({ sharing: false, status: 'Stopped sharing screen.' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
|
|
135
|
+
this._screenStream = stream;
|
|
136
|
+
const shareTrack = stream.getVideoTracks()[0];
|
|
137
|
+
// When the user clicks the browser's native "Stop sharing", flip back.
|
|
138
|
+
shareTrack.onended = () => { if (this.state.sharing) this.toggleShare(); };
|
|
139
|
+
await this._room.replaceVideoTrack(shareTrack);
|
|
140
|
+
this.setState({ sharing: true, status: 'Sharing your screen.' });
|
|
141
|
+
} catch (err) {
|
|
142
|
+
this.setState({ error: 'Screen share denied or unavailable.' });
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// ---- Roster + chat ---------------------------------------------------
|
|
147
|
+
|
|
148
|
+
_onPeers(peersMap) {
|
|
149
|
+
const list = Array.from(peersMap.values()).map((p) => ({
|
|
150
|
+
id: p.id, name: p.name, stream: p.stream,
|
|
151
|
+
}));
|
|
152
|
+
this.setState({ peers: list });
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
_onChat(msg) {
|
|
156
|
+
const mine = this._room && msg.from === this._room.id;
|
|
157
|
+
const next = this.state.messages.concat([{ ...msg, mine }]);
|
|
158
|
+
// Cap history so a long-running room doesn't grow forever.
|
|
159
|
+
if (next.length > 200) next.splice(0, next.length - 200);
|
|
160
|
+
this.setState({ messages: next });
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
setDraft(e) { this.setState({ draft: e.target.value }); },
|
|
164
|
+
|
|
165
|
+
sendChat(e) {
|
|
166
|
+
if (e && e.preventDefault) e.preventDefault();
|
|
167
|
+
const text = (this.state.draft || '').trim();
|
|
168
|
+
if (!text || !this._room) return;
|
|
169
|
+
this._room.sendChat(text);
|
|
170
|
+
this.setState({ draft: '' });
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
// ---- Render ----------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
render() {
|
|
176
|
+
if (!this.state.joined) return this._renderLobby();
|
|
177
|
+
return this._renderRoom();
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
_renderLobby() {
|
|
181
|
+
const { roomName, displayName, status, error } = this.state;
|
|
182
|
+
return `
|
|
183
|
+
<div class="lobby">
|
|
184
|
+
<h1>zQuery WebRTC Demo</h1>
|
|
185
|
+
<p class="lead">
|
|
186
|
+
A mini, no-backend room. Signaling runs over
|
|
187
|
+
<code>BroadcastChannel</code>, so opening this page in
|
|
188
|
+
multiple tabs / windows gives you a working mesh call
|
|
189
|
+
with audio, video, screen share, and chat — no
|
|
190
|
+
server required.
|
|
191
|
+
</p>
|
|
192
|
+
<form class="join-form" @submit="join">
|
|
193
|
+
<label>
|
|
194
|
+
Room
|
|
195
|
+
<input type="text" value="${$.escapeHtml(roomName)}" @input="setRoom" placeholder="lobby" />
|
|
196
|
+
</label>
|
|
197
|
+
<label>
|
|
198
|
+
Your name
|
|
199
|
+
<input type="text" value="${$.escapeHtml(displayName)}" @input="setName" placeholder="display name" />
|
|
200
|
+
</label>
|
|
201
|
+
<button type="button" class="primary" @click="join">Join room</button>
|
|
202
|
+
</form>
|
|
203
|
+
<p class="status ${error ? 'error' : ''}">
|
|
204
|
+
${error ? $.escapeHtml(error) : $.escapeHtml(status)}
|
|
205
|
+
</p>
|
|
206
|
+
</div>
|
|
207
|
+
`;
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
_renderRoom() {
|
|
211
|
+
const { localStream, peers, status, error, micOn, camOn, sharing, messages, draft, displayName, roomName } = this.state;
|
|
212
|
+
|
|
213
|
+
const peerCount = peers.length + 1; // include self
|
|
214
|
+
const peerTiles = peers.map((p, i) => `
|
|
215
|
+
<div class="tile">
|
|
216
|
+
<video z-stream="peers[${i}].stream" autoplay playsinline></video>
|
|
217
|
+
<div class="label">${$.escapeHtml(p.name || p.id)}</div>
|
|
218
|
+
</div>
|
|
219
|
+
`).join('');
|
|
220
|
+
|
|
221
|
+
const chatLines = messages.map((m) => `
|
|
222
|
+
<div class="msg ${m.mine ? 'mine' : ''}">
|
|
223
|
+
<span class="who">${$.escapeHtml(m.name)}</span>
|
|
224
|
+
<span class="text">${$.escapeHtml(m.text)}</span>
|
|
225
|
+
</div>
|
|
226
|
+
`).join('');
|
|
227
|
+
|
|
228
|
+
return `
|
|
229
|
+
<div class="room">
|
|
230
|
+
<aside class="sidebar">
|
|
231
|
+
<div class="room-meta">
|
|
232
|
+
<div class="room-name">#${$.escapeHtml(roomName)}</div>
|
|
233
|
+
<div class="room-sub">${peerCount} ${peerCount === 1 ? 'person' : 'people'}</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div class="roster">
|
|
236
|
+
<div class="roster-row me">
|
|
237
|
+
<span class="dot ${micOn ? 'on' : 'off'}"></span>
|
|
238
|
+
${$.escapeHtml(displayName)} <small>(you)</small>
|
|
239
|
+
</div>
|
|
240
|
+
${peers.map((p) => `
|
|
241
|
+
<div class="roster-row">
|
|
242
|
+
<span class="dot on"></span>
|
|
243
|
+
${$.escapeHtml(p.name || p.id)}
|
|
244
|
+
</div>
|
|
245
|
+
`).join('')}
|
|
246
|
+
</div>
|
|
247
|
+
<button class="leave" @click="leave">Leave room</button>
|
|
248
|
+
</aside>
|
|
249
|
+
|
|
250
|
+
<section class="stage">
|
|
251
|
+
<div class="tiles">
|
|
252
|
+
<div class="tile self">
|
|
253
|
+
<video z-stream="localStream" autoplay playsinline muted></video>
|
|
254
|
+
<div class="label">You${sharing ? ' · sharing' : ''}</div>
|
|
255
|
+
${!camOn && !sharing ? '<div class="camoff">Camera off</div>' : ''}
|
|
256
|
+
</div>
|
|
257
|
+
${peerTiles}
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<div class="controls">
|
|
261
|
+
<button class="${micOn ? '' : 'off'}" @click="toggleMic">
|
|
262
|
+
${micOn ? '🎤 Mute' : '🔇 Unmute'}
|
|
263
|
+
</button>
|
|
264
|
+
<button class="${camOn ? '' : 'off'}" @click="toggleCam">
|
|
265
|
+
${camOn ? '📷 Stop video' : '🚫 Start video'}
|
|
266
|
+
</button>
|
|
267
|
+
<button class="${sharing ? 'active' : ''}" @click="toggleShare">
|
|
268
|
+
${sharing ? '🛑 Stop share' : '🖥️ Share screen'}
|
|
269
|
+
</button>
|
|
270
|
+
<div class="status-inline ${error ? 'error' : ''}">
|
|
271
|
+
${error ? $.escapeHtml(error) : $.escapeHtml(status)}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</section>
|
|
275
|
+
|
|
276
|
+
<aside class="chat">
|
|
277
|
+
<div class="chat-header">Chat</div>
|
|
278
|
+
<div class="chat-log" id="chat-log">
|
|
279
|
+
${chatLines || '<div class="empty">No messages yet. Say hi 👋</div>'}
|
|
280
|
+
</div>
|
|
281
|
+
<form class="chat-form" @submit="sendChat">
|
|
282
|
+
<input type="text" value="${$.escapeHtml(draft)}" @input="setDraft" placeholder="Message #${$.escapeHtml(roomName)}" />
|
|
283
|
+
<button type="button" class="primary" @click="sendChat">Send</button>
|
|
284
|
+
</form>
|
|
285
|
+
</aside>
|
|
286
|
+
</div>
|
|
287
|
+
`;
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
updated() {
|
|
291
|
+
// Auto-scroll chat to the latest message after each render.
|
|
292
|
+
const log = this.$el && this.$el.querySelector ? this.$el.querySelector('#chat-log') : null;
|
|
293
|
+
if (log) log.scrollTop = log.scrollHeight;
|
|
294
|
+
},
|
|
295
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
}
|
|
File without changes
|