@zero-server/sdk 0.9.6 → 0.9.8
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/README.md +54 -53
- package/index.js +116 -4
- package/lib/app.js +22 -22
- package/lib/auth/authorize.js +11 -11
- package/lib/auth/enrollment.js +5 -5
- package/lib/auth/jwt.js +9 -9
- package/lib/auth/oauth.js +1 -1
- package/lib/auth/session.js +5 -5
- package/lib/auth/trustedDevice.js +2 -2
- package/lib/auth/twoFactor.js +11 -11
- package/lib/auth/webauthn.js +6 -6
- package/lib/body/json.js +1 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/rawBuffer.js +1 -1
- package/lib/body/text.js +1 -1
- package/lib/body/urlencoded.js +3 -3
- package/lib/cli.js +43 -28
- package/lib/cluster.js +3 -3
- package/lib/debug.js +10 -10
- package/lib/env/index.js +11 -11
- package/lib/errors.js +131 -16
- package/lib/fetch/index.js +1 -1
- package/lib/grpc/call.js +14 -14
- package/lib/grpc/client.js +4 -4
- package/lib/grpc/codec.js +7 -7
- package/lib/grpc/credentials.js +2 -2
- package/lib/grpc/frame.js +2 -2
- package/lib/grpc/health.js +3 -3
- package/lib/grpc/index.js +3 -3
- package/lib/grpc/metadata.js +3 -3
- package/lib/grpc/proto.js +5 -5
- package/lib/grpc/reflection.js +2 -2
- package/lib/grpc/server.js +3 -3
- package/lib/grpc/status.js +2 -2
- package/lib/grpc/watch.js +1 -1
- package/lib/http/request.js +13 -13
- package/lib/http/response.js +2 -2
- package/lib/lifecycle.js +5 -5
- package/lib/middleware/compress.js +4 -4
- package/lib/observe/health.js +1 -1
- package/lib/observe/index.js +1 -1
- package/lib/observe/logger.js +3 -3
- package/lib/observe/metrics.js +4 -4
- package/lib/observe/tracing.js +4 -4
- package/lib/orm/adapters/json.js +1 -1
- package/lib/orm/adapters/memory.js +2 -2
- package/lib/orm/adapters/mongo.js +2 -2
- package/lib/orm/adapters/mysql.js +2 -2
- package/lib/orm/adapters/postgres.js +2 -2
- package/lib/orm/adapters/sqlite.js +3 -3
- package/lib/orm/audit.js +1 -1
- package/lib/orm/index.js +7 -7
- package/lib/orm/migrate.js +1 -1
- package/lib/orm/model.js +15 -15
- package/lib/orm/procedures.js +1 -1
- package/lib/orm/profiler.js +1 -1
- package/lib/orm/query.js +9 -9
- package/lib/orm/schema.js +1 -1
- package/lib/orm/seed/data/person.js +1 -1
- package/lib/orm/seed/fake.js +10 -10
- package/lib/orm/seed/index.js +4 -4
- package/lib/orm/seed/rng.js +1 -1
- package/lib/orm/snapshot.js +3 -3
- package/lib/orm/tenancy.js +6 -6
- package/lib/orm/views.js +1 -1
- package/lib/router/index.js +9 -9
- package/lib/webrtc/bot.js +405 -0
- package/lib/webrtc/cli.js +182 -0
- package/lib/webrtc/cluster.js +338 -0
- package/lib/webrtc/e2ee.js +274 -0
- package/lib/webrtc/ice.js +363 -0
- package/lib/webrtc/index.js +212 -0
- package/lib/webrtc/joinToken.js +171 -0
- package/lib/webrtc/observe.js +260 -0
- package/lib/webrtc/peer.js +143 -0
- package/lib/webrtc/room.js +184 -0
- package/lib/webrtc/sdp.js +503 -0
- package/lib/webrtc/sfu/index.js +251 -0
- package/lib/webrtc/sfu/livekit.js +304 -0
- package/lib/webrtc/sfu/mediasoup.js +357 -0
- package/lib/webrtc/sfu/memory.js +221 -0
- package/lib/webrtc/signaling.js +590 -0
- package/lib/webrtc/stun.js +484 -0
- package/lib/webrtc/turn/codec.js +370 -0
- package/lib/webrtc/turn/credentials.js +156 -0
- package/lib/webrtc/turn/server.js +648 -0
- package/package.json +2 -2
- package/types/body.d.ts +82 -14
- package/types/cli.d.ts +40 -2
- package/types/index.d.ts +19 -6
- package/types/middleware.d.ts +18 -72
- package/types/orm.d.ts +4 -13
- package/types/request.d.ts +3 -3
- package/types/webrtc.d.ts +501 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/bot
|
|
3
|
+
* @description Server-side WebRTC peer ("bot") built on the `wrtc`
|
|
4
|
+
* peerDependency. `spawnBotPeer({ hub, room, ... })` attaches an
|
|
5
|
+
* in-process peer that joins a room and drives a real
|
|
6
|
+
* `RTCPeerConnection` per remote peer. Bidirectional — use for
|
|
7
|
+
* recording, transcription, AI participants, or SFU verification.
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { EventEmitter } = require('node:events');
|
|
12
|
+
const { WebRTCError } = require('../errors');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Spawn a server-side bot peer that joins `room` on the given hub.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {object} opts.hub The {@link SignalingHub} instance.
|
|
19
|
+
* @param {string} opts.room Room name to join.
|
|
20
|
+
* @param {*} [opts.user] Opaque user object attached to the peer.
|
|
21
|
+
* @param {string} [opts.ip='127.0.0.1'] IP recorded on the attached peer.
|
|
22
|
+
* @param {string} [opts.joinToken] Optional join token forwarded to the hub.
|
|
23
|
+
* @param {Array} [opts.iceServers=[]] RTCConfiguration.iceServers.
|
|
24
|
+
* @param {object} [opts.rtcConfig] Additional RTCConfiguration fields.
|
|
25
|
+
* @param {object} [opts.wrtc] Injected `wrtc` module (testing).
|
|
26
|
+
* @param {Function} [opts.onTrack] (track, streams, fromPeerId) => void
|
|
27
|
+
* @param {Function} [opts.onDataChannel] (channel, fromPeerId) => void
|
|
28
|
+
* @param {Function} [opts.onPeerJoin] (remotePeerId) => void
|
|
29
|
+
* @param {Function} [opts.onPeerLeave] (remotePeerId) => void
|
|
30
|
+
* @param {Function} [opts.onError] (err) => void (non-fatal errors)
|
|
31
|
+
* @returns {{
|
|
32
|
+
* peer: object,
|
|
33
|
+
* peerConnections: Map<string, object>,
|
|
34
|
+
* getPeerConnection: (remotePeerId: string) => object | undefined,
|
|
35
|
+
* ready: Promise<{ peerId: string }>,
|
|
36
|
+
* close: () => void,
|
|
37
|
+
* }}
|
|
38
|
+
*
|
|
39
|
+
* @example | Recording bot: dump every inbound audio track to a file
|
|
40
|
+
* const { spawnBotPeer } = require('@zero-server/webrtc');
|
|
41
|
+
* const { RTCAudioSink } = require('@roamhq/wrtc').nonstandard;
|
|
42
|
+
* const fs = require('node:fs');
|
|
43
|
+
*
|
|
44
|
+
* const bot = spawnBotPeer({
|
|
45
|
+
* hub,
|
|
46
|
+
* room: 'standup',
|
|
47
|
+
* user: { id: 'recorder' },
|
|
48
|
+
* iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
49
|
+
* onTrack: (track, _streams, fromPeerId) => {
|
|
50
|
+
* if (track.kind !== 'audio') return;
|
|
51
|
+
* const sink = new RTCAudioSink(track);
|
|
52
|
+
* const out = fs.createWriteStream(`./recordings/${fromPeerId}.pcm`);
|
|
53
|
+
* sink.ondata = ({ samples }) => out.write(Buffer.from(samples.buffer));
|
|
54
|
+
* track.onended = () => { sink.stop(); out.end(); };
|
|
55
|
+
* },
|
|
56
|
+
* });
|
|
57
|
+
*
|
|
58
|
+
* await bot.ready; // resolves with { peerId } once the hub welcomes us
|
|
59
|
+
* process.on('SIGTERM', () => bot.close());
|
|
60
|
+
*
|
|
61
|
+
* @example | AI participant: push synthesized audio back to the room
|
|
62
|
+
* const { spawnBotPeer } = require('@zero-server/webrtc');
|
|
63
|
+
* const { RTCAudioSource } = require('@roamhq/wrtc').nonstandard;
|
|
64
|
+
*
|
|
65
|
+
* const source = new RTCAudioSource();
|
|
66
|
+
* const track = source.createTrack();
|
|
67
|
+
*
|
|
68
|
+
* const bot = spawnBotPeer({
|
|
69
|
+
* hub, room: 'lounge', user: { id: 'ai-host' },
|
|
70
|
+
* onPeerJoin: (remoteId) => {
|
|
71
|
+
* const pc = bot.getPeerConnection(remoteId);
|
|
72
|
+
* if (pc) pc.addTrack(track); // perfect-negotiation will renegotiate
|
|
73
|
+
* },
|
|
74
|
+
* });
|
|
75
|
+
*
|
|
76
|
+
* // Feed synthesized PCM frames every 10ms.
|
|
77
|
+
* setInterval(() => source.onData({
|
|
78
|
+
* samples: ttsNextFrame(), // Int16Array(160)
|
|
79
|
+
* sampleRate: 16000,
|
|
80
|
+
* bitsPerSample: 16,
|
|
81
|
+
* channelCount: 1,
|
|
82
|
+
* numberOfFrames: 160,
|
|
83
|
+
* }), 10);
|
|
84
|
+
*
|
|
85
|
+
* @example | Inject a fake `wrtc` for unit tests
|
|
86
|
+
* const bot = spawnBotPeer({ hub, room: 'test', wrtc: fakeWrtc });
|
|
87
|
+
* await bot.ready;
|
|
88
|
+
* expect(hub.room('test').size).toBe(1);
|
|
89
|
+
* bot.close();
|
|
90
|
+
*/
|
|
91
|
+
function spawnBotPeer(opts)
|
|
92
|
+
{
|
|
93
|
+
const o = opts || {};
|
|
94
|
+
if (!o.hub || typeof o.hub.attach !== 'function')
|
|
95
|
+
{
|
|
96
|
+
throw new WebRTCError('spawnBotPeer requires { hub }', { code: 'WEBRTC_BOT_INVALID_CONFIG' });
|
|
97
|
+
}
|
|
98
|
+
if (!o.room || typeof o.room !== 'string')
|
|
99
|
+
{
|
|
100
|
+
throw new WebRTCError('spawnBotPeer requires { room }', { code: 'WEBRTC_BOT_INVALID_CONFIG' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const wrtc = o.wrtc || _tryRequireWrtc();
|
|
104
|
+
const { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } = wrtc;
|
|
105
|
+
if (typeof RTCPeerConnection !== 'function')
|
|
106
|
+
{
|
|
107
|
+
throw new WebRTCError(
|
|
108
|
+
"spawnBotPeer: provided 'wrtc' module is missing RTCPeerConnection",
|
|
109
|
+
{ code: 'WEBRTC_BOT_INVALID_WRTC' },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rtcConfig = {
|
|
114
|
+
iceServers: Array.isArray(o.iceServers) ? o.iceServers : [],
|
|
115
|
+
...(o.rtcConfig || {}),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const onTrack = typeof o.onTrack === 'function' ? o.onTrack : null;
|
|
119
|
+
const onDataChannel = typeof o.onDataChannel === 'function' ? o.onDataChannel : null;
|
|
120
|
+
const onPeerJoin = typeof o.onPeerJoin === 'function' ? o.onPeerJoin : null;
|
|
121
|
+
const onPeerLeave = typeof o.onPeerLeave === 'function' ? o.onPeerLeave : null;
|
|
122
|
+
const onError = typeof o.onError === 'function' ? o.onError : (() => {});
|
|
123
|
+
|
|
124
|
+
// In-process transport that satisfies the hub's contract.
|
|
125
|
+
const transport = new BotTransport();
|
|
126
|
+
|
|
127
|
+
const pcs = new Map(); // remotePeerId -> RTCPeerConnection
|
|
128
|
+
let myPeerId = null;
|
|
129
|
+
let closed = false;
|
|
130
|
+
let resolveReady;
|
|
131
|
+
let rejectReady;
|
|
132
|
+
const ready = new Promise((res, rej) => { resolveReady = res; rejectReady = rej; });
|
|
133
|
+
|
|
134
|
+
function pushToHub(msg)
|
|
135
|
+
{
|
|
136
|
+
if (closed) return;
|
|
137
|
+
try { transport._inject(msg); }
|
|
138
|
+
catch (err) { onError(err); }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getOrCreatePc(remoteId)
|
|
142
|
+
{
|
|
143
|
+
let pc = pcs.get(remoteId);
|
|
144
|
+
if (pc) return pc;
|
|
145
|
+
pc = new RTCPeerConnection(rtcConfig);
|
|
146
|
+
pcs.set(remoteId, pc);
|
|
147
|
+
|
|
148
|
+
pc.onicecandidate = (ev) =>
|
|
149
|
+
{
|
|
150
|
+
if (ev && ev.candidate && ev.candidate.candidate)
|
|
151
|
+
{
|
|
152
|
+
pushToHub({
|
|
153
|
+
type: 'ice',
|
|
154
|
+
target: remoteId,
|
|
155
|
+
candidate: ev.candidate.candidate,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
if (onTrack)
|
|
160
|
+
{
|
|
161
|
+
pc.ontrack = (ev) =>
|
|
162
|
+
{
|
|
163
|
+
try { onTrack(ev.track, ev.streams || [], remoteId); }
|
|
164
|
+
catch (err) { onError(err); }
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (onDataChannel)
|
|
168
|
+
{
|
|
169
|
+
pc.ondatachannel = (ev) =>
|
|
170
|
+
{
|
|
171
|
+
try { onDataChannel(ev.channel, remoteId); }
|
|
172
|
+
catch (err) { onError(err); }
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return pc;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function offerTo(remoteId)
|
|
179
|
+
{
|
|
180
|
+
try
|
|
181
|
+
{
|
|
182
|
+
const pc = getOrCreatePc(remoteId);
|
|
183
|
+
const offer = await pc.createOffer();
|
|
184
|
+
await pc.setLocalDescription(offer);
|
|
185
|
+
pushToHub({ type: 'offer', target: remoteId, sdp: pc.localDescription.sdp });
|
|
186
|
+
}
|
|
187
|
+
catch (err) { onError(err); }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function answerTo(remoteId, sdp)
|
|
191
|
+
{
|
|
192
|
+
try
|
|
193
|
+
{
|
|
194
|
+
const pc = getOrCreatePc(remoteId);
|
|
195
|
+
await pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp }));
|
|
196
|
+
const answer = await pc.createAnswer();
|
|
197
|
+
await pc.setLocalDescription(answer);
|
|
198
|
+
pushToHub({ type: 'answer', target: remoteId, sdp: pc.localDescription.sdp });
|
|
199
|
+
}
|
|
200
|
+
catch (err) { onError(err); }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function applyAnswer(remoteId, sdp)
|
|
204
|
+
{
|
|
205
|
+
try
|
|
206
|
+
{
|
|
207
|
+
const pc = pcs.get(remoteId);
|
|
208
|
+
if (!pc) return;
|
|
209
|
+
await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp }));
|
|
210
|
+
}
|
|
211
|
+
catch (err) { onError(err); }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function applyIce(remoteId, candidate)
|
|
215
|
+
{
|
|
216
|
+
try
|
|
217
|
+
{
|
|
218
|
+
const pc = pcs.get(remoteId);
|
|
219
|
+
if (!pc || !candidate) return;
|
|
220
|
+
await pc.addIceCandidate(new RTCIceCandidate({ candidate, sdpMid: '0', sdpMLineIndex: 0 }));
|
|
221
|
+
}
|
|
222
|
+
catch (err) { onError(err); }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function dropPc(remoteId)
|
|
226
|
+
{
|
|
227
|
+
const pc = pcs.get(remoteId);
|
|
228
|
+
if (!pc) return;
|
|
229
|
+
try { pc.close(); } catch (_) { /* noop */ }
|
|
230
|
+
pcs.delete(remoteId);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The hub calls `transport.send(json)` for every outbound message.
|
|
234
|
+
// We intercept those, parse them, and drive the negotiation state machine.
|
|
235
|
+
transport._onOutbound = (data) =>
|
|
236
|
+
{
|
|
237
|
+
let msg;
|
|
238
|
+
try { msg = JSON.parse(data); }
|
|
239
|
+
catch (err) { onError(err); return; }
|
|
240
|
+
|
|
241
|
+
switch (msg.type)
|
|
242
|
+
{
|
|
243
|
+
case 'hello':
|
|
244
|
+
myPeerId = msg.peerId;
|
|
245
|
+
pushToHub({ type: 'join', room: o.room, token: o.joinToken });
|
|
246
|
+
break;
|
|
247
|
+
|
|
248
|
+
case 'joined':
|
|
249
|
+
if (resolveReady)
|
|
250
|
+
{
|
|
251
|
+
resolveReady({ peerId: myPeerId });
|
|
252
|
+
resolveReady = null;
|
|
253
|
+
rejectReady = null;
|
|
254
|
+
}
|
|
255
|
+
// Existing peers in the room - bot is the newcomer, so it offers first.
|
|
256
|
+
// The hub's `peers` list includes the bot itself; skip self.
|
|
257
|
+
if (Array.isArray(msg.peers))
|
|
258
|
+
{
|
|
259
|
+
for (const id of msg.peers)
|
|
260
|
+
{
|
|
261
|
+
if (id !== myPeerId) offerTo(id);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case 'peer-joined':
|
|
267
|
+
if (onPeerJoin)
|
|
268
|
+
{
|
|
269
|
+
try { onPeerJoin(msg.id); }
|
|
270
|
+
catch (err) { onError(err); }
|
|
271
|
+
}
|
|
272
|
+
// New peer joined after us - they are the newcomer and will offer; we wait.
|
|
273
|
+
break;
|
|
274
|
+
|
|
275
|
+
case 'peer-left':
|
|
276
|
+
dropPc(msg.id);
|
|
277
|
+
if (onPeerLeave)
|
|
278
|
+
{
|
|
279
|
+
try { onPeerLeave(msg.id); }
|
|
280
|
+
catch (err) { onError(err); }
|
|
281
|
+
}
|
|
282
|
+
break;
|
|
283
|
+
|
|
284
|
+
case 'offer':
|
|
285
|
+
answerTo(msg.from, msg.sdp);
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
case 'answer':
|
|
289
|
+
applyAnswer(msg.from, msg.sdp);
|
|
290
|
+
break;
|
|
291
|
+
|
|
292
|
+
case 'ice':
|
|
293
|
+
applyIce(msg.from, msg.candidate);
|
|
294
|
+
break;
|
|
295
|
+
|
|
296
|
+
case 'error':
|
|
297
|
+
if (rejectReady)
|
|
298
|
+
{
|
|
299
|
+
rejectReady(new WebRTCError(
|
|
300
|
+
`bot peer error: ${msg.message || msg.code}`,
|
|
301
|
+
{ code: msg.code || 'WEBRTC_BOT_HUB_ERROR' },
|
|
302
|
+
));
|
|
303
|
+
rejectReady = null;
|
|
304
|
+
resolveReady = null;
|
|
305
|
+
}
|
|
306
|
+
onError(new WebRTCError(msg.message || msg.code, { code: msg.code || 'WEBRTC_BOT_HUB_ERROR' }));
|
|
307
|
+
break;
|
|
308
|
+
|
|
309
|
+
default:
|
|
310
|
+
// Unhandled message types are passed through silently; tests / consumers
|
|
311
|
+
// can subscribe to `peer` events on the hub if they need them.
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Attach AFTER the outbound handler is wired so that the synchronous
|
|
317
|
+
// `hello` frame the hub sends inside attach() is delivered to us.
|
|
318
|
+
const peer = o.hub.attach(transport, { user: o.user || null, ip: o.ip || '127.0.0.1' });
|
|
319
|
+
|
|
320
|
+
function close()
|
|
321
|
+
{
|
|
322
|
+
if (closed) return;
|
|
323
|
+
closed = true;
|
|
324
|
+
for (const id of Array.from(pcs.keys())) dropPc(id);
|
|
325
|
+
try { transport.close(1000, 'bot-close'); } catch (_) { /* noop */ }
|
|
326
|
+
if (rejectReady)
|
|
327
|
+
{
|
|
328
|
+
rejectReady(new WebRTCError('bot peer closed before ready', { code: 'WEBRTC_BOT_CLOSED' }));
|
|
329
|
+
rejectReady = null;
|
|
330
|
+
resolveReady = null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
peer,
|
|
336
|
+
peerConnections: pcs,
|
|
337
|
+
getPeerConnection: (remoteId) => pcs.get(remoteId),
|
|
338
|
+
ready,
|
|
339
|
+
close,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* @private
|
|
345
|
+
* In-process transport that bridges the hub <-> bot peer.
|
|
346
|
+
*
|
|
347
|
+
* The hub calls `send(string)` for every outbound message; the bot
|
|
348
|
+
* sets `_onOutbound` to receive those messages. The bot uses
|
|
349
|
+
* `_inject(obj)` to push inbound messages back to the hub (which
|
|
350
|
+
* listens via the standard `'message'` event).
|
|
351
|
+
*/
|
|
352
|
+
class BotTransport extends EventEmitter
|
|
353
|
+
{
|
|
354
|
+
constructor()
|
|
355
|
+
{
|
|
356
|
+
super();
|
|
357
|
+
this.closed = false;
|
|
358
|
+
this._onOutbound = null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
send(data)
|
|
362
|
+
{
|
|
363
|
+
if (this.closed) return;
|
|
364
|
+
if (typeof this._onOutbound === 'function')
|
|
365
|
+
{
|
|
366
|
+
try { this._onOutbound(data); }
|
|
367
|
+
catch (_) { /* swallow */ }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
_inject(obj)
|
|
372
|
+
{
|
|
373
|
+
if (this.closed) return;
|
|
374
|
+
const data = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
|
375
|
+
this.emit('message', data);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
close(code, reason)
|
|
379
|
+
{
|
|
380
|
+
if (this.closed) return;
|
|
381
|
+
this.closed = true;
|
|
382
|
+
this.emit('close', code || 1000, reason || '');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* @private
|
|
388
|
+
* Try to `require('wrtc')` then `require('@roamhq/wrtc')`.
|
|
389
|
+
* Throws a clean `WEBRTC_BOT_NOT_INSTALLED` error if neither is present.
|
|
390
|
+
*/
|
|
391
|
+
function _tryRequireWrtc()
|
|
392
|
+
{
|
|
393
|
+
const tried = [];
|
|
394
|
+
for (const name of ['wrtc', '@roamhq/wrtc'])
|
|
395
|
+
{
|
|
396
|
+
try { return require(name); }
|
|
397
|
+
catch (err) { tried.push(`${name} (${err.code || err.message})`); }
|
|
398
|
+
}
|
|
399
|
+
throw new WebRTCError(
|
|
400
|
+
`spawnBotPeer requires the 'wrtc' (or '@roamhq/wrtc') peerDependency: npm install wrtc - tried: ${tried.join(', ')}`,
|
|
401
|
+
{ code: 'WEBRTC_BOT_NOT_INSTALLED' },
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
module.exports = { spawnBotPeer };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/cli
|
|
3
|
+
* @description CLI subcommands for the `zs webrtc:*` namespace.
|
|
4
|
+
*
|
|
5
|
+
* Pure-function entry point `runWebRTCCommand(subcmd, flags, deps)` so
|
|
6
|
+
* the dispatch can be exercised in tests without spawning a child
|
|
7
|
+
* process or hitting the network. All side effects (stdout / stderr /
|
|
8
|
+
* process.exitCode) are injected through `deps`, defaulting to the
|
|
9
|
+
* real globals when called from `lib/cli.js`.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // From the shell, via the top-level CLI:
|
|
13
|
+
* // npx zs webrtc:stun --host stun.l.google.com --port 19302
|
|
14
|
+
* // npx zs webrtc:turn-creds --secret $SECRET --user alice \
|
|
15
|
+
* // --servers turn:turn.example.com:3478
|
|
16
|
+
* // npx zs webrtc:join-token --secret $JT_SECRET --room lobby --sub u1
|
|
17
|
+
* // npx zs webrtc:verify-token --secret $JT_SECRET --token $TOKEN
|
|
18
|
+
*
|
|
19
|
+
* // Programmatically:
|
|
20
|
+
* const { runWebRTCCommand } = require('@zero-server/webrtc/cli');
|
|
21
|
+
* await runWebRTCCommand('join-token', new Map([
|
|
22
|
+
* ['secret', 's'], ['room', 'lobby'], ['sub', 'u1'],
|
|
23
|
+
* ]));
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const defaultStun = require('./stun').stunBinding;
|
|
29
|
+
const { issueTurnCredentials } = require('./turn/credentials');
|
|
30
|
+
const { signJoinToken, verifyJoinToken } = require('./joinToken');
|
|
31
|
+
|
|
32
|
+
const SUBCOMMANDS = ['stun', 'turn-creds', 'join-token', 'verify-token', 'help'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @private
|
|
36
|
+
* Coerce a flag Map value to a number; return defaultValue if undefined.
|
|
37
|
+
*/
|
|
38
|
+
function flagNumber(flags, key, defaultValue)
|
|
39
|
+
{
|
|
40
|
+
if (!flags.has(key)) return defaultValue;
|
|
41
|
+
const raw = flags.get(key);
|
|
42
|
+
const n = Number(raw);
|
|
43
|
+
if (!Number.isFinite(n))
|
|
44
|
+
throw new Error(`--${key} must be a number, got "${raw}"`);
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @private
|
|
50
|
+
* Split a comma-separated flag into a trimmed, non-empty list.
|
|
51
|
+
*/
|
|
52
|
+
function flagList(flags, key)
|
|
53
|
+
{
|
|
54
|
+
if (!flags.has(key)) return [];
|
|
55
|
+
return String(flags.get(key))
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((s) => s.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @private
|
|
63
|
+
* Require a flag, throwing a friendly error otherwise.
|
|
64
|
+
*/
|
|
65
|
+
function flagRequired(flags, key)
|
|
66
|
+
{
|
|
67
|
+
if (!flags.has(key) || flags.get(key) === 'true')
|
|
68
|
+
throw new Error(`--${key} is required`);
|
|
69
|
+
return flags.get(key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run a single `webrtc:*` subcommand.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} subcmd
|
|
76
|
+
* One of `stun`, `turn-creds`, `join-token`, `verify-token`, `help`.
|
|
77
|
+
* @param {Map<string,string>} flags
|
|
78
|
+
* @param {object} [deps]
|
|
79
|
+
* Injection seam for tests.
|
|
80
|
+
* @param {(line: string) => void} [deps.out]
|
|
81
|
+
* @param {(line: string) => void} [deps.err]
|
|
82
|
+
* @param {(code: number) => void} [deps.setExit]
|
|
83
|
+
* @param {typeof defaultStun} [deps.stunBinding]
|
|
84
|
+
* @returns {Promise<number>} The exit code that would have been set.
|
|
85
|
+
*/
|
|
86
|
+
async function runWebRTCCommand(subcmd, flags = new Map(), deps = {})
|
|
87
|
+
{
|
|
88
|
+
const out = deps.out || ((line) => console.log(line));
|
|
89
|
+
const err = deps.err || ((line) => console.error(line));
|
|
90
|
+
const setExit = deps.setExit || ((code) => { process.exitCode = code; });
|
|
91
|
+
const stunFn = deps.stunBinding || defaultStun;
|
|
92
|
+
|
|
93
|
+
const name = String(subcmd || '').trim();
|
|
94
|
+
if (!name || name === 'help' || name === '--help' || name === '-h')
|
|
95
|
+
{
|
|
96
|
+
out(helpText());
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
if (!SUBCOMMANDS.includes(name))
|
|
100
|
+
{
|
|
101
|
+
err(`Unknown webrtc subcommand: "${name}"`);
|
|
102
|
+
out(helpText());
|
|
103
|
+
setExit(1);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try
|
|
108
|
+
{
|
|
109
|
+
switch (name)
|
|
110
|
+
{
|
|
111
|
+
case 'stun': await runStun(flags, { out, stunFn }); break;
|
|
112
|
+
case 'turn-creds': runTurnCreds(flags, { out }); break;
|
|
113
|
+
case 'join-token': runJoinToken(flags, { out }); break;
|
|
114
|
+
case 'verify-token': runVerifyToken(flags, { out }); break;
|
|
115
|
+
}
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
catch (e)
|
|
119
|
+
{
|
|
120
|
+
err(`webrtc:${name} failed: ${e.message}`);
|
|
121
|
+
setExit(1);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runStun(flags, { out, stunFn })
|
|
127
|
+
{
|
|
128
|
+
const host = flagRequired(flags, 'host');
|
|
129
|
+
const port = flagNumber(flags, 'port', 3478);
|
|
130
|
+
const timeout = flagNumber(flags, 'timeout', 1000);
|
|
131
|
+
const retries = flagNumber(flags, 'retries', 1);
|
|
132
|
+
const result = await stunFn({ host, port, timeoutMs: timeout, retries });
|
|
133
|
+
out(JSON.stringify(result));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function runTurnCreds(flags, { out })
|
|
137
|
+
{
|
|
138
|
+
const secret = flagRequired(flags, 'secret');
|
|
139
|
+
const userId = flagRequired(flags, 'user');
|
|
140
|
+
const servers = flagList(flags, 'servers');
|
|
141
|
+
if (servers.length === 0)
|
|
142
|
+
throw new Error('--servers is required (comma-separated turn: or turns: URIs)');
|
|
143
|
+
const ttl = flags.has('ttl') ? flags.get('ttl') : 3600;
|
|
144
|
+
const realm = flags.has('realm') ? flags.get('realm') : undefined;
|
|
145
|
+
const creds = issueTurnCredentials({ secret, userId, servers, ttl, realm });
|
|
146
|
+
out(JSON.stringify(creds));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runJoinToken(flags, { out })
|
|
150
|
+
{
|
|
151
|
+
const secret = flagRequired(flags, 'secret');
|
|
152
|
+
const room = flagRequired(flags, 'room');
|
|
153
|
+
const user = flagRequired(flags, 'user');
|
|
154
|
+
const ttl = flagNumber(flags, 'ttl', 300);
|
|
155
|
+
const token = signJoinToken({ secret, user, room, ttl });
|
|
156
|
+
out(token);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function runVerifyToken(flags, { out })
|
|
160
|
+
{
|
|
161
|
+
const secret = flagRequired(flags, 'secret');
|
|
162
|
+
const token = flagRequired(flags, 'token');
|
|
163
|
+
const room = flags.has('room') ? flags.get('room') : undefined;
|
|
164
|
+
const payload = verifyJoinToken(token, { secret, room });
|
|
165
|
+
out(JSON.stringify(payload));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function helpText()
|
|
169
|
+
{
|
|
170
|
+
return [
|
|
171
|
+
'zs webrtc:* - WebRTC tooling',
|
|
172
|
+
'',
|
|
173
|
+
'Subcommands:',
|
|
174
|
+
' webrtc:stun --host H [--port 3478] [--timeout 1000] [--retries 1]',
|
|
175
|
+
' webrtc:turn-creds --secret S --user U --servers turn:host:port[,...] [--ttl 3600] [--realm R]',
|
|
176
|
+
' webrtc:join-token --secret S --room R --user U [--ttl 300]',
|
|
177
|
+
' webrtc:verify-token --secret S --token T [--room R]',
|
|
178
|
+
' webrtc:help Show this message',
|
|
179
|
+
].join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { runWebRTCCommand, SUBCOMMANDS };
|