@mh-gg/relay-runtime 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/package.json +26 -0
- package/src/encryptedRoomRuntime.cjs +641 -0
- package/src/encryptedRoomRuntimeManager.cjs +131 -0
- package/src/index.cjs +152 -0
- package/src/pluginRuntimeHost.cjs +779 -0
- package/test/encryptedRoomRuntime.test.cjs +729 -0
- package/test/operation-role-keys.test.cjs +346 -0
- package/test/plugin-runtime-manager.test.cjs +651 -0
- package/test/relay-runtime.test.cjs +219 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const { createEncryptedRoomRuntime } = require("./encryptedRoomRuntime.cjs");
|
|
2
|
+
|
|
3
|
+
const MATTERHORN_OPERATION_KIND = 9001;
|
|
4
|
+
const MATTERHORN_INDEX_NGRAM_KIND = 9013;
|
|
5
|
+
|
|
6
|
+
function tagValue(tags, name) {
|
|
7
|
+
if (!Array.isArray(tags)) return undefined;
|
|
8
|
+
const tag = tags.find((item) => Array.isArray(item) && item[0] === name && typeof item[1] === "string");
|
|
9
|
+
return tag?.[1];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function roomNameForEncryptedRuntimeEvent(event) {
|
|
13
|
+
const roomName = tagValue(event?.tags, "d") || tagValue(event?.tags, "room");
|
|
14
|
+
if (typeof roomName !== "string" || !roomName) return undefined;
|
|
15
|
+
if (event.kind === MATTERHORN_OPERATION_KIND && tagValue(event.tags, "scheme") !== "matterhorn.operation.v2") return undefined;
|
|
16
|
+
if (event.kind !== MATTERHORN_OPERATION_KIND && event.kind !== MATTERHORN_INDEX_NGRAM_KIND) return undefined;
|
|
17
|
+
return roomName;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createEncryptedRoomRuntimeManager(options = {}) {
|
|
21
|
+
const logger = options.logger || { debug() {}, info() {}, warn() {}, error() {} };
|
|
22
|
+
const maxEventsPerStream = Number.isInteger(options.maxEventsPerStream) && options.maxEventsPerStream > 0
|
|
23
|
+
? options.maxEventsPerStream
|
|
24
|
+
: 10000;
|
|
25
|
+
const runtimes = new Map(); // roomName -> EncryptedRoomRuntime
|
|
26
|
+
|
|
27
|
+
function ensureRoom(roomName) {
|
|
28
|
+
let runtime = runtimes.get(roomName);
|
|
29
|
+
if (!runtime) {
|
|
30
|
+
runtime = createEncryptedRoomRuntime({ roomName, logger, maxEventsPerStream });
|
|
31
|
+
runtimes.set(roomName, runtime);
|
|
32
|
+
}
|
|
33
|
+
return runtime;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasRoom(roomName) {
|
|
37
|
+
return runtimes.has(roomName);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getRoom(roomName) {
|
|
41
|
+
return runtimes.get(roomName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function removeRoom(roomName) {
|
|
45
|
+
return runtimes.delete(roomName);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function insertEvent(roomName, nostrEvent) {
|
|
49
|
+
const runtime = ensureRoom(roomName);
|
|
50
|
+
return runtime.insert(nostrEvent);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function searchNgrams(roomName, options = {}) {
|
|
54
|
+
const runtime = getRoom(roomName);
|
|
55
|
+
if (!runtime || typeof runtime.searchNgrams !== "function") {
|
|
56
|
+
return { events: [], matches: [], total: 0, queryTokenCount: 0 };
|
|
57
|
+
}
|
|
58
|
+
return runtime.searchNgrams(options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ngramIndexStats(roomName) {
|
|
62
|
+
const runtime = getRoom(roomName);
|
|
63
|
+
return runtime?.ngramIndexStats?.() || { targets: 0, indexEvents: 0, tokenPostingLists: 0, postings: 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function listRooms() {
|
|
67
|
+
return Array.from(runtimes.keys()).sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function snapshot() {
|
|
71
|
+
const result = {};
|
|
72
|
+
for (const [roomName, runtime] of runtimes) {
|
|
73
|
+
result[roomName] = runtime.snapshot();
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function restore(data) {
|
|
79
|
+
let inserted = 0;
|
|
80
|
+
let rooms = 0;
|
|
81
|
+
for (const [roomName, snap] of Object.entries(data || {})) {
|
|
82
|
+
const runtime = ensureRoom(roomName);
|
|
83
|
+
const result = runtime.restore(snap);
|
|
84
|
+
if (result.ok) {
|
|
85
|
+
rooms += 1;
|
|
86
|
+
inserted += result.inserted;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, rooms, inserted };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rebuildFromEvents(queryEvents) {
|
|
93
|
+
let inserted = 0;
|
|
94
|
+
let operationEvents = 0;
|
|
95
|
+
let ngramIndexEvents = 0;
|
|
96
|
+
try {
|
|
97
|
+
const events = queryEvents([{ kinds: [MATTERHORN_OPERATION_KIND, MATTERHORN_INDEX_NGRAM_KIND] }]);
|
|
98
|
+
for (const event of events || []) {
|
|
99
|
+
const roomName = roomNameForEncryptedRuntimeEvent(event);
|
|
100
|
+
if (!roomName) continue;
|
|
101
|
+
const result = insertEvent(roomName, event);
|
|
102
|
+
if (result.ok && result.inserted) {
|
|
103
|
+
inserted += 1;
|
|
104
|
+
if (event.kind === MATTERHORN_OPERATION_KIND) operationEvents += 1;
|
|
105
|
+
else if (event.kind === MATTERHORN_INDEX_NGRAM_KIND) ngramIndexEvents += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
logger.warn?.("Encrypted room runtime rebuild failed", error);
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, inserted, operationEvents, ngramIndexEvents };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
ensureRoom,
|
|
116
|
+
getRoom,
|
|
117
|
+
hasRoom,
|
|
118
|
+
insertEvent,
|
|
119
|
+
listRooms,
|
|
120
|
+
ngramIndexStats,
|
|
121
|
+
removeRoom,
|
|
122
|
+
restore,
|
|
123
|
+
searchNgrams,
|
|
124
|
+
snapshot,
|
|
125
|
+
rebuildFromEvents
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
createEncryptedRoomRuntimeManager
|
|
131
|
+
};
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const { ensureRoomSecurity } = require("@mh-gg/room-security");
|
|
2
|
+
const { createEncryptedRoomRuntime } = require("./encryptedRoomRuntime.cjs");
|
|
3
|
+
const { createEncryptedRoomRuntimeManager } = require("./encryptedRoomRuntimeManager.cjs");
|
|
4
|
+
const { createRelayPluginRuntimeManager, isNewerRoomState } = require("./pluginRuntimeHost.cjs");
|
|
5
|
+
|
|
6
|
+
const PEER_OPEN_TIMEOUT_MS = 15000;
|
|
7
|
+
const PEER_RECONNECT_WARN_WINDOW_MS = 60000;
|
|
8
|
+
const PEER_RECONNECT_WARN_THRESHOLD = 3;
|
|
9
|
+
|
|
10
|
+
function jsonClone(value) {
|
|
11
|
+
return JSON.parse(JSON.stringify(value));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isNewerStore(left, right) {
|
|
15
|
+
const leftVersion = Number(left?.state?.version || 0);
|
|
16
|
+
const rightVersion = Number(right?.state?.version || 0);
|
|
17
|
+
if (leftVersion !== rightVersion) return leftVersion > rightVersion;
|
|
18
|
+
const leftUpdatedAt = Number(left?.updatedAt || left?.state?.updatedAt || 0);
|
|
19
|
+
const rightUpdatedAt = Number(right?.updatedAt || right?.state?.updatedAt || 0);
|
|
20
|
+
return leftUpdatedAt > rightUpdatedAt;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function relayRoomStoreSnapshot(roomName, rawStore) {
|
|
24
|
+
if (!rawStore || typeof rawStore !== "object" || rawStore.roomName !== roomName) return undefined;
|
|
25
|
+
const snapshot = jsonClone(rawStore);
|
|
26
|
+
ensureRoomSecurity(snapshot);
|
|
27
|
+
return snapshot;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function removePeerListener(peer, event, callback) {
|
|
31
|
+
if (typeof peer.off === "function") {
|
|
32
|
+
peer.off(event, callback);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (typeof peer.removeListener === "function") peer.removeListener(event, callback);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function waitForPeerOpen(peer, label, timeoutMs = PEER_OPEN_TIMEOUT_MS) {
|
|
39
|
+
if (peer.open) return Promise.resolve(peer.id);
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
cleanup();
|
|
43
|
+
reject(new Error(`${label} did not open within ${timeoutMs}ms`));
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
|
|
46
|
+
function cleanup() {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
removePeerListener(peer, "open", onOpen);
|
|
49
|
+
removePeerListener(peer, "error", onError);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onOpen(id) {
|
|
53
|
+
cleanup();
|
|
54
|
+
resolve(id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onError(error) {
|
|
58
|
+
cleanup();
|
|
59
|
+
reject(error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
peer.on("open", onOpen);
|
|
63
|
+
peer.on("error", onError);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createPeerReconnectTracker(options = {}) {
|
|
68
|
+
const loggedPeerOpenIds = new Set();
|
|
69
|
+
const statsByKey = new Map();
|
|
70
|
+
const warnedLabels = new Set();
|
|
71
|
+
const windowMs = options.windowMs || PEER_RECONNECT_WARN_WINDOW_MS;
|
|
72
|
+
const threshold = options.threshold || PEER_RECONNECT_WARN_THRESHOLD;
|
|
73
|
+
const warn = options.warn || console.warn;
|
|
74
|
+
const debug = options.debug || (() => {});
|
|
75
|
+
|
|
76
|
+
function stablePeerId(peer) {
|
|
77
|
+
return peer?.MatterhornStableId || peer?.id || "unknown";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function logOpen(label, id, peer) {
|
|
81
|
+
if (peer) {
|
|
82
|
+
peer.MatterhornStableId ||= id;
|
|
83
|
+
peer.MatterhornLastReconnectAt = 0;
|
|
84
|
+
}
|
|
85
|
+
if (loggedPeerOpenIds.has(id)) {
|
|
86
|
+
debug(`${label} reopened as ${id}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
loggedPeerOpenIds.add(id);
|
|
90
|
+
console.log(`${label} is live as ${id}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function warnOnRepeatedReconnect(peer, label, reason) {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
const peerId = stablePeerId(peer);
|
|
96
|
+
const key = `${label}:${peerId}`;
|
|
97
|
+
let stats = statsByKey.get(key);
|
|
98
|
+
if (!stats || now - stats.windowStartedAt > windowMs) {
|
|
99
|
+
stats = {
|
|
100
|
+
label,
|
|
101
|
+
peerId,
|
|
102
|
+
windowStartedAt: now,
|
|
103
|
+
windowAttempts: 0,
|
|
104
|
+
totalAttempts: stats?.totalAttempts || 0,
|
|
105
|
+
lastAttemptAt: stats?.lastAttemptAt || 0
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
stats.windowAttempts += 1;
|
|
110
|
+
stats.totalAttempts += 1;
|
|
111
|
+
stats.lastAttemptAt = now;
|
|
112
|
+
statsByKey.set(key, stats);
|
|
113
|
+
debug(`${label} reconnect attempt ${stats.totalAttempts} for ${peerId} after ${reason}`);
|
|
114
|
+
if (warnedLabels.has(label) || stats.windowAttempts < threshold) return;
|
|
115
|
+
|
|
116
|
+
warnedLabels.add(label);
|
|
117
|
+
warn(
|
|
118
|
+
`${label} PeerJS signaling is unstable (${stats.windowAttempts} reconnect attempts in ${Math.round(windowMs / 1000)}s). ` +
|
|
119
|
+
"This is signaling churn, not guest-room reconnects; further reconnects are debug-only. Set MATTERHORN_DEBUG=1 for per-event details."
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function summary() {
|
|
124
|
+
return Array.from(statsByKey.values()).map((stats) => ({
|
|
125
|
+
label: stats.label,
|
|
126
|
+
peerId: stats.peerId,
|
|
127
|
+
windowAttempts: stats.windowAttempts,
|
|
128
|
+
totalAttempts: stats.totalAttempts,
|
|
129
|
+
lastAttemptAt: stats.lastAttemptAt,
|
|
130
|
+
warned: warnedLabels.has(stats.label)
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
logOpen,
|
|
136
|
+
stablePeerId,
|
|
137
|
+
summary,
|
|
138
|
+
warn: warnOnRepeatedReconnect
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
createEncryptedRoomRuntime,
|
|
144
|
+
createEncryptedRoomRuntimeManager,
|
|
145
|
+
createRelayPluginRuntimeManager,
|
|
146
|
+
createPeerReconnectTracker,
|
|
147
|
+
isNewerRoomState,
|
|
148
|
+
isNewerStore,
|
|
149
|
+
jsonClone,
|
|
150
|
+
relayRoomStoreSnapshot,
|
|
151
|
+
waitForPeerOpen
|
|
152
|
+
};
|