@mh-gg/cli 0.1.1-alpha.20260613T085325975Z
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 +5 -0
- package/bin/matterhorn.cjs +57 -0
- package/package.json +49 -0
- package/runtime/bin/appFrontend/artifacts.cjs +25 -0
- package/runtime/bin/appFrontend/buildServers.cjs +176 -0
- package/runtime/bin/appFrontend/commandEnv.cjs +74 -0
- package/runtime/bin/appFrontend/commandPolicy.cjs +23 -0
- package/runtime/bin/appFrontend/devServers.cjs +150 -0
- package/runtime/bin/appFrontend/httpServers.cjs +221 -0
- package/runtime/bin/appFrontend/paths.cjs +103 -0
- package/runtime/bin/appFrontend/ports.cjs +36 -0
- package/runtime/bin/appFrontend/processes.cjs +127 -0
- package/runtime/bin/appFrontend.cjs +45 -0
- package/runtime/bin/appHostCommand.cjs +381 -0
- package/runtime/bin/matterhorn.cjs +501 -0
- package/runtime/bin/matterhornAppLoader.cjs +588 -0
- package/runtime/bin/matterhornApps.cjs +223 -0
- package/runtime/bin/matterhornDeploy.cjs +108 -0
- package/runtime/bin/matterhornEmitAppBundle.cjs +20 -0
- package/runtime/bin/matterhornInstall.cjs +609 -0
- package/runtime/host/callAuth.cjs +76 -0
- package/runtime/host/host.cjs +103 -0
- package/runtime/host/hostAnnouncement.cjs +70 -0
- package/runtime/host/hostClients/constants.cjs +7 -0
- package/runtime/host/hostClients/frontendBundleRefresh.cjs +158 -0
- package/runtime/host/hostClients/frontendRequests.cjs +166 -0
- package/runtime/host/hostClients/index.cjs +68 -0
- package/runtime/host/hostClients/rejections.cjs +37 -0
- package/runtime/host/hostSession.cjs +160 -0
- package/runtime/host/inlineProgressBar.cjs +128 -0
- package/runtime/host/localPeerServer.cjs +114 -0
- package/runtime/host/localRelayClient.cjs +151 -0
- package/runtime/host/matterhornrc.cjs +75 -0
- package/runtime/host/memberRootRegistry.cjs +132 -0
- package/runtime/host/nodePeer.cjs +127 -0
- package/runtime/host/nodePeerRacePatch.cjs +106 -0
- package/runtime/host/peerJsConfig.cjs +26 -0
- package/runtime/host/pushEgress.cjs +48 -0
- package/runtime/host/pushStorage.cjs +233 -0
- package/runtime/host/relay/config.cjs +179 -0
- package/runtime/host/relay/connectionCleanup.cjs +34 -0
- package/runtime/host/relay/connectionDispatcher.cjs +140 -0
- package/runtime/host/relay/matterhornOperationEvents.cjs +100 -0
- package/runtime/host/relay/matterhornRuntimeEventBridge.cjs +182 -0
- package/runtime/host/relay/nostrRelay.cjs +30 -0
- package/runtime/host/relay/peerStartup.cjs +81 -0
- package/runtime/host/relay.cjs +653 -0
- package/runtime/host/relayClientRouting.cjs +1054 -0
- package/runtime/host/relayConfig.cjs +156 -0
- package/runtime/host/relayHostAuth.cjs +39 -0
- package/runtime/host/relayHostMessages.cjs +367 -0
- package/runtime/host/relayHttp.cjs +48 -0
- package/runtime/host/relayIdentity.cjs +496 -0
- package/runtime/host/relayIncomingGate.cjs +153 -0
- package/runtime/host/relayMeshEnvelopes.cjs +522 -0
- package/runtime/host/relayPeerLifecycle.cjs +96 -0
- package/runtime/host/relayPeerSignals.cjs +175 -0
- package/runtime/host/relayRoomRuntimePersistence.cjs +129 -0
- package/runtime/host/relayStatus.cjs +160 -0
- package/runtime/host/sfuRelay.cjs +553 -0
- package/runtime/host/sqliteRelayStorage.cjs +352 -0
- package/runtime/host/wireValidation/client.cjs +213 -0
- package/runtime/host/wireValidation/host.cjs +33 -0
- package/runtime/host/wireValidation/index.cjs +13 -0
- package/runtime/host/wireValidation/peerSignal.cjs +35 -0
- package/runtime/host/wireValidation/presenceEvent.cjs +49 -0
- package/runtime/host/wireValidation/push.cjs +49 -0
- package/runtime/host/wireValidation/relay.cjs +131 -0
- package/runtime/host/wireValidation/shared.cjs +49 -0
- package/runtime/scripts/ensureWorkspaceSdkBuild.cjs +148 -0
- package/runtime/scripts/killChildTree.cjs +18 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
const { verifyCallSignal } = require("./callAuth.cjs");
|
|
2
|
+
const { peerJsOptionsFromAddress } = require("@mh-gg/relay-core");
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
|
|
5
|
+
function waitForPeerOpen(peer, timeoutMs = 15000) {
|
|
6
|
+
if (!peer || peer.open) return Promise.resolve(peer?.id);
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
cleanup();
|
|
10
|
+
reject(new Error("SFU peer did not open in time."));
|
|
11
|
+
}, timeoutMs);
|
|
12
|
+
|
|
13
|
+
function cleanup() {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
peer.off?.("open", onOpen);
|
|
16
|
+
peer.off?.("error", onError);
|
|
17
|
+
peer.removeListener?.("open", onOpen);
|
|
18
|
+
peer.removeListener?.("error", onError);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function onOpen(id) {
|
|
22
|
+
cleanup();
|
|
23
|
+
resolve(id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function onError(error) {
|
|
27
|
+
cleanup();
|
|
28
|
+
reject(error);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
peer.on("open", onOpen);
|
|
32
|
+
peer.on("error", onError);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultEmptyMediaStream() {
|
|
37
|
+
const wrtcModule = require("@roamhq/wrtc");
|
|
38
|
+
const wrtc = wrtcModule.default || wrtcModule;
|
|
39
|
+
return new wrtc.MediaStream();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadWrtc() {
|
|
43
|
+
const wrtcModule = require("@roamhq/wrtc");
|
|
44
|
+
return wrtcModule.default || wrtcModule;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function metadataForCall(call) {
|
|
48
|
+
return call?.metadata?.MatterhornSfu || call?.options?.metadata?.MatterhornSfu;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function roomPayloadAad(roomName, kind) {
|
|
52
|
+
return Buffer.from(`matterhorn:v1:${roomName}:${kind}`, "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function deriveRoomPayloadKey(roomSecret, roomName) {
|
|
56
|
+
return crypto.pbkdf2Sync(String(roomSecret), `matterhorn:${roomName}`, 120000, 32, "sha256");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isEncryptedPayload(payload, kind) {
|
|
60
|
+
return payload
|
|
61
|
+
&& typeof payload === "object"
|
|
62
|
+
&& payload.encrypted === true
|
|
63
|
+
&& payload.alg === "A256GCM"
|
|
64
|
+
&& payload.kind === kind
|
|
65
|
+
&& typeof payload.iv === "string"
|
|
66
|
+
&& typeof payload.data === "string";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function decryptRoomPayload(roomSecret, roomName, kind, payload) {
|
|
70
|
+
if (!roomSecret || !isEncryptedPayload(payload, kind)) throw new Error("Encrypted room payload is invalid.");
|
|
71
|
+
const bytes = Buffer.from(payload.data, "base64");
|
|
72
|
+
if (bytes.length < 17) throw new Error("Encrypted room payload is invalid.");
|
|
73
|
+
const ciphertext = bytes.subarray(0, bytes.length - 16);
|
|
74
|
+
const authTag = bytes.subarray(bytes.length - 16);
|
|
75
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", deriveRoomPayloadKey(roomSecret, roomName), Buffer.from(payload.iv, "base64"));
|
|
76
|
+
decipher.setAAD(roomPayloadAad(roomName, kind));
|
|
77
|
+
decipher.setAuthTag(authTag);
|
|
78
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
79
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function encryptRoomPayload(roomSecret, roomName, kind, value) {
|
|
83
|
+
const iv = crypto.randomBytes(12);
|
|
84
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", deriveRoomPayloadKey(roomSecret, roomName), iv);
|
|
85
|
+
cipher.setAAD(roomPayloadAad(roomName, kind));
|
|
86
|
+
const ciphertext = Buffer.concat([
|
|
87
|
+
cipher.update(Buffer.from(JSON.stringify(value), "utf8")),
|
|
88
|
+
cipher.final(),
|
|
89
|
+
cipher.getAuthTag()
|
|
90
|
+
]);
|
|
91
|
+
return {
|
|
92
|
+
encrypted: true,
|
|
93
|
+
alg: "A256GCM",
|
|
94
|
+
kind,
|
|
95
|
+
iv: iv.toString("base64"),
|
|
96
|
+
data: ciphertext.toString("base64")
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isCallRequest(signal) {
|
|
101
|
+
return signal
|
|
102
|
+
&& signal.type === "call.request"
|
|
103
|
+
&& typeof signal.sessionId === "string"
|
|
104
|
+
&& (signal.mode === "voice" || signal.mode === "video")
|
|
105
|
+
&& (!signal.mediaRoomId || typeof signal.mediaRoomId === "string")
|
|
106
|
+
&& typeof signal.directPeerId === "string";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isCallEnd(signal) {
|
|
110
|
+
return signal
|
|
111
|
+
&& signal.type === "call.end"
|
|
112
|
+
&& typeof signal.sessionId === "string";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function preferVp8Sdp(sdp) {
|
|
116
|
+
const sections = sdp.split(/(?=m=)/);
|
|
117
|
+
return sections.map((section) => {
|
|
118
|
+
if (!section.startsWith("m=video ")) return section;
|
|
119
|
+
const lines = section.split("\r\n");
|
|
120
|
+
const mLine = lines[0].split(" ");
|
|
121
|
+
const payloads = mLine.slice(3);
|
|
122
|
+
const vp8Payloads = new Set();
|
|
123
|
+
const rtxByApt = new Map();
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
const rtpMatch = /^a=rtpmap:(\d+)\s+VP8\/90000/i.exec(line);
|
|
126
|
+
if (rtpMatch) vp8Payloads.add(rtpMatch[1]);
|
|
127
|
+
const fmtpMatch = /^a=fmtp:(\d+)\s+.*(?:^|[ ;])apt=(\d+)/i.exec(line);
|
|
128
|
+
if (fmtpMatch) rtxByApt.set(fmtpMatch[2], [...(rtxByApt.get(fmtpMatch[2]) || []), fmtpMatch[1]]);
|
|
129
|
+
}
|
|
130
|
+
const preferred = [];
|
|
131
|
+
for (const payload of payloads) {
|
|
132
|
+
if (!vp8Payloads.has(payload)) continue;
|
|
133
|
+
preferred.push(payload, ...(rtxByApt.get(payload) || []));
|
|
134
|
+
}
|
|
135
|
+
if (preferred.length === 0) return section;
|
|
136
|
+
const ordered = [...preferred, ...payloads.filter((payload) => !preferred.includes(payload))];
|
|
137
|
+
lines[0] = [...mLine.slice(0, 3), ...ordered].join(" ");
|
|
138
|
+
return lines.join("\r\n");
|
|
139
|
+
}).join("");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createRelaySfu(options) {
|
|
143
|
+
const enabled = Boolean(options.enabled);
|
|
144
|
+
const peerId = options.peerId;
|
|
145
|
+
const peerAddress = options.peerAddress;
|
|
146
|
+
const maxParticipants = Number.isInteger(options.maxParticipants) && options.maxParticipants > 0
|
|
147
|
+
? options.maxParticipants
|
|
148
|
+
: 16;
|
|
149
|
+
const createEmptyMediaStream = options.createEmptyMediaStream || defaultEmptyMediaStream;
|
|
150
|
+
const rooms = new Map();
|
|
151
|
+
const mediaStats = {
|
|
152
|
+
upstreamStreams: 0,
|
|
153
|
+
downstreamCalls: 0,
|
|
154
|
+
audioFrames: 0,
|
|
155
|
+
videoFrames: 0,
|
|
156
|
+
lastAudioFrameAt: 0,
|
|
157
|
+
lastVideoFrameAt: 0
|
|
158
|
+
};
|
|
159
|
+
let peer;
|
|
160
|
+
|
|
161
|
+
function roomKey(roomName, mediaRoomId) {
|
|
162
|
+
return mediaRoomId ? `${roomName}#${mediaRoomId}` : roomName;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function roomFor(roomName, mediaRoomId) {
|
|
166
|
+
const key = roomKey(roomName, mediaRoomId);
|
|
167
|
+
let room = rooms.get(key);
|
|
168
|
+
if (!room) {
|
|
169
|
+
room = { roomName, mediaRoomId, participants: new Map() };
|
|
170
|
+
rooms.set(key, room);
|
|
171
|
+
}
|
|
172
|
+
return room;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function participantCount(roomName) {
|
|
176
|
+
if (roomName) {
|
|
177
|
+
let count = 0;
|
|
178
|
+
for (const room of rooms.values()) {
|
|
179
|
+
if (room.roomName === roomName) count += room.participants.size;
|
|
180
|
+
}
|
|
181
|
+
return count;
|
|
182
|
+
}
|
|
183
|
+
let count = 0;
|
|
184
|
+
for (const room of rooms.values()) count += room.participants.size;
|
|
185
|
+
return count;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function stats() {
|
|
189
|
+
return {
|
|
190
|
+
enabled,
|
|
191
|
+
peerId: enabled ? peerId : undefined,
|
|
192
|
+
peerAddress: enabled ? peerAddress : undefined,
|
|
193
|
+
open: Boolean(peer?.open),
|
|
194
|
+
participants: participantCount(),
|
|
195
|
+
maxParticipants,
|
|
196
|
+
media: { ...mediaStats }
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function status(roomName) {
|
|
201
|
+
if (!enabled) return { enabled: false };
|
|
202
|
+
return {
|
|
203
|
+
enabled: true,
|
|
204
|
+
peerId,
|
|
205
|
+
peerAddress,
|
|
206
|
+
participants: participantCount(roomName),
|
|
207
|
+
maxParticipants,
|
|
208
|
+
media: { ...mediaStats }
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function emitStatus(roomName) {
|
|
213
|
+
if (enabled && typeof roomName === "string") options.onStatus?.(roomName, status(roomName));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function debugSfu(message) {
|
|
217
|
+
if (process.env.MATTERHORN_SFU_DEBUG === "1") options.logger?.log?.(`matterhorn relay SFU debug: ${message}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function validJoin(call, metadata) {
|
|
221
|
+
if (!enabled || !metadata || metadata.protocol !== 1) return undefined;
|
|
222
|
+
if (typeof metadata.roomName !== "string" || typeof metadata.clientId !== "string") return undefined;
|
|
223
|
+
let signal = metadata.signal;
|
|
224
|
+
let auth = metadata.auth;
|
|
225
|
+
if (metadata.encryptedSignal) {
|
|
226
|
+
const roomSecret = await options.roomSecretForRoom?.(metadata.roomName);
|
|
227
|
+
try {
|
|
228
|
+
const payload = decryptRoomPayload(roomSecret, metadata.roomName, "call.signal", metadata.encryptedSignal);
|
|
229
|
+
signal = payload.signal;
|
|
230
|
+
auth = payload.auth;
|
|
231
|
+
} catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!isCallRequest(signal) || signal.directPeerId !== call.peer) return undefined;
|
|
236
|
+
const guest = await options.getGuest?.(metadata.roomName, metadata.clientId);
|
|
237
|
+
if (!guest || guest.bannedAt || !guest.callPubkey) return undefined;
|
|
238
|
+
const targetClientId = `sfu:${peerId}`;
|
|
239
|
+
if (!verifyCallSignal({
|
|
240
|
+
roomName: metadata.roomName,
|
|
241
|
+
sourceClientId: metadata.clientId,
|
|
242
|
+
targetClientId,
|
|
243
|
+
signal,
|
|
244
|
+
auth,
|
|
245
|
+
expectedPubkey: guest.callPubkey
|
|
246
|
+
})) return undefined;
|
|
247
|
+
return { guest, signal };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function validSignal(roomName, clientId, signal, auth) {
|
|
251
|
+
if (!enabled || typeof roomName !== "string" || typeof clientId !== "string") return false;
|
|
252
|
+
const participant = findParticipant(roomName, clientId);
|
|
253
|
+
if (!participant?.callPubkey) return false;
|
|
254
|
+
return verifyCallSignal({
|
|
255
|
+
roomName,
|
|
256
|
+
sourceClientId: clientId,
|
|
257
|
+
targetClientId: `sfu:${peerId}`,
|
|
258
|
+
signal,
|
|
259
|
+
auth,
|
|
260
|
+
expectedPubkey: participant.callPubkey
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function closeCall(call) {
|
|
265
|
+
try {
|
|
266
|
+
call?.close?.();
|
|
267
|
+
} catch {}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function stopStream(stream) {
|
|
271
|
+
for (const track of stream?.getTracks?.() || []) {
|
|
272
|
+
try {
|
|
273
|
+
track.stop?.();
|
|
274
|
+
} catch {}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function closeDownstream(call) {
|
|
279
|
+
if (!call || call.MatterhornSfuClosing) return;
|
|
280
|
+
call.MatterhornSfuClosing = true;
|
|
281
|
+
if (call.MatterhornForwarder) call.MatterhornForwarder.close();
|
|
282
|
+
else stopStream(call?.MatterhornSfuStream);
|
|
283
|
+
closeCall(call);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function findParticipant(roomName, clientId) {
|
|
287
|
+
for (const room of rooms.values()) {
|
|
288
|
+
if (room.roomName !== roomName) continue;
|
|
289
|
+
const participant = room.participants.get(clientId);
|
|
290
|
+
if (participant) return participant;
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function streamTrackCount(stream) {
|
|
296
|
+
return stream?.getTracks?.().length || 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function createMediaForwarder(stream) {
|
|
300
|
+
const tracks = stream?.getTracks?.() || [];
|
|
301
|
+
if (tracks.length === 0) return { stream, close() {} };
|
|
302
|
+
|
|
303
|
+
const wrtc = loadWrtc();
|
|
304
|
+
const nonstandard = wrtc.nonstandard || {};
|
|
305
|
+
const output = new wrtc.MediaStream();
|
|
306
|
+
const sinks = [];
|
|
307
|
+
|
|
308
|
+
for (const track of tracks) {
|
|
309
|
+
if (track.kind === "video" && nonstandard.RTCVideoSink && nonstandard.RTCVideoSource) {
|
|
310
|
+
const sink = new nonstandard.RTCVideoSink(track);
|
|
311
|
+
const source = new nonstandard.RTCVideoSource();
|
|
312
|
+
const outputTrack = source.createTrack();
|
|
313
|
+
sink.onframe = ({ frame }) => {
|
|
314
|
+
mediaStats.videoFrames += 1;
|
|
315
|
+
mediaStats.lastVideoFrameAt = Date.now();
|
|
316
|
+
if (mediaStats.videoFrames === 1) debugSfu(`first video frame ${frame.width}x${frame.height}`);
|
|
317
|
+
source.onFrame(frame);
|
|
318
|
+
};
|
|
319
|
+
output.addTrack(outputTrack);
|
|
320
|
+
sinks.push({ sink, track: outputTrack });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (track.kind === "audio" && nonstandard.RTCAudioSink && nonstandard.RTCAudioSource) {
|
|
324
|
+
const sink = new nonstandard.RTCAudioSink(track);
|
|
325
|
+
const source = new nonstandard.RTCAudioSource();
|
|
326
|
+
const outputTrack = source.createTrack();
|
|
327
|
+
sink.ondata = (data) => {
|
|
328
|
+
mediaStats.audioFrames += 1;
|
|
329
|
+
mediaStats.lastAudioFrameAt = Date.now();
|
|
330
|
+
if (mediaStats.audioFrames === 1) debugSfu(`first audio frame ${data.sampleRate}hz`);
|
|
331
|
+
source.onData(data);
|
|
332
|
+
};
|
|
333
|
+
output.addTrack(outputTrack);
|
|
334
|
+
sinks.push({ sink, track: outputTrack });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (output.getTracks().length === 0) return {
|
|
339
|
+
stream: typeof stream.clone === "function" ? stream.clone() : stream,
|
|
340
|
+
close() {}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
stream: output,
|
|
345
|
+
close() {
|
|
346
|
+
for (const entry of sinks) {
|
|
347
|
+
try {
|
|
348
|
+
entry.sink.stop();
|
|
349
|
+
} catch {}
|
|
350
|
+
try {
|
|
351
|
+
entry.track.stop();
|
|
352
|
+
} catch {}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function removeParticipant(participant) {
|
|
359
|
+
const room = rooms.get(participant.roomKey);
|
|
360
|
+
if (room?.participants.get(participant.clientId) === participant) room.participants.delete(participant.clientId);
|
|
361
|
+
closeCall(participant.upstreamCall);
|
|
362
|
+
for (const call of participant.downstreamCalls.values()) closeDownstream(call);
|
|
363
|
+
participant.downstreamCalls.clear();
|
|
364
|
+
if (!room) return;
|
|
365
|
+
for (const other of room.participants.values()) {
|
|
366
|
+
const call = other.downstreamCalls.get(participant.clientId);
|
|
367
|
+
if (call) {
|
|
368
|
+
other.downstreamCalls.delete(participant.clientId);
|
|
369
|
+
closeDownstream(call);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
emitStatus(participant.roomName);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function replaceSourceStream(participant, stream) {
|
|
376
|
+
participant.stream = stream;
|
|
377
|
+
const room = rooms.get(participant.roomKey);
|
|
378
|
+
if (!room) return;
|
|
379
|
+
for (const other of room.participants.values()) {
|
|
380
|
+
if (other === participant) continue;
|
|
381
|
+
const existing = other.downstreamCalls.get(participant.clientId);
|
|
382
|
+
if (existing) {
|
|
383
|
+
other.downstreamCalls.delete(participant.clientId);
|
|
384
|
+
closeDownstream(existing);
|
|
385
|
+
}
|
|
386
|
+
bridgeSourceToTarget(participant, other);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function bridgeSourceToTarget(source, target) {
|
|
391
|
+
if (!peer?.open || source.clientId === target.clientId || !source.stream) return;
|
|
392
|
+
const existing = target.downstreamCalls.get(source.clientId);
|
|
393
|
+
if (existing) closeDownstream(existing);
|
|
394
|
+
const forwarder = createMediaForwarder(source.stream);
|
|
395
|
+
const roomSecret = options.roomSecretForRoom?.(source.roomName);
|
|
396
|
+
const encryptedPayload = roomSecret ? encryptRoomPayload(roomSecret, source.roomName, "sfu.downstream", {
|
|
397
|
+
sourceName: source.name,
|
|
398
|
+
sourceAvatar: source.avatar,
|
|
399
|
+
mode: source.mode,
|
|
400
|
+
media: source.media,
|
|
401
|
+
mediaTracks: source.mediaTracks,
|
|
402
|
+
mediaRoomId: source.mediaRoomId
|
|
403
|
+
}) : undefined;
|
|
404
|
+
mediaStats.downstreamCalls += 1;
|
|
405
|
+
const downstream = peer.call(target.peerId, forwarder.stream, {
|
|
406
|
+
sdpTransform: preferVp8Sdp,
|
|
407
|
+
metadata: {
|
|
408
|
+
MatterhornSfuDownstream: encryptedPayload ? {
|
|
409
|
+
protocol: 1,
|
|
410
|
+
roomName: source.roomName,
|
|
411
|
+
sfuPeerId: peerId,
|
|
412
|
+
sessionId: target.sessionId,
|
|
413
|
+
sourceClientId: source.clientId,
|
|
414
|
+
encryptedPayload
|
|
415
|
+
} : {
|
|
416
|
+
protocol: 1,
|
|
417
|
+
roomName: source.roomName,
|
|
418
|
+
sfuPeerId: peerId,
|
|
419
|
+
sessionId: target.sessionId,
|
|
420
|
+
sourceClientId: source.clientId,
|
|
421
|
+
sourceName: source.name,
|
|
422
|
+
sourceAvatar: source.avatar,
|
|
423
|
+
mode: source.mode,
|
|
424
|
+
media: source.media,
|
|
425
|
+
mediaTracks: source.mediaTracks,
|
|
426
|
+
mediaRoomId: source.mediaRoomId
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
downstream.MatterhornForwarder = forwarder;
|
|
431
|
+
target.downstreamCalls.set(source.clientId, downstream);
|
|
432
|
+
downstream.on?.("close", () => {
|
|
433
|
+
if (target.downstreamCalls.get(source.clientId) === downstream) target.downstreamCalls.delete(source.clientId);
|
|
434
|
+
closeDownstream(downstream);
|
|
435
|
+
});
|
|
436
|
+
downstream.on?.("error", () => {
|
|
437
|
+
if (target.downstreamCalls.get(source.clientId) === downstream) target.downstreamCalls.delete(source.clientId);
|
|
438
|
+
closeDownstream(downstream);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function addParticipant(call, metadata, validation, stream) {
|
|
443
|
+
const room = roomFor(metadata.roomName, validation.signal.mediaRoomId);
|
|
444
|
+
const existing = room.participants.get(metadata.clientId);
|
|
445
|
+
if (existing?.upstreamCall === call) {
|
|
446
|
+
if (streamTrackCount(stream) > streamTrackCount(existing.stream)) replaceSourceStream(existing, stream);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (existing) removeParticipant(existing);
|
|
450
|
+
|
|
451
|
+
const participant = {
|
|
452
|
+
roomName: metadata.roomName,
|
|
453
|
+
roomKey: roomKey(metadata.roomName, validation.signal.mediaRoomId),
|
|
454
|
+
mediaRoomId: validation.signal.mediaRoomId,
|
|
455
|
+
clientId: metadata.clientId,
|
|
456
|
+
peerId: call.peer,
|
|
457
|
+
sessionId: validation.signal.sessionId,
|
|
458
|
+
mode: validation.signal.mode,
|
|
459
|
+
media: validation.signal.media,
|
|
460
|
+
mediaTracks: validation.signal.mediaTracks,
|
|
461
|
+
name: validation.guest.name || validation.signal.profile?.name || "Guest",
|
|
462
|
+
avatar: validation.guest.avatar || validation.signal.profile?.avatar || "*",
|
|
463
|
+
callPubkey: validation.guest.callPubkey,
|
|
464
|
+
stream,
|
|
465
|
+
upstreamCall: call,
|
|
466
|
+
downstreamCalls: new Map()
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
room.participants.set(participant.clientId, participant);
|
|
470
|
+
mediaStats.upstreamStreams += 1;
|
|
471
|
+
debugSfu(`upstream stream from ${participant.clientId} with ${stream.getTracks?.().length || 0} tracks`);
|
|
472
|
+
for (const other of room.participants.values()) {
|
|
473
|
+
if (other === participant) continue;
|
|
474
|
+
bridgeSourceToTarget(other, participant);
|
|
475
|
+
bridgeSourceToTarget(participant, other);
|
|
476
|
+
}
|
|
477
|
+
emitStatus(metadata.roomName);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function handleCall(call) {
|
|
481
|
+
const metadata = metadataForCall(call);
|
|
482
|
+
void validJoin(call, metadata).then((validation) => {
|
|
483
|
+
const room = metadata?.roomName && validation ? roomFor(metadata.roomName, validation.signal.mediaRoomId) : undefined;
|
|
484
|
+
if (!validation || !room || room.participants.size >= maxParticipants) {
|
|
485
|
+
closeCall(call);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
call.answer(createEmptyMediaStream());
|
|
489
|
+
call.on?.("stream", (stream) => addParticipant(call, metadata, validation, stream));
|
|
490
|
+
call.on?.("close", () => {
|
|
491
|
+
const participant = room.participants.get(metadata.clientId);
|
|
492
|
+
if (participant?.upstreamCall === call) removeParticipant(participant);
|
|
493
|
+
});
|
|
494
|
+
call.on?.("error", () => {
|
|
495
|
+
const participant = room.participants.get(metadata.clientId);
|
|
496
|
+
if (participant?.upstreamCall === call) removeParticipant(participant);
|
|
497
|
+
});
|
|
498
|
+
}).catch(() => closeCall(call));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function handleSignal(message) {
|
|
502
|
+
if (!isCallEnd(message?.signal) || !validSignal(message.roomName, message.sourceClientId, message.signal, message.auth)) return false;
|
|
503
|
+
const participant = findParticipant(message.roomName, message.sourceClientId);
|
|
504
|
+
if (participant?.sessionId === message.signal.sessionId) removeParticipant(participant);
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function start() {
|
|
509
|
+
if (!enabled) return undefined;
|
|
510
|
+
peer = await options.createPeer(peerId, options.iceServers || [], {
|
|
511
|
+
signaling: peerJsOptionsFromAddress(peerAddress)
|
|
512
|
+
});
|
|
513
|
+
peer.on("call", handleCall);
|
|
514
|
+
await waitForPeerOpen(peer);
|
|
515
|
+
options.logger?.log?.(`matterhorn relay SFU peer is live as ${peerId}`);
|
|
516
|
+
return peer;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function close() {
|
|
520
|
+
const roomNames = Array.from(rooms.keys());
|
|
521
|
+
const calls = [];
|
|
522
|
+
for (const room of rooms.values()) {
|
|
523
|
+
for (const participant of room.participants.values()) {
|
|
524
|
+
calls.push(participant.upstreamCall, ...participant.downstreamCalls.values());
|
|
525
|
+
participant.downstreamCalls.clear();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
rooms.clear();
|
|
529
|
+
const peerToDestroy = peer;
|
|
530
|
+
peer = undefined;
|
|
531
|
+
for (const roomName of roomNames) emitStatus(roomName);
|
|
532
|
+
setTimeout(() => {
|
|
533
|
+
for (const call of calls) {
|
|
534
|
+
if (call?.MatterhornForwarder || call?.MatterhornSfuStream) closeDownstream(call);
|
|
535
|
+
else closeCall(call);
|
|
536
|
+
}
|
|
537
|
+
peerToDestroy?.destroy?.();
|
|
538
|
+
}, 0);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
close,
|
|
543
|
+
handleCall,
|
|
544
|
+
handleSignal,
|
|
545
|
+
start,
|
|
546
|
+
stats,
|
|
547
|
+
status
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
module.exports = {
|
|
552
|
+
createRelaySfu
|
|
553
|
+
};
|