@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,132 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
function registryPathFromOptions(options = {}) {
|
|
5
|
+
if (options.registryPath) return options.registryPath;
|
|
6
|
+
if (options.memberRootRegistryPath) return options.memberRootRegistryPath;
|
|
7
|
+
if (options.storagePath) return `${options.storagePath}.member-roots.json`;
|
|
8
|
+
if (options.dataDir) return path.join(options.dataDir, "member-roots.json");
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function clone(value) {
|
|
13
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function safeKeyPart(value) {
|
|
17
|
+
return String(value || "").replace(/[\u0000-\u001f\u007f:]/g, "_");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rootKeyFor(roomName, memberId) {
|
|
21
|
+
return `${safeKeyPart(roomName)}:${safeKeyPart(memberId)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function claimRoom(claim) {
|
|
25
|
+
return claim?.roomId || claim?.roomName;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeClaim(claim) {
|
|
29
|
+
const roomName = claimRoom(claim);
|
|
30
|
+
if (!roomName || !claim?.memberId || !claim?.rootPublicKey) return undefined;
|
|
31
|
+
return {
|
|
32
|
+
roomName,
|
|
33
|
+
memberId: claim.memberId,
|
|
34
|
+
rootPublicKey: claim.rootPublicKey,
|
|
35
|
+
firstSeenAt: Number.isFinite(Number(claim.createdAt)) ? Number(claim.createdAt) : Date.now(),
|
|
36
|
+
updatedAt: Date.now(),
|
|
37
|
+
lastDeviceId: claim.deviceId,
|
|
38
|
+
lastKeyId: claim.keyId
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createMemoryRegistry(initial = {}) {
|
|
43
|
+
const roots = new Map(Object.entries(initial));
|
|
44
|
+
return {
|
|
45
|
+
path: undefined,
|
|
46
|
+
entries() {
|
|
47
|
+
return Object.fromEntries(roots.entries());
|
|
48
|
+
},
|
|
49
|
+
get(roomName, memberId) {
|
|
50
|
+
return clone(roots.get(rootKeyFor(roomName, memberId)));
|
|
51
|
+
},
|
|
52
|
+
remember(claim) {
|
|
53
|
+
const normalized = normalizeClaim(claim);
|
|
54
|
+
if (!normalized) return { ok: false, error: "member root claim is incomplete" };
|
|
55
|
+
const key = rootKeyFor(normalized.roomName, normalized.memberId);
|
|
56
|
+
const existing = roots.get(key);
|
|
57
|
+
if (existing && existing.rootPublicKey !== normalized.rootPublicKey) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: "member root public key changed",
|
|
61
|
+
existing: clone(existing),
|
|
62
|
+
incoming: clone(normalized)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const next = existing ? { ...existing, ...normalized, firstSeenAt: existing.firstSeenAt || normalized.firstSeenAt } : normalized;
|
|
66
|
+
roots.set(key, next);
|
|
67
|
+
return { ok: true, entry: clone(next) };
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readRegistryFile(file) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
75
|
+
if (parsed && parsed.version === 1 && parsed.roots && typeof parsed.roots === "object" && !Array.isArray(parsed.roots)) {
|
|
76
|
+
return parsed.roots;
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error && error.code === "ENOENT") return {};
|
|
80
|
+
}
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeRegistryFile(file, roots) {
|
|
85
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
86
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
87
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: 1, roots }, null, 2));
|
|
88
|
+
fs.renameSync(tmp, file);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createMemberRootRegistry(options = {}) {
|
|
92
|
+
const file = registryPathFromOptions(options);
|
|
93
|
+
if (!file) return createMemoryRegistry(options.initialRoots);
|
|
94
|
+
let roots = readRegistryFile(file);
|
|
95
|
+
function persist() {
|
|
96
|
+
writeRegistryFile(file, roots);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
path: file,
|
|
100
|
+
entries() {
|
|
101
|
+
roots = readRegistryFile(file);
|
|
102
|
+
return clone(roots);
|
|
103
|
+
},
|
|
104
|
+
get(roomName, memberId) {
|
|
105
|
+
roots = readRegistryFile(file);
|
|
106
|
+
return clone(roots[rootKeyFor(roomName, memberId)]);
|
|
107
|
+
},
|
|
108
|
+
remember(claim) {
|
|
109
|
+
const normalized = normalizeClaim(claim);
|
|
110
|
+
if (!normalized) return { ok: false, error: "member root claim is incomplete" };
|
|
111
|
+
roots = readRegistryFile(file);
|
|
112
|
+
const key = rootKeyFor(normalized.roomName, normalized.memberId);
|
|
113
|
+
const existing = roots[key];
|
|
114
|
+
if (existing && existing.rootPublicKey !== normalized.rootPublicKey) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: "member root public key changed",
|
|
118
|
+
existing: clone(existing),
|
|
119
|
+
incoming: clone(normalized)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
roots[key] = existing ? { ...existing, ...normalized, firstSeenAt: existing.firstSeenAt || normalized.firstSeenAt } : normalized;
|
|
123
|
+
persist();
|
|
124
|
+
return { ok: true, entry: clone(roots[key]) };
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
createMemberRootRegistry,
|
|
131
|
+
rootKeyFor
|
|
132
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const wrtcModule = require("@roamhq/wrtc");
|
|
2
|
+
const WebSocketModule = require("ws");
|
|
3
|
+
const xhr2Module = require("xhr2");
|
|
4
|
+
const {
|
|
5
|
+
DEFAULT_PEERJS_HOST,
|
|
6
|
+
DEFAULT_PEERJS_PATH,
|
|
7
|
+
DEFAULT_PEERJS_PORT,
|
|
8
|
+
peerJsSignalingConfig
|
|
9
|
+
} = require("./peerJsConfig.cjs");
|
|
10
|
+
const { patchNodePeer } = require("./nodePeerRacePatch.cjs");
|
|
11
|
+
|
|
12
|
+
const wrtc = wrtcModule.default || wrtcModule;
|
|
13
|
+
const WebSocket = WebSocketModule.default || WebSocketModule;
|
|
14
|
+
const XMLHttpRequest = xhr2Module.XMLHttpRequest || xhr2Module.default?.XMLHttpRequest || xhr2Module;
|
|
15
|
+
|
|
16
|
+
function debugPeerJs(message) {
|
|
17
|
+
if (process.env.MATTERHORN_DEBUG === "1") {
|
|
18
|
+
console.log(`matterhorn peerjs debug: ${message}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function peerJsSocketTarget(address) {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(String(address));
|
|
25
|
+
const id = url.searchParams.get("id");
|
|
26
|
+
const version = url.searchParams.get("version");
|
|
27
|
+
const suffix = [id ? `id=${id}` : "", version ? `version=${version}` : ""].filter(Boolean).join("&");
|
|
28
|
+
return `${url.protocol}//${url.host}${url.pathname}${suffix ? `?${suffix}` : ""}`;
|
|
29
|
+
} catch {
|
|
30
|
+
return String(address).replace(/([?&](?:key|token)=)[^&]+/g, "$1<redacted>");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createDebugWebSocket() {
|
|
35
|
+
return class MatterhornDebugWebSocket extends WebSocket {
|
|
36
|
+
constructor(address, protocols, options) {
|
|
37
|
+
const target = peerJsSocketTarget(address);
|
|
38
|
+
const openedAt = Date.now();
|
|
39
|
+
let lastMessage = "none";
|
|
40
|
+
debugPeerJs(`socket opening ${target}`);
|
|
41
|
+
super(address, protocols, options);
|
|
42
|
+
this.on("open", () => debugPeerJs(`socket open ${target}`));
|
|
43
|
+
this.on("message", (data) => {
|
|
44
|
+
lastMessage = peerJsServerMessageSummary(data);
|
|
45
|
+
debugPeerJs(`socket message ${target} ${lastMessage}`);
|
|
46
|
+
});
|
|
47
|
+
this.on("close", (code, reason) => {
|
|
48
|
+
const reasonText = Buffer.isBuffer(reason) ? reason.toString("utf8") : String(reason || "");
|
|
49
|
+
const ageMs = Date.now() - openedAt;
|
|
50
|
+
debugPeerJs(`socket close ${target} code=${code} ageMs=${ageMs} lastMessage=${lastMessage}${reasonText ? ` reason=${reasonText}` : ""}`);
|
|
51
|
+
});
|
|
52
|
+
this.on("error", (error) => {
|
|
53
|
+
const code = error?.code ? ` code=${error.code}` : "";
|
|
54
|
+
debugPeerJs(`socket error ${target}${code}: ${error?.message || String(error)}`);
|
|
55
|
+
});
|
|
56
|
+
this.on("unexpected-response", (_request, response) => {
|
|
57
|
+
debugPeerJs(`socket unexpected response ${target}: ${response.statusCode} ${response.statusMessage || ""}`.trim());
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function peerJsServerMessageSummary(data) {
|
|
64
|
+
try {
|
|
65
|
+
const raw = Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
|
|
66
|
+
const message = JSON.parse(raw);
|
|
67
|
+
const parts = [`type=${message.type || "unknown"}`];
|
|
68
|
+
if (message.src) parts.push(`src=${message.src}`);
|
|
69
|
+
if (message.dst) parts.push(`dst=${message.dst}`);
|
|
70
|
+
return parts.join(" ");
|
|
71
|
+
} catch {
|
|
72
|
+
return "unparseable";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function installGlobal(name, value, force = false) {
|
|
77
|
+
if (!force && globalThis[name] != null) return;
|
|
78
|
+
Object.defineProperty(globalThis, name, {
|
|
79
|
+
value,
|
|
80
|
+
configurable: true,
|
|
81
|
+
writable: true
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function installPeerJsNodeGlobals(signaling = peerJsSignalingConfig()) {
|
|
86
|
+
installGlobal("window", globalThis);
|
|
87
|
+
installGlobal("self", globalThis);
|
|
88
|
+
installGlobal("location", {
|
|
89
|
+
protocol: signaling.secure ? "https:" : "http:",
|
|
90
|
+
hostname: signaling.host,
|
|
91
|
+
host: `${signaling.host}:${signaling.port}`
|
|
92
|
+
}, true);
|
|
93
|
+
installGlobal("navigator", { userAgent: "node" });
|
|
94
|
+
installGlobal("WebSocket", createDebugWebSocket(), true);
|
|
95
|
+
installGlobal("XMLHttpRequest", XMLHttpRequest);
|
|
96
|
+
installGlobal("RTCPeerConnection", wrtc.RTCPeerConnection);
|
|
97
|
+
installGlobal("RTCSessionDescription", wrtc.RTCSessionDescription);
|
|
98
|
+
installGlobal("RTCIceCandidate", wrtc.RTCIceCandidate);
|
|
99
|
+
installGlobal("MediaStream", wrtc.MediaStream);
|
|
100
|
+
installGlobal("MediaStreamTrack", wrtc.MediaStreamTrack);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function createNodePeer(peerId, iceServers, options = {}) {
|
|
104
|
+
const signaling = options.signaling || peerJsSignalingConfig();
|
|
105
|
+
installPeerJsNodeGlobals(signaling);
|
|
106
|
+
const peerjs = await import("peerjs");
|
|
107
|
+
const Peer = peerjs.Peer || peerjs.default?.Peer || peerjs["module.exports"]?.Peer;
|
|
108
|
+
const peer = new Peer(peerId, {
|
|
109
|
+
debug: Number(process.env.PEER_DEBUG || 0),
|
|
110
|
+
host: signaling.host,
|
|
111
|
+
port: signaling.port,
|
|
112
|
+
path: signaling.path,
|
|
113
|
+
secure: signaling.secure,
|
|
114
|
+
config: {
|
|
115
|
+
iceServers,
|
|
116
|
+
sdpSemantics: "unified-plan"
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return patchNodePeer(peer, debugPeerJs);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
DEFAULT_PEERJS_HOST,
|
|
124
|
+
DEFAULT_PEERJS_PATH,
|
|
125
|
+
DEFAULT_PEERJS_PORT,
|
|
126
|
+
createNodePeer
|
|
127
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
function isPromiseLike(value) {
|
|
2
|
+
return value && typeof value.then === "function";
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function isMissingProviderCandidateRace(connection, error) {
|
|
6
|
+
const message = error?.message || "";
|
|
7
|
+
return !connection?.provider
|
|
8
|
+
&& message.includes("Cannot read properties of null")
|
|
9
|
+
&& message.includes("emitError");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isMissingDataChannelRace(error) {
|
|
13
|
+
const message = error?.message || "";
|
|
14
|
+
const stack = error?.stack || "";
|
|
15
|
+
return message.includes("Cannot read properties of null")
|
|
16
|
+
&& message.includes("_initializeDataChannel")
|
|
17
|
+
&& stack.includes("peerjs");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPeerJsConnectionRace(connection, error) {
|
|
21
|
+
const stack = error?.stack || "";
|
|
22
|
+
return isMissingProviderCandidateRace(connection, error)
|
|
23
|
+
|| isMissingDataChannelRace(error)
|
|
24
|
+
|| (stack.includes("peerjs") && stack.includes("handleCandidate") && String(error?.message || "").includes("emitError"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ignorePeerJsConnectionRace(connection, error, debug) {
|
|
28
|
+
if (!isPeerJsConnectionRace(connection, error)) return false;
|
|
29
|
+
debug?.(`ignored stale PeerJS connection event: ${error?.message || String(error)}`);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withRaceFilter(connection, debug, callback) {
|
|
34
|
+
try {
|
|
35
|
+
const result = callback();
|
|
36
|
+
if (!isPromiseLike(result)) return result;
|
|
37
|
+
return result.catch((error) => {
|
|
38
|
+
if (ignorePeerJsConnectionRace(connection, error, debug)) return undefined;
|
|
39
|
+
throw error;
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (ignorePeerJsConnectionRace(connection, error, debug)) return undefined;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function patchPeerConnectionEvents(connection, debug) {
|
|
48
|
+
const peerConnection = connection?.peerConnection;
|
|
49
|
+
if (!peerConnection || peerConnection.MatterhornPeerJsRacePatched) return;
|
|
50
|
+
peerConnection.MatterhornPeerJsRacePatched = true;
|
|
51
|
+
const originalDataChannel = peerConnection.ondatachannel;
|
|
52
|
+
if (typeof originalDataChannel === "function") {
|
|
53
|
+
peerConnection.ondatachannel = function onDataChannel(event) {
|
|
54
|
+
return withRaceFilter(connection, debug, () => originalDataChannel.call(this, event));
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function patchNegotiator(connection, debug) {
|
|
60
|
+
const negotiator = connection?._negotiator;
|
|
61
|
+
if (!negotiator || negotiator.MatterhornPeerJsRacePatched) return;
|
|
62
|
+
negotiator.MatterhornPeerJsRacePatched = true;
|
|
63
|
+
const originalCandidate = negotiator.handleCandidate;
|
|
64
|
+
if (typeof originalCandidate === "function") {
|
|
65
|
+
negotiator.handleCandidate = function handleCandidate(ice) {
|
|
66
|
+
return withRaceFilter(connection, debug, () => originalCandidate.call(this, ice));
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function patchConnectionMessageHandler(connection, debug) {
|
|
72
|
+
if (!connection || connection.MatterhornPeerJsRacePatched) return;
|
|
73
|
+
connection.MatterhornPeerJsRacePatched = true;
|
|
74
|
+
const originalHandleMessage = connection.handleMessage;
|
|
75
|
+
if (typeof originalHandleMessage === "function") {
|
|
76
|
+
connection.handleMessage = function handleMessage(message) {
|
|
77
|
+
return withRaceFilter(connection, debug, () => originalHandleMessage.call(this, message));
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function patchPeerJsConnection(connection, debug) {
|
|
83
|
+
if (!connection) return connection;
|
|
84
|
+
patchConnectionMessageHandler(connection, debug);
|
|
85
|
+
patchNegotiator(connection, debug);
|
|
86
|
+
patchPeerConnectionEvents(connection, debug);
|
|
87
|
+
return connection;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function patchNodePeer(peer, debug) {
|
|
91
|
+
if (!peer || peer.MatterhornPeerJsRacePatched) return peer;
|
|
92
|
+
peer.MatterhornPeerJsRacePatched = true;
|
|
93
|
+
const originalAddConnection = peer._addConnection;
|
|
94
|
+
if (typeof originalAddConnection === "function") {
|
|
95
|
+
peer._addConnection = function addConnection(peerId, connection) {
|
|
96
|
+
return originalAddConnection.call(this, peerId, patchPeerJsConnection(connection, debug));
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return peer;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
isPeerJsConnectionRace,
|
|
104
|
+
patchNodePeer,
|
|
105
|
+
patchPeerJsConnection
|
|
106
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const { signalingUrl } = require("@mh-gg/relay-core");
|
|
2
|
+
|
|
3
|
+
const DEFAULT_PEERJS_HOST = "peer.yage.games";
|
|
4
|
+
const DEFAULT_PEERJS_PORT = 443;
|
|
5
|
+
const DEFAULT_PEERJS_PATH = "/";
|
|
6
|
+
|
|
7
|
+
function peerJsSignalingConfig(env = process.env) {
|
|
8
|
+
return {
|
|
9
|
+
host: env.MATTERHORN_PEERJS_HOST || DEFAULT_PEERJS_HOST,
|
|
10
|
+
port: Number(env.MATTERHORN_PEERJS_PORT || DEFAULT_PEERJS_PORT),
|
|
11
|
+
path: env.MATTERHORN_PEERJS_PATH || DEFAULT_PEERJS_PATH,
|
|
12
|
+
secure: env.MATTERHORN_PEERJS_SECURE !== "false"
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function peerJsSignalingUrl(env = process.env) {
|
|
17
|
+
return signalingUrl(peerJsSignalingConfig(env));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
DEFAULT_PEERJS_HOST,
|
|
22
|
+
DEFAULT_PEERJS_PATH,
|
|
23
|
+
DEFAULT_PEERJS_PORT,
|
|
24
|
+
peerJsSignalingConfig,
|
|
25
|
+
peerJsSignalingUrl
|
|
26
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { sendToUser } = require("@mh-gg/push");
|
|
2
|
+
|
|
3
|
+
function createPushEgress({ pushStorage, subject, send = sendToUser }) {
|
|
4
|
+
function payloadDeviceId(payload) {
|
|
5
|
+
try {
|
|
6
|
+
const parsed = typeof payload === "string" ? JSON.parse(payload) : payload;
|
|
7
|
+
return typeof parsed?.deviceId === "string" ? parsed.deviceId : undefined;
|
|
8
|
+
} catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function sendRelayPush(message) {
|
|
14
|
+
const userId = message?.target?.userId;
|
|
15
|
+
if (!userId) throw new Error("relay.push target userId is required.");
|
|
16
|
+
const grant = pushStorage.selectGrant(userId);
|
|
17
|
+
if (!grant) return { delivered: 0, pruned: 0, reason: "missing-vapid-grant" };
|
|
18
|
+
const deviceId = payloadDeviceId(message.payload);
|
|
19
|
+
const subscriptions = deviceId
|
|
20
|
+
? [pushStorage.selectSubscription(userId, deviceId)].filter(Boolean)
|
|
21
|
+
: pushStorage.selectSubscriptionsForUser(userId);
|
|
22
|
+
if (subscriptions.length === 0) return { delivered: 0, pruned: 0, reason: "missing-subscription" };
|
|
23
|
+
const results = await send({
|
|
24
|
+
vapidPublicKey: grant.vapidPublicKey,
|
|
25
|
+
vapidPrivateKey: grant.vapidPrivateKey,
|
|
26
|
+
subject,
|
|
27
|
+
subscriptions,
|
|
28
|
+
payload: Buffer.from(message.payload),
|
|
29
|
+
ttlSeconds: message.ttlSeconds,
|
|
30
|
+
urgency: message.urgency
|
|
31
|
+
});
|
|
32
|
+
let delivered = 0;
|
|
33
|
+
let pruned = 0;
|
|
34
|
+
for (const result of results) {
|
|
35
|
+
if (result.gone) {
|
|
36
|
+
pushStorage.deleteSubscriptionByEndpoint(userId, result.endpoint);
|
|
37
|
+
pruned += 1;
|
|
38
|
+
} else {
|
|
39
|
+
delivered += 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { delivered, pruned };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { sendRelayPush };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { createPushEgress };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
4
|
+
|
|
5
|
+
function sqliteFile(storagePath) {
|
|
6
|
+
return path.join(storagePath, "events.sqlite");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class PushStorage {
|
|
10
|
+
constructor(storagePathOrDb) {
|
|
11
|
+
if (typeof storagePathOrDb === "string") {
|
|
12
|
+
fs.mkdirSync(storagePathOrDb, { recursive: true });
|
|
13
|
+
this.db = new DatabaseSync(sqliteFile(storagePathOrDb));
|
|
14
|
+
} else {
|
|
15
|
+
this.db = storagePathOrDb;
|
|
16
|
+
}
|
|
17
|
+
this.db.exec(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
19
|
+
user_id TEXT NOT NULL,
|
|
20
|
+
device_id TEXT NOT NULL,
|
|
21
|
+
endpoint TEXT NOT NULL,
|
|
22
|
+
p256dh TEXT NOT NULL,
|
|
23
|
+
auth TEXT NOT NULL,
|
|
24
|
+
updated_at INTEGER NOT NULL,
|
|
25
|
+
PRIMARY KEY (user_id, device_id)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS push_grants (
|
|
29
|
+
user_id TEXT PRIMARY KEY,
|
|
30
|
+
vapid_private TEXT NOT NULL,
|
|
31
|
+
vapid_public TEXT NOT NULL,
|
|
32
|
+
updated_at INTEGER NOT NULL
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS pending_pushes (
|
|
36
|
+
push_id TEXT PRIMARY KEY,
|
|
37
|
+
user_id TEXT NOT NULL,
|
|
38
|
+
payload TEXT NOT NULL,
|
|
39
|
+
created_at INTEGER NOT NULL,
|
|
40
|
+
expires_at INTEGER NOT NULL,
|
|
41
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_pending_pushes_user
|
|
45
|
+
ON pending_pushes (user_id);
|
|
46
|
+
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_pending_pushes_expires
|
|
48
|
+
ON pending_pushes (expires_at);
|
|
49
|
+
`);
|
|
50
|
+
this.upsertSubscriptionStatement = this.db.prepare(`
|
|
51
|
+
INSERT INTO push_subscriptions (user_id, device_id, endpoint, p256dh, auth, updated_at)
|
|
52
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
53
|
+
ON CONFLICT(user_id, device_id) DO UPDATE SET
|
|
54
|
+
endpoint = excluded.endpoint,
|
|
55
|
+
p256dh = excluded.p256dh,
|
|
56
|
+
auth = excluded.auth,
|
|
57
|
+
updated_at = excluded.updated_at
|
|
58
|
+
`);
|
|
59
|
+
this.selectSubscriptionStatement = this.db.prepare(`
|
|
60
|
+
SELECT user_id, device_id, endpoint, p256dh, auth, updated_at
|
|
61
|
+
FROM push_subscriptions
|
|
62
|
+
WHERE user_id = ? AND device_id = ?
|
|
63
|
+
LIMIT 1
|
|
64
|
+
`);
|
|
65
|
+
this.selectSubscriptionsForUserStatement = this.db.prepare(`
|
|
66
|
+
SELECT user_id, device_id, endpoint, p256dh, auth, updated_at
|
|
67
|
+
FROM push_subscriptions
|
|
68
|
+
WHERE user_id = ?
|
|
69
|
+
ORDER BY device_id ASC
|
|
70
|
+
`);
|
|
71
|
+
this.deleteSubscriptionStatement = this.db.prepare(`
|
|
72
|
+
DELETE FROM push_subscriptions
|
|
73
|
+
WHERE user_id = ? AND device_id = ?
|
|
74
|
+
`);
|
|
75
|
+
this.deleteSubscriptionByEndpointStatement = this.db.prepare(`
|
|
76
|
+
DELETE FROM push_subscriptions
|
|
77
|
+
WHERE user_id = ? AND endpoint = ?
|
|
78
|
+
`);
|
|
79
|
+
this.upsertGrantStatement = this.db.prepare(`
|
|
80
|
+
INSERT INTO push_grants (user_id, vapid_private, vapid_public, updated_at)
|
|
81
|
+
VALUES (?, ?, ?, ?)
|
|
82
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
83
|
+
vapid_private = excluded.vapid_private,
|
|
84
|
+
vapid_public = excluded.vapid_public,
|
|
85
|
+
updated_at = excluded.updated_at
|
|
86
|
+
`);
|
|
87
|
+
this.selectGrantStatement = this.db.prepare(`
|
|
88
|
+
SELECT user_id, vapid_private, vapid_public, updated_at
|
|
89
|
+
FROM push_grants
|
|
90
|
+
WHERE user_id = ?
|
|
91
|
+
LIMIT 1
|
|
92
|
+
`);
|
|
93
|
+
this.upsertPendingPushStatement = this.db.prepare(`
|
|
94
|
+
INSERT INTO pending_pushes (push_id, user_id, payload, created_at, expires_at, attempts)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, 0)
|
|
96
|
+
ON CONFLICT(push_id) DO NOTHING
|
|
97
|
+
`);
|
|
98
|
+
this.selectPendingPushesStatement = this.db.prepare(`
|
|
99
|
+
SELECT push_id, user_id, payload, created_at, expires_at, attempts
|
|
100
|
+
FROM pending_pushes
|
|
101
|
+
WHERE expires_at > ?
|
|
102
|
+
ORDER BY created_at ASC
|
|
103
|
+
LIMIT ?
|
|
104
|
+
`);
|
|
105
|
+
this.deletePendingPushStatement = this.db.prepare(`
|
|
106
|
+
DELETE FROM pending_pushes
|
|
107
|
+
WHERE push_id = ?
|
|
108
|
+
`);
|
|
109
|
+
this.pruneExpiredPendingPushesStatement = this.db.prepare(`
|
|
110
|
+
DELETE FROM pending_pushes
|
|
111
|
+
WHERE expires_at <= ?
|
|
112
|
+
`);
|
|
113
|
+
this.incrementPendingPushAttemptsStatement = this.db.prepare(`
|
|
114
|
+
UPDATE pending_pushes
|
|
115
|
+
SET attempts = attempts + 1
|
|
116
|
+
WHERE push_id = ?
|
|
117
|
+
`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
subscriptionFromRow(row) {
|
|
121
|
+
if (!row) return undefined;
|
|
122
|
+
return {
|
|
123
|
+
userId: row.user_id,
|
|
124
|
+
deviceId: row.device_id,
|
|
125
|
+
endpoint: row.endpoint,
|
|
126
|
+
keys: {
|
|
127
|
+
p256dh: row.p256dh,
|
|
128
|
+
auth: row.auth
|
|
129
|
+
},
|
|
130
|
+
updatedAt: row.updated_at
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
grantFromRow(row) {
|
|
135
|
+
if (!row) return undefined;
|
|
136
|
+
return {
|
|
137
|
+
userId: row.user_id,
|
|
138
|
+
vapidPrivateKey: row.vapid_private,
|
|
139
|
+
vapidPublicKey: row.vapid_public,
|
|
140
|
+
updatedAt: row.updated_at
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
upsertSubscription(input, now = Date.now()) {
|
|
145
|
+
this.upsertSubscriptionStatement.run(
|
|
146
|
+
input.userId,
|
|
147
|
+
input.deviceId,
|
|
148
|
+
input.subscription.endpoint,
|
|
149
|
+
input.subscription.keys.p256dh,
|
|
150
|
+
input.subscription.keys.auth,
|
|
151
|
+
now
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
selectSubscription(userId, deviceId) {
|
|
156
|
+
return this.subscriptionFromRow(this.selectSubscriptionStatement.get(userId, deviceId));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
selectSubscriptionsForUser(userId) {
|
|
160
|
+
return this.selectSubscriptionsForUserStatement.all(userId).map((row) => this.subscriptionFromRow(row));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
deleteSubscription(userId, deviceId) {
|
|
164
|
+
this.deleteSubscriptionStatement.run(userId, deviceId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
deleteSubscriptionByEndpoint(userId, endpoint) {
|
|
168
|
+
this.deleteSubscriptionByEndpointStatement.run(userId, endpoint);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
upsertGrant(input, now = Date.now()) {
|
|
172
|
+
this.upsertGrantStatement.run(input.userId, input.vapidPrivateKey, input.vapidPublicKey, now);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
selectGrant(userId) {
|
|
176
|
+
return this.grantFromRow(this.selectGrantStatement.get(userId));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
pendingPushFromRow(row) {
|
|
180
|
+
if (!row) return undefined;
|
|
181
|
+
try {
|
|
182
|
+
return {
|
|
183
|
+
pushId: row.push_id,
|
|
184
|
+
userId: row.user_id,
|
|
185
|
+
payload: JSON.parse(row.payload),
|
|
186
|
+
createdAt: row.created_at,
|
|
187
|
+
expiresAt: row.expires_at,
|
|
188
|
+
attempts: row.attempts
|
|
189
|
+
};
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
upsertPendingPush(input, now = Date.now()) {
|
|
196
|
+
const ttlSeconds = Number.isInteger(input.ttlSeconds) && input.ttlSeconds > 0 ? input.ttlSeconds : 60 * 60;
|
|
197
|
+
const createdAt = Number.isInteger(input.createdAt) ? input.createdAt : now;
|
|
198
|
+
const expiresAt = Number.isInteger(input.expiresAt) ? input.expiresAt : createdAt + ttlSeconds * 1000;
|
|
199
|
+
this.upsertPendingPushStatement.run(input.pushId, input.userId, JSON.stringify(input.payload), createdAt, expiresAt);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
selectPendingPushes(limit = 100, now = Date.now()) {
|
|
203
|
+
return this.selectPendingPushesStatement.all(now, limit).map((row) => this.pendingPushFromRow(row)).filter(Boolean);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
deletePendingPush(pushId) {
|
|
207
|
+
this.deletePendingPushStatement.run(pushId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
incrementPendingPushAttempts(pushId) {
|
|
211
|
+
this.incrementPendingPushAttemptsStatement.run(pushId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
pruneExpiredPendingPushes(now = Date.now()) {
|
|
215
|
+
return this.pruneExpiredPendingPushesStatement.run(now).changes;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
close() {
|
|
219
|
+
try {
|
|
220
|
+
this.db.close();
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function createPushStorage(storagePath) {
|
|
226
|
+
return new PushStorage(storagePath);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
PushStorage,
|
|
231
|
+
createPushStorage,
|
|
232
|
+
sqliteFile
|
|
233
|
+
};
|