@mh-gg/relay-core 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 +20 -0
- package/src/addresses.cjs +148 -0
- package/src/index.cjs +10 -0
- package/src/messages.cjs +264 -0
- package/src/negentropy.cjs +45 -0
- package/src/nips.cjs +79 -0
- package/src/policies.cjs +84 -0
- package/src/relay/kinds.cjs +56 -0
- package/src/relay/quotas.cjs +40 -0
- package/src/relay/retention.cjs +105 -0
- package/src/relay/stats.cjs +31 -0
- package/src/relay.cjs +495 -0
- package/src/relayInfo.cjs +20 -0
- package/src/validation.cjs +95 -0
- package/test/relay-core.test.cjs +821 -0
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/relay-core",
|
|
3
|
+
"version": "0.1.1-alpha.20260613T085325975Z",
|
|
4
|
+
"description": "Transport-independent Nostr relay core for matterhorn rooms.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.cjs"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"nostr-tools": "^2.23.5"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.12"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test test/*.test.cjs",
|
|
18
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-include=src/**/*.cjs test/*.test.cjs"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
|
|
3
|
+
const RELAY_ADDRESS_PREFIX = "peerjs:";
|
|
4
|
+
const RELAY_MESH_SUFFIX = "-relay";
|
|
5
|
+
const DEFAULT_SECURE_PORT = 443;
|
|
6
|
+
const DEFAULT_INSECURE_PORT = 80;
|
|
7
|
+
|
|
8
|
+
function normalizePeerId(peerId) {
|
|
9
|
+
const value = String(peerId || "").trim();
|
|
10
|
+
if (!value || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) return undefined;
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizePath(path) {
|
|
15
|
+
const value = String(path || "/").trim() || "/";
|
|
16
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeSignalingConfig(signaling) {
|
|
20
|
+
if (!signaling) return undefined;
|
|
21
|
+
if (typeof signaling === "string") {
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(signaling);
|
|
24
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:") return undefined;
|
|
25
|
+
if (url.search || url.hash || !url.hostname) return undefined;
|
|
26
|
+
const secure = url.protocol === "wss:";
|
|
27
|
+
return {
|
|
28
|
+
host: url.hostname,
|
|
29
|
+
port: Number(url.port || (secure ? DEFAULT_SECURE_PORT : DEFAULT_INSECURE_PORT)),
|
|
30
|
+
path: normalizePath(url.pathname),
|
|
31
|
+
secure
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (typeof signaling !== "object") return undefined;
|
|
38
|
+
const host = String(signaling.host || "").trim();
|
|
39
|
+
if (!host) return undefined;
|
|
40
|
+
const secure = signaling.secure !== false;
|
|
41
|
+
const port = Number(signaling.port || (secure ? DEFAULT_SECURE_PORT : DEFAULT_INSECURE_PORT));
|
|
42
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return undefined;
|
|
43
|
+
return {
|
|
44
|
+
host,
|
|
45
|
+
port,
|
|
46
|
+
path: normalizePath(signaling.path),
|
|
47
|
+
secure
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function signalingUrl(signaling) {
|
|
52
|
+
const config = normalizeSignalingConfig(signaling);
|
|
53
|
+
if (!config) return undefined;
|
|
54
|
+
return `${config.secure ? "wss" : "ws"}://${config.host}:${config.port}${config.path}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseRelayAddress(address, defaultSignaling) {
|
|
58
|
+
if (typeof address !== "string") return undefined;
|
|
59
|
+
const trimmed = address.trim();
|
|
60
|
+
if (!trimmed) return undefined;
|
|
61
|
+
const raw = trimmed.startsWith(RELAY_ADDRESS_PREFIX) ? trimmed.slice(RELAY_ADDRESS_PREFIX.length) : trimmed;
|
|
62
|
+
const separatorIndex = raw.indexOf("@");
|
|
63
|
+
const peerId = normalizePeerId(separatorIndex >= 0 ? raw.slice(0, separatorIndex) : raw);
|
|
64
|
+
if (!peerId) return undefined;
|
|
65
|
+
const endpoint = separatorIndex >= 0 ? raw.slice(separatorIndex + 1) : undefined;
|
|
66
|
+
const signaling = normalizeSignalingConfig(endpoint || defaultSignaling);
|
|
67
|
+
if (endpoint && !signaling) return undefined;
|
|
68
|
+
const endpointUrl = signalingUrl(signaling);
|
|
69
|
+
return {
|
|
70
|
+
peerId,
|
|
71
|
+
signaling,
|
|
72
|
+
signalingUrl: endpointUrl,
|
|
73
|
+
address: endpointUrl ? `${RELAY_ADDRESS_PREFIX}${peerId}@${endpointUrl}` : `${RELAY_ADDRESS_PREFIX}${peerId}`
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function relayAddressForPeerId(peerId, signaling) {
|
|
78
|
+
const value = normalizePeerId(peerId);
|
|
79
|
+
if (!value) return undefined;
|
|
80
|
+
const endpointUrl = signalingUrl(signaling);
|
|
81
|
+
return endpointUrl ? `${RELAY_ADDRESS_PREFIX}${value}@${endpointUrl}` : `${RELAY_ADDRESS_PREFIX}${value}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function relayMeshPeerIdForRoomPeerId(peerId) {
|
|
85
|
+
const value = normalizePeerId(peerId);
|
|
86
|
+
if (!value) return undefined;
|
|
87
|
+
return `${value.slice(0, 128 - RELAY_MESH_SUFFIX.length)}${RELAY_MESH_SUFFIX}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function relayMeshPeerIdFromAddressPeerId(peerId) {
|
|
91
|
+
const value = normalizePeerId(peerId);
|
|
92
|
+
if (!value) return undefined;
|
|
93
|
+
if (value.endsWith(RELAY_MESH_SUFFIX)) return value.slice(0, 128);
|
|
94
|
+
return `${value.slice(0, 128 - RELAY_MESH_SUFFIX.length)}${RELAY_MESH_SUFFIX}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function relayMeshAddressForPeerId(peerId, signaling) {
|
|
98
|
+
const meshPeerId = relayMeshPeerIdForRoomPeerId(peerId);
|
|
99
|
+
return meshPeerId ? relayAddressForPeerId(meshPeerId, signaling) : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function relayMeshAddressFromAddress(address, defaultSignaling) {
|
|
103
|
+
const parsed = parseRelayAddress(address, defaultSignaling);
|
|
104
|
+
const peerId = parsed?.peerId;
|
|
105
|
+
const meshPeerId = relayMeshPeerIdFromAddressPeerId(peerId);
|
|
106
|
+
return meshPeerId ? relayAddressForPeerId(meshPeerId, parsed?.signaling) : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function roomPeerAddressFromRelayAddress(address, defaultSignaling) {
|
|
110
|
+
const parsed = parseRelayAddress(address, defaultSignaling);
|
|
111
|
+
if (!parsed) return undefined;
|
|
112
|
+
const roomPeerId = parsed.peerId.endsWith(RELAY_MESH_SUFFIX)
|
|
113
|
+
? parsed.peerId.slice(0, -RELAY_MESH_SUFFIX.length)
|
|
114
|
+
: parsed.peerId;
|
|
115
|
+
return relayAddressForPeerId(roomPeerId, parsed.signaling);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function peerIdFromRelayAddress(address) {
|
|
119
|
+
return parseRelayAddress(address)?.peerId;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function peerJsOptionsFromAddress(address, fallbackSignaling) {
|
|
123
|
+
const parsed = parseRelayAddress(address, fallbackSignaling);
|
|
124
|
+
if (!parsed?.signaling) return undefined;
|
|
125
|
+
return { ...parsed.signaling };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function randomRelayPeerId(roomName = "relay") {
|
|
129
|
+
const suffix = crypto.randomBytes(8).toString("base64url").toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
130
|
+
return `matterhorn-${roomName}-${suffix}`.slice(0, 120);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
RELAY_ADDRESS_PREFIX,
|
|
135
|
+
RELAY_MESH_SUFFIX,
|
|
136
|
+
normalizeSignalingConfig,
|
|
137
|
+
parseRelayAddress,
|
|
138
|
+
peerIdFromRelayAddress,
|
|
139
|
+
peerJsOptionsFromAddress,
|
|
140
|
+
randomRelayPeerId,
|
|
141
|
+
relayAddressForPeerId,
|
|
142
|
+
relayMeshAddressFromAddress,
|
|
143
|
+
relayMeshAddressForPeerId,
|
|
144
|
+
relayMeshPeerIdFromAddressPeerId,
|
|
145
|
+
relayMeshPeerIdForRoomPeerId,
|
|
146
|
+
roomPeerAddressFromRelayAddress,
|
|
147
|
+
signalingUrl
|
|
148
|
+
};
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
...require("./addresses.cjs"),
|
|
3
|
+
...require("./messages.cjs"),
|
|
4
|
+
...require("./negentropy.cjs"),
|
|
5
|
+
...require("./nips.cjs"),
|
|
6
|
+
...require("./policies.cjs"),
|
|
7
|
+
...require("./relayInfo.cjs"),
|
|
8
|
+
...require("./relay.cjs"),
|
|
9
|
+
...require("./validation.cjs")
|
|
10
|
+
};
|
package/src/messages.cjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
const { isNegentropyMessage } = require("./negentropy.cjs");
|
|
2
|
+
|
|
3
|
+
const RELAY_CHUNK_BYTES = 10 * 1024;
|
|
4
|
+
const RELAY_CHUNK_BACKPRESSURE_HIGH = 1024 * 1024;
|
|
5
|
+
const RELAY_CHUNK_BACKPRESSURE_LOW = 512 * 1024;
|
|
6
|
+
const RELAY_CHUNK_MAX_AGE_MS = 60_000;
|
|
7
|
+
const RELAY_CHUNK_MAX_MESSAGES = 64;
|
|
8
|
+
const RELAY_CHUNK_MAX_IN_FLIGHT_BYTES = 4 * 1024 * 1024;
|
|
9
|
+
const RELAY_CHUNK_MAX_TOTAL_IN_FLIGHT_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
const RELAY_CHUNK_MAX_CHUNKS = 1024;
|
|
11
|
+
const RELAY_SEND_QUEUE_MAX_MESSAGES = 256;
|
|
12
|
+
const RELAY_SEND_QUEUE_MAX_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
const sendQueues = new WeakMap();
|
|
15
|
+
const sendQueueStats = new WeakMap();
|
|
16
|
+
let globalChunkBytes = 0;
|
|
17
|
+
let chunkSeq = 0;
|
|
18
|
+
|
|
19
|
+
function isNostrMessage(message) {
|
|
20
|
+
return Array.isArray(message) && (["EVENT", "REQ", "CLOSE", "IHAVE", "GRAFT"].includes(message[0]) || isNegentropyMessage(message));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function messageJsonByteLength(message) {
|
|
24
|
+
try {
|
|
25
|
+
return Buffer.byteLength(JSON.stringify(message), "utf8");
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function objectMessage(message) {
|
|
32
|
+
return message && typeof message === "object" && !Array.isArray(message) ? message : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function messageType(message) {
|
|
36
|
+
if (Array.isArray(message)) return typeof message[0] === "string" ? message[0] : "array";
|
|
37
|
+
return objectMessage(message)?.type || typeof message;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function chunkId(conn, message) {
|
|
41
|
+
chunkSeq += 1;
|
|
42
|
+
const peerId = conn?.MatterhornPeerId || conn?.peer || conn?.id || "conn";
|
|
43
|
+
return `${peerId}:${messageType(message)}:${Date.now()}:${chunkSeq}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dataChannel(conn) {
|
|
47
|
+
return conn?.dataChannel || conn?._dc;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function waitForBufferedAmountLow(channel) {
|
|
51
|
+
if (!channel || channel.bufferedAmount <= RELAY_CHUNK_BACKPRESSURE_HIGH) return Promise.resolve();
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let done = false;
|
|
54
|
+
const finish = () => {
|
|
55
|
+
if (done) return;
|
|
56
|
+
done = true;
|
|
57
|
+
if (typeof channel.removeEventListener === "function") {
|
|
58
|
+
channel.removeEventListener("bufferedamountlow", finish);
|
|
59
|
+
}
|
|
60
|
+
if (channel.onbufferedamountlow === finish) channel.onbufferedamountlow = null;
|
|
61
|
+
resolve();
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
channel.bufferedAmountLowThreshold = RELAY_CHUNK_BACKPRESSURE_LOW;
|
|
65
|
+
if (typeof channel.addEventListener === "function") channel.addEventListener("bufferedamountlow", finish, { once: true });
|
|
66
|
+
else channel.onbufferedamountlow = finish;
|
|
67
|
+
} catch {
|
|
68
|
+
setTimeout(finish, 10);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sendRawMessage(conn, message) {
|
|
74
|
+
try {
|
|
75
|
+
if (!conn || conn.open === false || typeof conn.send !== "function") return false;
|
|
76
|
+
conn.send(message);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
// Closed WebRTC data channels are expected during mobile sleep/reconnects.
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function sendChunkedMessage(conn, message, json) {
|
|
85
|
+
const id = chunkId(conn, message);
|
|
86
|
+
const details = objectMessage(message);
|
|
87
|
+
const bytes = Buffer.from(json, "utf8");
|
|
88
|
+
const totalChunks = Math.ceil(bytes.length / RELAY_CHUNK_BYTES);
|
|
89
|
+
for (let offset = 0, index = 0; offset < bytes.length; offset += RELAY_CHUNK_BYTES, index += 1) {
|
|
90
|
+
await waitForBufferedAmountLow(dataChannel(conn));
|
|
91
|
+
const chunk = bytes.subarray(offset, offset + RELAY_CHUNK_BYTES);
|
|
92
|
+
sendRawMessage(conn, {
|
|
93
|
+
type: "relay.chunk",
|
|
94
|
+
protocol: 1,
|
|
95
|
+
id,
|
|
96
|
+
messageType: messageType(message),
|
|
97
|
+
roomName: details?.roomName,
|
|
98
|
+
clientId: details?.clientId,
|
|
99
|
+
index,
|
|
100
|
+
totalChunks,
|
|
101
|
+
byteLength: bytes.length,
|
|
102
|
+
encoding: "base64",
|
|
103
|
+
data: chunk.toString("base64")
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
await waitForBufferedAmountLow(dataChannel(conn));
|
|
107
|
+
sendRawMessage(conn, {
|
|
108
|
+
type: "relay.chunk",
|
|
109
|
+
protocol: 1,
|
|
110
|
+
id,
|
|
111
|
+
messageType: messageType(message),
|
|
112
|
+
roomName: details?.roomName,
|
|
113
|
+
clientId: details?.clientId,
|
|
114
|
+
done: true,
|
|
115
|
+
totalChunks,
|
|
116
|
+
byteLength: bytes.length
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function sendMessageNow(conn, message, json) {
|
|
121
|
+
const byteLength = Buffer.byteLength(json, "utf8");
|
|
122
|
+
if (byteLength > RELAY_CHUNK_BYTES) {
|
|
123
|
+
await sendChunkedMessage(conn, message, json);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await waitForBufferedAmountLow(dataChannel(conn));
|
|
127
|
+
sendRawMessage(conn, message);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sendMessage(conn, message) {
|
|
131
|
+
let json;
|
|
132
|
+
try {
|
|
133
|
+
json = JSON.stringify(message);
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
if (typeof json !== "string") return undefined;
|
|
138
|
+
|
|
139
|
+
const channel = dataChannel(conn);
|
|
140
|
+
const previous = sendQueues.get(conn);
|
|
141
|
+
const byteLength = Buffer.byteLength(json, "utf8");
|
|
142
|
+
if (!previous && byteLength <= RELAY_CHUNK_BYTES && (!channel || channel.bufferedAmount <= RELAY_CHUNK_BACKPRESSURE_HIGH)) {
|
|
143
|
+
sendRawMessage(conn, message);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const maxQueuedMessages = Number.isInteger(conn?.maxQueuedMessages) && conn.maxQueuedMessages > 0 ? conn.maxQueuedMessages : RELAY_SEND_QUEUE_MAX_MESSAGES;
|
|
148
|
+
const maxQueuedBytes = Number.isInteger(conn?.maxQueuedBytes) && conn.maxQueuedBytes > 0 ? conn.maxQueuedBytes : RELAY_SEND_QUEUE_MAX_BYTES;
|
|
149
|
+
const stats = sendQueueStats.get(conn) || { messages: 0, bytes: 0 };
|
|
150
|
+
if (stats.messages + 1 > maxQueuedMessages || stats.bytes + byteLength > maxQueuedBytes) {
|
|
151
|
+
try { conn?.close?.(); } catch {}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
stats.messages += 1;
|
|
155
|
+
stats.bytes += byteLength;
|
|
156
|
+
sendQueueStats.set(conn, stats);
|
|
157
|
+
|
|
158
|
+
const queued = (previous || Promise.resolve())
|
|
159
|
+
.catch(() => undefined)
|
|
160
|
+
.then(() => sendMessageNow(conn, message, json))
|
|
161
|
+
.catch(() => undefined)
|
|
162
|
+
.finally(() => {
|
|
163
|
+
const current = sendQueueStats.get(conn);
|
|
164
|
+
if (!current) return;
|
|
165
|
+
current.messages = Math.max(0, current.messages - 1);
|
|
166
|
+
current.bytes = Math.max(0, current.bytes - byteLength);
|
|
167
|
+
if (current.messages === 0 && current.bytes === 0) sendQueueStats.delete(conn);
|
|
168
|
+
});
|
|
169
|
+
sendQueues.set(conn, queued);
|
|
170
|
+
return queued;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isRelayChunk(message) {
|
|
174
|
+
return objectMessage(message)?.type === "relay.chunk";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function createRelayChunkAssembler(options = {}) {
|
|
178
|
+
const chunks = new Map();
|
|
179
|
+
const maxBytes = Number.isInteger(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : undefined;
|
|
180
|
+
const maxAgeMs = Number.isInteger(options.maxAgeMs) && options.maxAgeMs > 0 ? options.maxAgeMs : RELAY_CHUNK_MAX_AGE_MS;
|
|
181
|
+
const maxMessages = Number.isInteger(options.maxMessages) && options.maxMessages > 0 ? options.maxMessages : RELAY_CHUNK_MAX_MESSAGES;
|
|
182
|
+
const maxInFlightBytes = Number.isInteger(options.maxInFlightBytes) && options.maxInFlightBytes > 0 ? options.maxInFlightBytes : RELAY_CHUNK_MAX_IN_FLIGHT_BYTES;
|
|
183
|
+
const maxGlobalInFlightBytes = Number.isInteger(options.maxGlobalInFlightBytes) && options.maxGlobalInFlightBytes > 0 ? options.maxGlobalInFlightBytes : RELAY_CHUNK_MAX_TOTAL_IN_FLIGHT_BYTES;
|
|
184
|
+
const maxChunks = Number.isInteger(options.maxChunks) && options.maxChunks > 0 ? options.maxChunks : RELAY_CHUNK_MAX_CHUNKS;
|
|
185
|
+
let inFlightBytes = 0;
|
|
186
|
+
|
|
187
|
+
function remove(id) {
|
|
188
|
+
const item = chunks.get(id);
|
|
189
|
+
if (!item) return;
|
|
190
|
+
inFlightBytes = Math.max(0, inFlightBytes - item.byteLength);
|
|
191
|
+
globalChunkBytes = Math.max(0, globalChunkBytes - item.byteLength);
|
|
192
|
+
chunks.delete(id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function prune(now = Date.now()) {
|
|
196
|
+
for (const [id, item] of chunks) {
|
|
197
|
+
if (now - item.createdAt > maxAgeMs) remove(id);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function accept(message) {
|
|
202
|
+
if (!isRelayChunk(message)) return message;
|
|
203
|
+
if (typeof message.id !== "string" || !message.id) return undefined;
|
|
204
|
+
prune();
|
|
205
|
+
|
|
206
|
+
const byteLength = Number(message.byteLength);
|
|
207
|
+
if (!Number.isInteger(byteLength) || byteLength < 0 || (maxBytes && byteLength > maxBytes)) {
|
|
208
|
+
remove(message.id);
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const totalChunks = Number(message.totalChunks);
|
|
213
|
+
if (!Number.isInteger(totalChunks) || totalChunks <= 0 || totalChunks > maxChunks) return undefined;
|
|
214
|
+
|
|
215
|
+
let item = chunks.get(message.id);
|
|
216
|
+
if (!item) {
|
|
217
|
+
if (chunks.size >= maxMessages || inFlightBytes + byteLength > maxInFlightBytes || globalChunkBytes + byteLength > maxGlobalInFlightBytes) return undefined;
|
|
218
|
+
item = { chunks: [], byteLength, createdAt: Date.now(), receivedBytes: 0, totalChunks };
|
|
219
|
+
chunks.set(message.id, item);
|
|
220
|
+
inFlightBytes += byteLength;
|
|
221
|
+
globalChunkBytes += byteLength;
|
|
222
|
+
}
|
|
223
|
+
if (item.byteLength !== byteLength || item.totalChunks !== totalChunks) {
|
|
224
|
+
remove(message.id);
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!message.done) {
|
|
229
|
+
if (!Number.isInteger(message.index) || message.index < 0 || message.index >= totalChunks || message.encoding !== "base64" || typeof message.data !== "string") return undefined;
|
|
230
|
+
const chunk = Buffer.from(message.data, "base64");
|
|
231
|
+
if (!item.chunks[message.index]) item.receivedBytes += chunk.length;
|
|
232
|
+
if (item.receivedBytes > byteLength || (maxBytes && item.receivedBytes > maxBytes)) {
|
|
233
|
+
remove(message.id);
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
item.chunks[message.index] = chunk;
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
remove(message.id);
|
|
241
|
+
if (item.chunks.length !== totalChunks || item.chunks.some((chunk) => !Buffer.isBuffer(chunk))) return undefined;
|
|
242
|
+
const buffer = Buffer.concat(item.chunks);
|
|
243
|
+
if (buffer.length !== byteLength) return undefined;
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
246
|
+
} catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
accept,
|
|
253
|
+
prune
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
RELAY_CHUNK_BYTES,
|
|
259
|
+
createRelayChunkAssembler,
|
|
260
|
+
isRelayChunk,
|
|
261
|
+
isNostrMessage,
|
|
262
|
+
messageJsonByteLength,
|
|
263
|
+
sendMessage
|
|
264
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const { nip77 } = require("nostr-tools");
|
|
2
|
+
const { Negentropy, NegentropyStorageVector } = nip77;
|
|
3
|
+
|
|
4
|
+
const NIP77_DEFAULT_FILTER = Object.freeze({});
|
|
5
|
+
const NIP77_MAX_NEED_IDS = 200;
|
|
6
|
+
|
|
7
|
+
function negentropyStorage(events, filter) {
|
|
8
|
+
const storage = new NegentropyStorageVector();
|
|
9
|
+
for (const event of events) {
|
|
10
|
+
if (filter && !filter(event)) continue;
|
|
11
|
+
storage.insert(event.created_at, event.id);
|
|
12
|
+
}
|
|
13
|
+
storage.seal();
|
|
14
|
+
return storage;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createNegentropySession(events, filter) {
|
|
18
|
+
return {
|
|
19
|
+
negentropy: new Negentropy(negentropyStorage(events, filter)),
|
|
20
|
+
haveIds: [],
|
|
21
|
+
needIds: []
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tagValue(tags, name) {
|
|
26
|
+
const tag = Array.isArray(tags) ? tags.find((item) => Array.isArray(item) && item[0] === name) : undefined;
|
|
27
|
+
return tag ? tag[1] : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createStreamNegentropySession(stream, events) {
|
|
31
|
+
return createNegentropySession(events, (event) => tagValue(event?.tags, "stream") === stream);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isNegentropyMessage(message) {
|
|
35
|
+
return Array.isArray(message) && ["NEG-OPEN", "NEG-MSG", "NEG-CLOSE", "NEG-ERR"].includes(message[0]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
NIP77_DEFAULT_FILTER,
|
|
40
|
+
NIP77_MAX_NEED_IDS,
|
|
41
|
+
createNegentropySession,
|
|
42
|
+
createStreamNegentropySession,
|
|
43
|
+
isNegentropyMessage,
|
|
44
|
+
negentropyStorage
|
|
45
|
+
};
|
package/src/nips.cjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const SUBPATH_NIPS = Object.freeze([
|
|
2
|
+
"nip04",
|
|
3
|
+
"nip05",
|
|
4
|
+
"nip06",
|
|
5
|
+
"nip10",
|
|
6
|
+
"nip11",
|
|
7
|
+
"nip13",
|
|
8
|
+
"nip17",
|
|
9
|
+
"nip18",
|
|
10
|
+
"nip19",
|
|
11
|
+
"nip21",
|
|
12
|
+
"nip22",
|
|
13
|
+
"nip25",
|
|
14
|
+
"nip27",
|
|
15
|
+
"nip28",
|
|
16
|
+
"nip29",
|
|
17
|
+
"nip30",
|
|
18
|
+
"nip39",
|
|
19
|
+
"nip42",
|
|
20
|
+
"nip44",
|
|
21
|
+
"nip46",
|
|
22
|
+
"nip49",
|
|
23
|
+
"nip54",
|
|
24
|
+
"nip57",
|
|
25
|
+
"nip58",
|
|
26
|
+
"nip59",
|
|
27
|
+
"nip75",
|
|
28
|
+
"nip94",
|
|
29
|
+
"nip98",
|
|
30
|
+
"nip99",
|
|
31
|
+
"nipb7"
|
|
32
|
+
]);
|
|
33
|
+
const INDEX_NIPS = Object.freeze(["nip47", "nip77"]);
|
|
34
|
+
const KIND_NIPS = Object.freeze(["nip09", "nip16", "nip52", "nip65"]);
|
|
35
|
+
const SUPPORTED_NOSTR_TOOLS_NIPS = Object.freeze(["nip01", ...KIND_NIPS, ...SUBPATH_NIPS, ...INDEX_NIPS].sort());
|
|
36
|
+
|
|
37
|
+
function normalizeNipName(value) {
|
|
38
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasNipSupport(name) {
|
|
42
|
+
return SUPPORTED_NOSTR_TOOLS_NIPS.includes(normalizeNipName(name));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function unsupportedNips(requiredNips = []) {
|
|
46
|
+
if (!Array.isArray(requiredNips)) return [];
|
|
47
|
+
const unsupported = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const required of requiredNips) {
|
|
50
|
+
const name = normalizeNipName(required);
|
|
51
|
+
if (!name || seen.has(name)) continue;
|
|
52
|
+
seen.add(name);
|
|
53
|
+
if (!hasNipSupport(name)) unsupported.push(name);
|
|
54
|
+
}
|
|
55
|
+
return unsupported;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadNip(name) {
|
|
59
|
+
const normalized = normalizeNipName(name);
|
|
60
|
+
if (normalized === "nip01") {
|
|
61
|
+
return {
|
|
62
|
+
...require("nostr-tools/pure"),
|
|
63
|
+
...require("nostr-tools/filter"),
|
|
64
|
+
kinds: require("nostr-tools/kinds")
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (SUBPATH_NIPS.includes(normalized)) return require(`nostr-tools/${normalized}`);
|
|
68
|
+
if (INDEX_NIPS.includes(normalized)) return require("nostr-tools")[normalized];
|
|
69
|
+
if (KIND_NIPS.includes(normalized)) return require("nostr-tools/kinds");
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
SUPPORTED_NOSTR_TOOLS_NIPS,
|
|
75
|
+
hasNipSupport,
|
|
76
|
+
loadNip,
|
|
77
|
+
normalizeNipName,
|
|
78
|
+
unsupportedNips
|
|
79
|
+
};
|
package/src/policies.cjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const { normalizeNipName, unsupportedNips } = require("./nips.cjs");
|
|
2
|
+
|
|
3
|
+
function normalizeRequiredNips(requiredNips = []) {
|
|
4
|
+
if (!Array.isArray(requiredNips)) return [];
|
|
5
|
+
const normalized = [];
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
for (const required of requiredNips) {
|
|
8
|
+
const name = normalizeNipName(required);
|
|
9
|
+
if (!name || seen.has(name)) continue;
|
|
10
|
+
seen.add(name);
|
|
11
|
+
normalized.push(name);
|
|
12
|
+
}
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ok() {
|
|
17
|
+
return { ok: true };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function reject(code, message) {
|
|
21
|
+
return { ok: false, code, message };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createFeaturePolicy(options = {}) {
|
|
25
|
+
const name = typeof options.name === "string" && options.name.trim() ? options.name.trim() : "nostr-feature";
|
|
26
|
+
const requiredNips = Object.freeze(normalizeRequiredNips(options.requiredNips));
|
|
27
|
+
const unsupported = unsupportedNips(requiredNips);
|
|
28
|
+
const validate = typeof options.validate === "function" ? options.validate : ok;
|
|
29
|
+
|
|
30
|
+
function featurePolicy(nostrEvent, context = {}) {
|
|
31
|
+
if (unsupported.length > 0) {
|
|
32
|
+
return reject("unsupported-nips", `${name} requires unsupported Nostr NIPs: ${unsupported.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
const result = validate(nostrEvent, context);
|
|
35
|
+
if (!result || result.ok !== false) return ok();
|
|
36
|
+
return reject(result.code || "policy-rejected", result.message || `${name} rejected this Nostr event`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
featurePolicy.feature = Object.freeze({ name, requiredNips });
|
|
40
|
+
return featurePolicy;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function eventPoliciesFromOptions(...sources) {
|
|
44
|
+
const policies = [];
|
|
45
|
+
for (const source of sources) {
|
|
46
|
+
if (!source || typeof source !== "object") continue;
|
|
47
|
+
if (typeof source.eventPolicy === "function") policies.push(source.eventPolicy);
|
|
48
|
+
if (Array.isArray(source.eventPolicies)) {
|
|
49
|
+
for (const policy of source.eventPolicies) {
|
|
50
|
+
if (typeof policy === "function") policies.push(policy);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return policies;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function featurePolicyUnsupportedNips(policy) {
|
|
58
|
+
const requiredNips = normalizeRequiredNips(policy?.feature?.requiredNips);
|
|
59
|
+
return unsupportedNips(requiredNips);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateEventPolicies(nostrEvent, context = {}, policies = []) {
|
|
63
|
+
for (const policy of policies) {
|
|
64
|
+
const unsupported = featurePolicyUnsupportedNips(policy);
|
|
65
|
+
if (unsupported.length > 0) {
|
|
66
|
+
const featureName = policy.feature?.name || "nostr-feature";
|
|
67
|
+
return reject("unsupported-nips", `${featureName} requires unsupported Nostr NIPs: ${unsupported.join(", ")}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = policy(nostrEvent, context);
|
|
71
|
+
if (result?.ok === false) {
|
|
72
|
+
return reject(result.code || "policy-rejected", result.message || "Nostr event rejected by feature policy");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return ok();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
createFeaturePolicy,
|
|
80
|
+
eventPoliciesFromOptions,
|
|
81
|
+
featurePolicyUnsupportedNips,
|
|
82
|
+
normalizeRequiredNips,
|
|
83
|
+
validateEventPolicies
|
|
84
|
+
};
|