@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,522 @@
|
|
|
1
|
+
const { PROTOCOL } = require("@mh-gg/host-config");
|
|
2
|
+
const { sendMessage } = require("@mh-gg/relay-core");
|
|
3
|
+
const { validateRelayEnvelope } = require("./wireValidation/index.cjs");
|
|
4
|
+
const crypto = require("node:crypto");
|
|
5
|
+
const { unwrapVapidGrant, vapidPublicKeyFromPrivateKey } = require("@mh-gg/push");
|
|
6
|
+
|
|
7
|
+
function sendInvalidRelayEnvelope(conn, validation) {
|
|
8
|
+
sendMessage(conn, {
|
|
9
|
+
type: "relay.error",
|
|
10
|
+
code: validation.code,
|
|
11
|
+
message: validation.message
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createRelayMeshEnvelopeHandler({
|
|
16
|
+
clientConnections,
|
|
17
|
+
clientRooms,
|
|
18
|
+
hostConnections,
|
|
19
|
+
mesh,
|
|
20
|
+
peerSignals,
|
|
21
|
+
relayPluginRuntimes,
|
|
22
|
+
encryptedRoomRuntimes,
|
|
23
|
+
relayStatus,
|
|
24
|
+
broadcastToRoom,
|
|
25
|
+
rememberPersonalStateFromHostMessage = () => {},
|
|
26
|
+
personalStateHostMessageForRoom = () => undefined,
|
|
27
|
+
pushStorage,
|
|
28
|
+
pushEgress,
|
|
29
|
+
eventStorage,
|
|
30
|
+
config
|
|
31
|
+
}) {
|
|
32
|
+
function sendRelayHost(conn, peerId, roomName, message) {
|
|
33
|
+
sendMessage(conn, {
|
|
34
|
+
type: "relay.host",
|
|
35
|
+
id: mesh.nextMessageId("relay.host"),
|
|
36
|
+
roomName,
|
|
37
|
+
peerId,
|
|
38
|
+
message
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pendingPushTtlSeconds(message) {
|
|
43
|
+
return Number.isInteger(message?.ttlSeconds) && message.ttlSeconds > 0 ? message.ttlSeconds : 60 * 60;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function persistPendingPush(message, reason) {
|
|
47
|
+
const pushId = message?.pushId || message?.id;
|
|
48
|
+
const userId = message?.target?.userId;
|
|
49
|
+
if (!pushStorage?.upsertPendingPush || !pushId || !userId) return false;
|
|
50
|
+
pushStorage.upsertPendingPush({
|
|
51
|
+
pushId,
|
|
52
|
+
userId,
|
|
53
|
+
payload: message,
|
|
54
|
+
ttlSeconds: pendingPushTtlSeconds(message)
|
|
55
|
+
});
|
|
56
|
+
if (process.env.MATTERHORN_DEBUG === "1") console.log(`matterhorn relay debug: push pending ${pushId} ${reason || "deferred"}`);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sendPushAck(conn, message, status = "accepted") {
|
|
61
|
+
if (!conn || !message?.pushId) return;
|
|
62
|
+
sendMessage(conn, {
|
|
63
|
+
type: "relay.push.ack",
|
|
64
|
+
id: mesh.nextMessageId("relay.push.ack"),
|
|
65
|
+
pushId: message.pushId,
|
|
66
|
+
status
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function off(conn, event, listener) {
|
|
71
|
+
if (typeof conn.off === "function") conn.off(event, listener);
|
|
72
|
+
else if (typeof conn.removeListener === "function") conn.removeListener(event, listener);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sendPushWithAck(targetConn, message, timeoutMs = 500) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
let timer;
|
|
78
|
+
let done = false;
|
|
79
|
+
const finish = (ok) => {
|
|
80
|
+
if (done) return;
|
|
81
|
+
done = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
off(targetConn, "data", onData);
|
|
84
|
+
off(targetConn, "close", onClose);
|
|
85
|
+
off(targetConn, "error", onError);
|
|
86
|
+
resolve(ok);
|
|
87
|
+
};
|
|
88
|
+
const onData = (incoming) => {
|
|
89
|
+
if (incoming?.type === "relay.push.ack" && incoming.pushId === message.pushId) finish(true);
|
|
90
|
+
};
|
|
91
|
+
const onClose = () => finish(false);
|
|
92
|
+
const onError = () => finish(false);
|
|
93
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
94
|
+
targetConn.on("data", onData);
|
|
95
|
+
targetConn.on("close", onClose);
|
|
96
|
+
targetConn.on("error", onError);
|
|
97
|
+
try {
|
|
98
|
+
sendMessage(targetConn, message);
|
|
99
|
+
} catch {
|
|
100
|
+
finish(false);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sendHostUnavailable(conn, peerId, roomName) {
|
|
106
|
+
sendRelayHost(conn, peerId, roomName, {
|
|
107
|
+
type: "host/error",
|
|
108
|
+
protocol: PROTOCOL,
|
|
109
|
+
code: "host-unavailable",
|
|
110
|
+
message: "The room runtime is not connected to this relay."
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function roomInfoMessage(roomName) {
|
|
115
|
+
const roomApp = relayPluginRuntimes?.roomApp?.(roomName);
|
|
116
|
+
if (!roomApp) return undefined;
|
|
117
|
+
const status = relayStatus.relayStatusMessage(undefined);
|
|
118
|
+
const runtimeCapabilities = relayPluginRuntimes?.runtimeCapabilities?.(roomName);
|
|
119
|
+
return {
|
|
120
|
+
type: "room.info",
|
|
121
|
+
protocol: PROTOCOL,
|
|
122
|
+
roomName,
|
|
123
|
+
roomApp,
|
|
124
|
+
relayHints: status.relayHints || [],
|
|
125
|
+
icedRelayHints: status.icedRelayHints || [],
|
|
126
|
+
relayClaim: status.relayClaim,
|
|
127
|
+
relayClaims: status.relayClaims || [],
|
|
128
|
+
relayHealth: status.relayHealth || [],
|
|
129
|
+
...(runtimeCapabilities ? { runtimeCapabilities } : {}),
|
|
130
|
+
sfu: status.sfu
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function relayHintMessage(roomName) {
|
|
135
|
+
const status = relayStatus.relayStatusMessage(undefined);
|
|
136
|
+
const roomApp = relayPluginRuntimes?.roomApp?.(roomName);
|
|
137
|
+
const runtimeCapabilities = relayPluginRuntimes?.runtimeCapabilities?.(roomName);
|
|
138
|
+
const message = {
|
|
139
|
+
type: "relay.hints",
|
|
140
|
+
roomName,
|
|
141
|
+
relayHints: status.relayHints || [],
|
|
142
|
+
icedRelayHints: status.icedRelayHints || [],
|
|
143
|
+
relayClaim: status.relayClaim,
|
|
144
|
+
relayClaims: status.relayClaims || [],
|
|
145
|
+
relayHealth: status.relayHealth || [],
|
|
146
|
+
sfu: status.sfu
|
|
147
|
+
};
|
|
148
|
+
if (roomApp) message.roomApp = roomApp;
|
|
149
|
+
if (runtimeCapabilities) message.runtimeCapabilities = runtimeCapabilities;
|
|
150
|
+
return message;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function forwardFrontendRequest(conn, envelope) {
|
|
154
|
+
const host = hostConnections.get(envelope.roomName);
|
|
155
|
+
if (host) {
|
|
156
|
+
sendMessage(host, {
|
|
157
|
+
type: "relay/client-message",
|
|
158
|
+
peerId: envelope.peerId,
|
|
159
|
+
message: envelope.message
|
|
160
|
+
});
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return mesh.sendEnvelope(envelope, conn, { alreadyRemembered: true }) > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sendSnapshot(conn, peerId, roomName) {
|
|
167
|
+
if (!relayPluginRuntimes?.hasRoom?.(roomName)) return false;
|
|
168
|
+
void relayPluginRuntimes.snapshot(roomName).then((snapshot) => {
|
|
169
|
+
if (!snapshot) {
|
|
170
|
+
sendHostUnavailable(conn, peerId, roomName);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
sendRelayHost(conn, peerId, roomName, {
|
|
174
|
+
type: "host/matterhorn-state",
|
|
175
|
+
protocol: PROTOCOL,
|
|
176
|
+
roomName,
|
|
177
|
+
state: snapshot.state
|
|
178
|
+
});
|
|
179
|
+
}).catch((error) => {
|
|
180
|
+
sendRelayHost(conn, peerId, roomName, {
|
|
181
|
+
type: "host/error",
|
|
182
|
+
protocol: PROTOCOL,
|
|
183
|
+
code: "matterhorn-state-unavailable",
|
|
184
|
+
message: error?.message || "Matterhorn state is unavailable."
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function handleRuntimeOperation(conn, envelope) {
|
|
191
|
+
if (!relayPluginRuntimes?.hasRoom?.(envelope.roomName)) return false;
|
|
192
|
+
void relayPluginRuntimes.handleClientOperation({
|
|
193
|
+
roomName: envelope.roomName,
|
|
194
|
+
peerId: envelope.peerId,
|
|
195
|
+
message: envelope.message,
|
|
196
|
+
sendToClient: (peerId, message, roomName) => sendRelayHost(conn, peerId, roomName || envelope.roomName, message),
|
|
197
|
+
broadcastToRoom,
|
|
198
|
+
broadcastToMesh: (message) => mesh.sendEnvelope(message, conn)
|
|
199
|
+
}).catch((error) => {
|
|
200
|
+
sendRelayHost(conn, envelope.peerId, envelope.roomName, {
|
|
201
|
+
type: "host/error",
|
|
202
|
+
protocol: PROTOCOL,
|
|
203
|
+
code: "operation-runtime-error",
|
|
204
|
+
message: error?.message || "Relay operation runtime failed."
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function roomIndexSearchResultsMessage(roomName, request, result) {
|
|
211
|
+
return {
|
|
212
|
+
type: "host/room-index-search-results",
|
|
213
|
+
protocol: PROTOCOL,
|
|
214
|
+
roomName,
|
|
215
|
+
clientId: request.clientId,
|
|
216
|
+
requestId: request.requestId,
|
|
217
|
+
suite: request.suite,
|
|
218
|
+
keyId: request.keyId,
|
|
219
|
+
stream: request.stream,
|
|
220
|
+
mode: request.mode || "all",
|
|
221
|
+
events: Array.isArray(result?.events) ? result.events : [],
|
|
222
|
+
matches: Array.isArray(result?.matches) ? result.matches : [],
|
|
223
|
+
total: Number.isInteger(result?.total) ? result.total : 0,
|
|
224
|
+
queryTokenCount: Number.isInteger(result?.queryTokenCount) ? result.queryTokenCount : 0
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function handleRoomIndexSearch(conn, envelope) {
|
|
229
|
+
const runtime = encryptedRoomRuntimes?.getRoom?.(envelope.roomName);
|
|
230
|
+
if (!runtime || typeof runtime.searchNgrams !== "function") return false;
|
|
231
|
+
const msg = envelope.message || {};
|
|
232
|
+
const result = runtime.searchNgrams({
|
|
233
|
+
suite: msg.suite,
|
|
234
|
+
keyId: msg.keyId,
|
|
235
|
+
tokens: msg.tokens,
|
|
236
|
+
stream: msg.stream,
|
|
237
|
+
mode: msg.mode,
|
|
238
|
+
minScore: msg.minScore,
|
|
239
|
+
limit: msg.limit,
|
|
240
|
+
offset: msg.offset,
|
|
241
|
+
order: msg.order
|
|
242
|
+
});
|
|
243
|
+
sendRelayHost(conn, envelope.peerId, envelope.roomName, roomIndexSearchResultsMessage(envelope.roomName, msg, result));
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function handleRelayClient(conn, message) {
|
|
248
|
+
if (typeof message.peerId !== "string" || typeof message.roomName !== "string") return;
|
|
249
|
+
mesh.rememberRemoteClientRoute(message.peerId, conn);
|
|
250
|
+
mesh.rememberClientRoute(conn, message.roomName, message.message?.clientId, message.peerId);
|
|
251
|
+
|
|
252
|
+
switch (message.message?.type) {
|
|
253
|
+
case "client/hello": {
|
|
254
|
+
sendRelayHost(conn, message.peerId, message.roomName, relayHintMessage(message.roomName));
|
|
255
|
+
if (relayPluginRuntimes?.hasRoom?.(message.roomName)) return;
|
|
256
|
+
const host = hostConnections.get(message.roomName);
|
|
257
|
+
if (host) {
|
|
258
|
+
sendMessage(host, {
|
|
259
|
+
type: "relay/client-message",
|
|
260
|
+
peerId: message.peerId,
|
|
261
|
+
message: message.message
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case "client/room-info": {
|
|
268
|
+
const info = roomInfoMessage(message.roomName);
|
|
269
|
+
if (info) {
|
|
270
|
+
sendRelayHost(conn, message.peerId, message.roomName, info);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
case "client/matterhorn-state":
|
|
276
|
+
case "client/snapshot-request":
|
|
277
|
+
if (sendSnapshot(conn, message.peerId, message.roomName)) return;
|
|
278
|
+
break;
|
|
279
|
+
case "client/operation":
|
|
280
|
+
if (handleRuntimeOperation(conn, message)) return;
|
|
281
|
+
break;
|
|
282
|
+
case "client/room-index-search":
|
|
283
|
+
if (handleRoomIndexSearch(conn, message)) return;
|
|
284
|
+
break;
|
|
285
|
+
case "client/personal-state":
|
|
286
|
+
if (message.message.action === "subscribe") {
|
|
287
|
+
sendRelayHost(conn, message.peerId, message.roomName, relayHintMessage(message.roomName));
|
|
288
|
+
const cached = personalStateHostMessageForRoom(message.roomName);
|
|
289
|
+
if (cached) sendRelayHost(conn, message.peerId, message.roomName, cached);
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
case "client/frontend-manifest":
|
|
293
|
+
case "client/frontend-chunk":
|
|
294
|
+
if (forwardFrontendRequest(conn, message)) return;
|
|
295
|
+
break;
|
|
296
|
+
case "client/push-grant": {
|
|
297
|
+
const msg = message.message;
|
|
298
|
+
const isTargetLocal = (msg.relayId === config?.relayPeerId || msg.relayId === config?.relayMeshPeerId);
|
|
299
|
+
if (isTargetLocal) {
|
|
300
|
+
try {
|
|
301
|
+
// X25519 grant key, not the Ed25519 identity key (see relayClientRouting).
|
|
302
|
+
const relayPrivateKey = crypto.createPrivateKey(config.relayIdentity.grantPrivateKeyPem);
|
|
303
|
+
const { vapidPrivateKey } = unwrapVapidGrant({
|
|
304
|
+
grant: msg.grant,
|
|
305
|
+
relayPrivateKey
|
|
306
|
+
});
|
|
307
|
+
const vapidPublicKey = vapidPublicKeyFromPrivateKey(vapidPrivateKey);
|
|
308
|
+
pushStorage?.upsertGrant({
|
|
309
|
+
userId: msg.userId,
|
|
310
|
+
vapidPrivateKey,
|
|
311
|
+
vapidPublicKey
|
|
312
|
+
});
|
|
313
|
+
sendRelayHost(conn, message.peerId, message.roomName, {
|
|
314
|
+
type: "client/push-grant.ok",
|
|
315
|
+
protocol: PROTOCOL
|
|
316
|
+
});
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (process.env.MATTERHORN_DEBUG === "1") {
|
|
319
|
+
console.error("Failed to unwrap VAPID grant from relay.client envelope:", err);
|
|
320
|
+
}
|
|
321
|
+
sendRelayHost(conn, message.peerId, message.roomName, {
|
|
322
|
+
type: "client/push-grant.error",
|
|
323
|
+
protocol: PROTOCOL,
|
|
324
|
+
code: "grant-unwrap-failed",
|
|
325
|
+
message: err?.message || "Failed to unwrap push grant."
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
sendRelayHost(conn, message.peerId, message.roomName, {
|
|
330
|
+
type: "client/push-grant.ok",
|
|
331
|
+
protocol: PROTOCOL
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
case "client/push-register": {
|
|
337
|
+
const msg = message.message;
|
|
338
|
+
pushStorage?.upsertSubscription({
|
|
339
|
+
userId: msg.userId,
|
|
340
|
+
deviceId: msg.clientId,
|
|
341
|
+
subscription: msg.subscription
|
|
342
|
+
});
|
|
343
|
+
const grant = pushStorage?.selectGrant(msg.userId);
|
|
344
|
+
sendRelayHost(conn, message.peerId, message.roomName, {
|
|
345
|
+
type: "client/push-register.ok",
|
|
346
|
+
protocol: PROTOCOL,
|
|
347
|
+
hasGrant: Boolean(grant)
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
default:
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (mesh.sendEnvelope(message, conn, { alreadyRemembered: true }) === 0) {
|
|
356
|
+
sendHostUnavailable(conn, message.peerId, message.roomName);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function handleRelayHost(conn, message) {
|
|
361
|
+
if (typeof message.peerId !== "string") return;
|
|
362
|
+
const client = clientConnections.get(message.peerId);
|
|
363
|
+
if (client) {
|
|
364
|
+
sendMessage(client, message.message);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (mesh.sendDiscoveredClientRoute(message.peerId, message.message, conn, message.roomName)) return;
|
|
368
|
+
const relayConn = mesh.remoteClientRoute(message.peerId);
|
|
369
|
+
if (relayConn && relayConn !== conn) {
|
|
370
|
+
sendMessage(relayConn, message);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
mesh.sendEnvelope(message, conn, { alreadyRemembered: true });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function handleRelayEnvelope(conn, message) {
|
|
377
|
+
if (process.env.MATTERHORN_DEBUG === "1" && message?.type === "relay.peer-signal") {
|
|
378
|
+
console.log(`[relay-peer-signal] mesh-envelope-recv ${JSON.stringify({ roomName: message.roomName, sourceClientId: message.sourceClientId, targetClientId: message.targetClientId, hasId: Boolean(message.id) })}`);
|
|
379
|
+
}
|
|
380
|
+
const validation = validateRelayEnvelope(message);
|
|
381
|
+
if (!validation.ok) {
|
|
382
|
+
if (process.env.MATTERHORN_DEBUG === "1" && message?.type === "relay.peer-signal") {
|
|
383
|
+
console.log(`[relay-peer-signal] mesh-envelope-invalid ${JSON.stringify({ message: validation.message })}`);
|
|
384
|
+
}
|
|
385
|
+
sendInvalidRelayEnvelope(conn, validation);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
mesh.recordRelayLiveness?.(conn, message.relayAddress);
|
|
389
|
+
|
|
390
|
+
if (message.type === "relay.ping") {
|
|
391
|
+
mesh.sendPong?.(conn, message);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (message.type === "relay.pong") return;
|
|
395
|
+
|
|
396
|
+
if (!mesh.rememberMessage(message.id)) return;
|
|
397
|
+
|
|
398
|
+
switch (message.type) {
|
|
399
|
+
case "relay.status":
|
|
400
|
+
sendMessage(conn, relayStatus.relayStatusMessage(message.requestId));
|
|
401
|
+
return;
|
|
402
|
+
case "relay.client":
|
|
403
|
+
handleRelayClient(conn, message);
|
|
404
|
+
return;
|
|
405
|
+
case "relay.host":
|
|
406
|
+
handleRelayHost(conn, message);
|
|
407
|
+
return;
|
|
408
|
+
case "relay.peer-signal": {
|
|
409
|
+
if (typeof message.roomName !== "string") return;
|
|
410
|
+
if (typeof message.sourcePeerId === "string") mesh.rememberRemoteClientRoute(message.sourcePeerId, conn);
|
|
411
|
+
mesh.rememberClientRoute(conn, message.roomName, message.sourceClientId, message.sourcePeerId);
|
|
412
|
+
if (peerSignals.sendPeerSignalToClient(message, conn)) return;
|
|
413
|
+
mesh.sendEnvelope(message, conn, { alreadyRemembered: true });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
case "relay.matterhorn-operation":
|
|
417
|
+
if (typeof message.roomName !== "string") return;
|
|
418
|
+
void relayPluginRuntimes?.handleRelayOperation?.(message).catch((error) => {
|
|
419
|
+
sendMessage(conn, { type: "relay.error", code: "matterhorn-operation-failed", message: error?.message || "Relay Matterhorn operation failed" });
|
|
420
|
+
});
|
|
421
|
+
mesh.sendEnvelope(message, conn, { alreadyRemembered: true });
|
|
422
|
+
return;
|
|
423
|
+
case "relay.broadcast":
|
|
424
|
+
if (typeof message.roomName !== "string") return;
|
|
425
|
+
rememberPersonalStateFromHostMessage(message.roomName, message.message);
|
|
426
|
+
broadcastToRoom(message.roomName, message.message);
|
|
427
|
+
mesh.sendEnvelope(message, conn, { alreadyRemembered: true });
|
|
428
|
+
return;
|
|
429
|
+
case "relay.client.close": {
|
|
430
|
+
if (typeof message.peerId !== "string") return;
|
|
431
|
+
mesh.forgetRemoteClientRoute(message.peerId);
|
|
432
|
+
const client = clientConnections.get(message.peerId);
|
|
433
|
+
if (client) {
|
|
434
|
+
client.close?.();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
mesh.sendEnvelope(message, conn, { alreadyRemembered: true });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
case "relay.push": {
|
|
441
|
+
const userId = message.target?.userId;
|
|
442
|
+
if (!userId) return;
|
|
443
|
+
const grant = pushStorage?.selectGrant(userId);
|
|
444
|
+
if (grant) {
|
|
445
|
+
pushEgress?.sendRelayPush(message).then((result) => {
|
|
446
|
+
if (result?.delivered > 0) {
|
|
447
|
+
pushStorage?.deletePendingPush?.(message.pushId);
|
|
448
|
+
sendPushAck(conn, message, "delivered");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (persistPendingPush(message, result?.reason || "local-egress-deferred")) sendPushAck(conn, message);
|
|
452
|
+
}).catch((err) => {
|
|
453
|
+
if (process.env.MATTERHORN_DEBUG === "1") console.error(`Failed to send web push egress for user ${userId}:`, err);
|
|
454
|
+
if (persistPendingPush(message, "local-egress-error")) sendPushAck(conn, message);
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const events = eventStorage ? eventStorage.queryEvents([{ kinds: [9002] }]) : [];
|
|
459
|
+
let latestPayload = null;
|
|
460
|
+
for (const event of events) {
|
|
461
|
+
try {
|
|
462
|
+
const content = JSON.parse(event.content || "{}");
|
|
463
|
+
const payload = content.payload;
|
|
464
|
+
if (payload && payload.userId === userId) {
|
|
465
|
+
if (!latestPayload || payload.updatedAt > latestPayload.updatedAt) {
|
|
466
|
+
latestPayload = payload;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
} catch {}
|
|
470
|
+
}
|
|
471
|
+
if (latestPayload && Array.isArray(latestPayload.relayIds)) {
|
|
472
|
+
const homeRelayIds = new Set(latestPayload.relayIds);
|
|
473
|
+
const connections = mesh.connections().filter((c) => {
|
|
474
|
+
const addr = c.MatterhornRelayAddress || c.MatterhornExpectedRelayAddress;
|
|
475
|
+
return addr && homeRelayIds.has(addr);
|
|
476
|
+
});
|
|
477
|
+
connections.sort((a, b) => {
|
|
478
|
+
const loadA = (a.MatterhornRelayLoad?.clients || 0) + (a.MatterhornRelayLoad?.roomHosts || 0);
|
|
479
|
+
const loadB = (b.MatterhornRelayLoad?.clients || 0) + (b.MatterhornRelayLoad?.roomHosts || 0);
|
|
480
|
+
return loadA - loadB;
|
|
481
|
+
});
|
|
482
|
+
const tryNext = async () => {
|
|
483
|
+
if (index >= connections.length) {
|
|
484
|
+
if (persistPendingPush(message, "forward-unconfirmed")) sendPushAck(conn, message);
|
|
485
|
+
mesh.sendEnvelope(message, conn, { alreadyRemembered: true });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const targetConn = connections[index++];
|
|
489
|
+
if (targetConn.open === false) {
|
|
490
|
+
await tryNext();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (await sendPushWithAck(targetConn, message)) {
|
|
494
|
+
sendPushAck(conn, message);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
await tryNext();
|
|
498
|
+
};
|
|
499
|
+
let index = 0;
|
|
500
|
+
void tryNext();
|
|
501
|
+
} else {
|
|
502
|
+
if (persistPendingPush(message, "no-home-relay")) sendPushAck(conn, message);
|
|
503
|
+
mesh.sendEnvelope(message, conn, { alreadyRemembered: true });
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
case "relay.push.ack":
|
|
508
|
+
pushStorage?.deletePendingPush?.(message.pushId);
|
|
509
|
+
return;
|
|
510
|
+
default:
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
handleRelayEnvelope
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
module.exports = {
|
|
521
|
+
createRelayMeshEnvelopeHandler
|
|
522
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const DEFAULT_PEER_RECONNECT_DEBOUNCE_MS = 1000;
|
|
2
|
+
|
|
3
|
+
function isTransientPeerError(error) {
|
|
4
|
+
const type = error?.type;
|
|
5
|
+
const message = error?.message || "";
|
|
6
|
+
return type === "network"
|
|
7
|
+
|| type === "peer-unavailable"
|
|
8
|
+
|| type === "webrtc"
|
|
9
|
+
|| message.includes("Lost connection to server")
|
|
10
|
+
|| message.includes("Could not connect to peer");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function unavailablePeerIdFromError(error) {
|
|
14
|
+
const message = error?.message || "";
|
|
15
|
+
const match = message.match(/Could not connect to peer ([^\s.]+)/);
|
|
16
|
+
return match?.[1];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isUnavailablePeerError(error) {
|
|
20
|
+
return error?.type === "peer-unavailable" || Boolean(unavailablePeerIdFromError(error));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function transientPeerErrorLabel(error) {
|
|
24
|
+
const message = error?.message || "";
|
|
25
|
+
if (error?.type === "network" || message.includes("Lost connection to server")) return "signaling network error";
|
|
26
|
+
if (message.includes("Could not connect to peer")) return "remote relay peer unavailable";
|
|
27
|
+
return "transient peer error";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createRelayPeerLifecycle({
|
|
31
|
+
debug,
|
|
32
|
+
isClosing,
|
|
33
|
+
mesh,
|
|
34
|
+
peerReconnects,
|
|
35
|
+
relayPeer,
|
|
36
|
+
reconnectDebounceMs = DEFAULT_PEER_RECONNECT_DEBOUNCE_MS
|
|
37
|
+
}) {
|
|
38
|
+
function iceUnavailableRelayPeer(error) {
|
|
39
|
+
const peerId = unavailablePeerIdFromError(error);
|
|
40
|
+
if (!peerId) return false;
|
|
41
|
+
const relayAddress = mesh.iceUnavailablePeer(peerId);
|
|
42
|
+
if (!relayAddress) return false;
|
|
43
|
+
debug(`relay peer unavailable; icing ${relayAddress}`);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reconnectPeer(peer, label, reason = "disconnect") {
|
|
48
|
+
if (isClosing() || !peer || peer.destroyed || typeof peer.reconnect !== "function") return;
|
|
49
|
+
const peerId = peerReconnects.stablePeerId(peer);
|
|
50
|
+
if (peer.open && peer.disconnected !== true) {
|
|
51
|
+
debug(`${label} reconnect skipped after ${reason}; ${peerId} is already open`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const lastReconnectAt = peer.MatterhornLastReconnectAt || 0;
|
|
56
|
+
if (now - lastReconnectAt < reconnectDebounceMs) {
|
|
57
|
+
debug(`${label} reconnect skipped after ${reason}; ${peerId} already attempted recently`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
peer.MatterhornLastReconnectAt = now;
|
|
61
|
+
peerReconnects.warn(peer, label, reason);
|
|
62
|
+
try {
|
|
63
|
+
peer.reconnect();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const message = error?.message || String(error);
|
|
66
|
+
if (!message.includes("cannot reconnect because it is not disconnected")) {
|
|
67
|
+
debug(`${label} reconnect failed after ${reason}: ${message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function handlePeerError(peer, label, error) {
|
|
73
|
+
if (!isTransientPeerError(error)) {
|
|
74
|
+
console.error(`${label} error:`, error);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const errorLabel = transientPeerErrorLabel(error);
|
|
78
|
+
debug(`${label} ${errorLabel}: ${error?.message || String(error)}`);
|
|
79
|
+
if (peer === relayPeer() && isUnavailablePeerError(error)) iceUnavailableRelayPeer(error);
|
|
80
|
+
if (error?.type === "network") reconnectPeer(peer, label, errorLabel);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
handlePeerError,
|
|
85
|
+
reconnectPeer
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
DEFAULT_PEER_RECONNECT_DEBOUNCE_MS,
|
|
91
|
+
createRelayPeerLifecycle,
|
|
92
|
+
isTransientPeerError,
|
|
93
|
+
isUnavailablePeerError,
|
|
94
|
+
transientPeerErrorLabel,
|
|
95
|
+
unavailablePeerIdFromError
|
|
96
|
+
};
|