wireweave 0.3.44 → 0.3.46
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 +6 -2
- package/src/bans.js +125 -4
- package/src/data.js +96 -9
- package/src/dm.js +39 -27
- package/src/dtag.js +1 -1
- package/src/ephemeral-relay.js +111 -0
- package/src/frame.js +202 -0
- package/src/index.js +2 -0
- package/src/message.js +129 -2
- package/src/profile.js +135 -0
- package/src/relay-pool.js +99 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wireweave",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.46",
|
|
4
4
|
"description": "nostr + webrtc voice + data SDK. networking layer for 247420 projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
"./wireweave": "./src/wireweave.js",
|
|
11
11
|
"./relay-pool": "./src/relay-pool.js",
|
|
12
12
|
"./auth": "./src/auth.js",
|
|
13
|
+
"./profile": "./src/profile.js",
|
|
13
14
|
"./fsm": "./src/fsm.js",
|
|
14
15
|
"./voice": "./src/voice.js",
|
|
15
16
|
"./data": "./src/data.js",
|
|
17
|
+
"./frame": "./src/frame.js",
|
|
16
18
|
"./chat": "./src/chat.js",
|
|
17
19
|
"./channels": "./src/channels.js",
|
|
18
20
|
"./servers": "./src/servers.js",
|
|
@@ -22,7 +24,9 @@
|
|
|
22
24
|
"./settings": "./src/settings.js",
|
|
23
25
|
"./media": "./src/media.js",
|
|
24
26
|
"./pages": "./src/pages.js",
|
|
25
|
-
"./debug": "./src/debug.js"
|
|
27
|
+
"./debug": "./src/debug.js",
|
|
28
|
+
"./dm": "./src/dm.js",
|
|
29
|
+
"./ephemeral-relay": "./src/ephemeral-relay.js"
|
|
26
30
|
},
|
|
27
31
|
"files": [
|
|
28
32
|
"src",
|
package/src/bans.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { dtag, parseDtag } from './dtag.js';
|
|
2
2
|
|
|
3
|
+
const AUDIT_LOG_MAX = 200;
|
|
4
|
+
|
|
3
5
|
export class Bans extends EventTarget {
|
|
4
6
|
constructor({ relayPool, auth = null, roles = null }) {
|
|
5
7
|
super();
|
|
@@ -7,10 +9,17 @@ export class Bans extends EventTarget {
|
|
|
7
9
|
this.pool = relayPool; this.auth = auth; this.roles = roles;
|
|
8
10
|
this.store = new Map();
|
|
9
11
|
this.subs = new Map();
|
|
12
|
+
// In-memory audit log of moderation actions seen via subscribe(), most
|
|
13
|
+
// recent first, capped at AUDIT_LOG_MAX. Rebuilt purely from the same
|
|
14
|
+
// real relay-published events the ban/timeout/kick/unban/mute state
|
|
15
|
+
// already derives from — no separate write path, so the log can never
|
|
16
|
+
// drift from the actual enforced state.
|
|
17
|
+
this.auditLog = [];
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
isBanned(serverId, pubkey) { return !!(this.store.get(serverId)?.banned || []).includes(pubkey); }
|
|
13
21
|
isKicked(serverId, pubkey) { return !!(this.store.get(serverId)?.kicked || []).includes(pubkey); }
|
|
22
|
+
isMuted(serverId, channelId, pubkey) { return !!(this.store.get(serverId)?.muted?.[channelId] || []).includes(pubkey); }
|
|
14
23
|
|
|
15
24
|
isTimedOut(serverId, pubkey) {
|
|
16
25
|
const t = this.store.get(serverId)?.timeouts?.[pubkey];
|
|
@@ -29,6 +38,23 @@ export class Bans extends EventTarget {
|
|
|
29
38
|
this.pool.publish(signed);
|
|
30
39
|
}
|
|
31
40
|
|
|
41
|
+
// Reverses a prior ban. A separate 'unban' d-tag namespace (not a delete
|
|
42
|
+
// of the 'ban' event — nostr relays aren't guaranteed to honor NIP-09
|
|
43
|
+
// deletion requests, and a replaceable/addressable ban event has no
|
|
44
|
+
// built-in revocation) whose presence with a newer timestamp than the
|
|
45
|
+
// matching ban event means "no longer banned" — see _applyEvent's ordering.
|
|
46
|
+
async unban(serverId, pubkey) {
|
|
47
|
+
if (!this.auth?.isLoggedIn()) throw new Error('Not logged in');
|
|
48
|
+
if (this.roles && !this.roles.isAdmin(serverId)) throw new Error('Insufficient permissions');
|
|
49
|
+
const dTag = dtag('unban', serverId, pubkey);
|
|
50
|
+
const signed = await this.auth.sign({
|
|
51
|
+
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
52
|
+
tags: [['d', dTag], ['server', serverId]],
|
|
53
|
+
content: JSON.stringify({ action: 'unban', pubkey, timestamp: Math.floor(Date.now() / 1000) })
|
|
54
|
+
});
|
|
55
|
+
this.pool.publish(signed);
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
async timeout(serverId, pubkey, minutes) {
|
|
33
59
|
if (!this.auth?.isLoggedIn()) throw new Error('Not logged in');
|
|
34
60
|
if (this.roles && !this.roles.isAdmin(serverId)) throw new Error('Insufficient permissions');
|
|
@@ -42,6 +68,23 @@ export class Bans extends EventTarget {
|
|
|
42
68
|
this.pool.publish(signed);
|
|
43
69
|
}
|
|
44
70
|
|
|
71
|
+
// Explicit early-clear of an active timeout — publishing a new timeout
|
|
72
|
+
// event with expiry already in the past is the wire-level mechanism
|
|
73
|
+
// (mirrors how the existing subscribe() handler already deletes an
|
|
74
|
+
// expired timeout from the local store), exposed as its own method so a
|
|
75
|
+
// caller doesn't have to know that trick.
|
|
76
|
+
async clearTimeout(serverId, pubkey) {
|
|
77
|
+
if (!this.auth?.isLoggedIn()) throw new Error('Not logged in');
|
|
78
|
+
if (this.roles && !this.roles.isAdmin(serverId)) throw new Error('Insufficient permissions');
|
|
79
|
+
const dTag = dtag('timeout', serverId, pubkey);
|
|
80
|
+
const signed = await this.auth.sign({
|
|
81
|
+
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
82
|
+
tags: [['d', dTag], ['server', serverId]],
|
|
83
|
+
content: JSON.stringify({ action: 'timeout', pubkey, expiry: Math.floor(Date.now() / 1000) - 1 })
|
|
84
|
+
});
|
|
85
|
+
this.pool.publish(signed);
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
async kickFromVoice(pubkey) {
|
|
46
89
|
if (!this.auth?.isLoggedIn()) throw new Error('Not logged in');
|
|
47
90
|
const signed = await this.auth.sign({
|
|
@@ -51,6 +94,46 @@ export class Bans extends EventTarget {
|
|
|
51
94
|
this.pool.publish(signed);
|
|
52
95
|
}
|
|
53
96
|
|
|
97
|
+
// Channel-level mute (distinct from a server-wide ban/timeout): silences
|
|
98
|
+
// one pubkey in one channel only, e.g. for a channel-specific moderator
|
|
99
|
+
// without server-wide admin rights over the whole server's ban list.
|
|
100
|
+
async mute(serverId, channelId, pubkey) {
|
|
101
|
+
if (!this.auth?.isLoggedIn()) throw new Error('Not logged in');
|
|
102
|
+
if (this.roles && !this.roles.isMod(serverId)) throw new Error('Insufficient permissions');
|
|
103
|
+
const dTag = dtag('mute', serverId, channelId, pubkey);
|
|
104
|
+
const signed = await this.auth.sign({
|
|
105
|
+
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
106
|
+
tags: [['d', dTag], ['server', serverId], ['channel', channelId]],
|
|
107
|
+
content: JSON.stringify({ action: 'mute', pubkey, channelId, timestamp: Math.floor(Date.now() / 1000) })
|
|
108
|
+
});
|
|
109
|
+
this.pool.publish(signed);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async unmute(serverId, channelId, pubkey) {
|
|
113
|
+
if (!this.auth?.isLoggedIn()) throw new Error('Not logged in');
|
|
114
|
+
if (this.roles && !this.roles.isMod(serverId)) throw new Error('Insufficient permissions');
|
|
115
|
+
const dTag = dtag('mute', serverId, channelId, pubkey);
|
|
116
|
+
const signed = await this.auth.sign({
|
|
117
|
+
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
118
|
+
tags: [['d', dTag], ['server', serverId], ['channel', channelId]],
|
|
119
|
+
content: JSON.stringify({ action: 'unmute', pubkey, channelId, timestamp: Math.floor(Date.now() / 1000) })
|
|
120
|
+
});
|
|
121
|
+
this.pool.publish(signed);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Most-recent-first audit trail of every moderation action seen via
|
|
125
|
+
// subscribe() for this serverId (or all servers if serverId is omitted),
|
|
126
|
+
// capped at AUDIT_LOG_MAX entries.
|
|
127
|
+
getAuditLog(serverId = null) {
|
|
128
|
+
return serverId ? this.auditLog.filter((e) => e.serverId === serverId) : this.auditLog.slice();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_recordAudit(entry) {
|
|
132
|
+
this.auditLog.unshift(entry);
|
|
133
|
+
if (this.auditLog.length > AUDIT_LOG_MAX) this.auditLog.length = AUDIT_LOG_MAX;
|
|
134
|
+
this._emit('audit', { entry });
|
|
135
|
+
}
|
|
136
|
+
|
|
54
137
|
subscribe(serverId) {
|
|
55
138
|
if (this.subs.has(serverId)) return;
|
|
56
139
|
if (!serverId) return;
|
|
@@ -66,20 +149,58 @@ export class Bans extends EventTarget {
|
|
|
66
149
|
const dTag = event.tags.find(t => t[0] === 'd');
|
|
67
150
|
if (!dTag?.[1]) return;
|
|
68
151
|
const parsed = parseDtag(dTag[1]);
|
|
69
|
-
if (!parsed || !['ban', 'timeout', 'kick'].includes(parsed.ns)) return;
|
|
152
|
+
if (!parsed || !['ban', 'unban', 'timeout', 'kick', 'mute'].includes(parsed.ns)) return;
|
|
70
153
|
const pubkey = parsed.parts[parsed.parts.length - 1];
|
|
71
|
-
const data = this.store.get(serverId) || { banned: [], timeouts: {}, kicked: [] };
|
|
72
|
-
|
|
73
|
-
|
|
154
|
+
const data = this.store.get(serverId) || { banned: [], timeouts: {}, kicked: [], muted: {}, _banTs: {} };
|
|
155
|
+
data.muted = data.muted || {};
|
|
156
|
+
data._banTs = data._banTs || {};
|
|
157
|
+
// A relay-delivered event always carries created_at; default to
|
|
158
|
+
// "now" only for a malformed/missing value so a legitimate action
|
|
159
|
+
// is never silently dropped by an always-false comparison against
|
|
160
|
+
// undefined (0 <= undefined is false in JS).
|
|
161
|
+
const eventTs = Number.isFinite(event.created_at) ? event.created_at : Math.floor(Date.now() / 1000);
|
|
162
|
+
|
|
163
|
+
// ban/unban share one addressable slot per pubkey (different
|
|
164
|
+
// d-tag namespaces, so a relay won't collapse them as the same
|
|
165
|
+
// replaceable event) — track the newest-seen timestamp per pubkey
|
|
166
|
+
// so an out-of-order-delivered older event never undoes a newer
|
|
167
|
+
// decision, same "latest wins" discipline roles.js/settings.js
|
|
168
|
+
// already apply to their own single-namespace replaceable state.
|
|
169
|
+
if (parsed.ns === 'ban' && pubkey) {
|
|
170
|
+
if ((data._banTs[pubkey] || 0) <= eventTs) {
|
|
171
|
+
data._banTs[pubkey] = eventTs;
|
|
172
|
+
if (!data.banned.includes(pubkey)) data.banned.push(pubkey);
|
|
173
|
+
}
|
|
174
|
+
} else if (parsed.ns === 'unban' && pubkey) {
|
|
175
|
+
if ((data._banTs[pubkey] || 0) <= eventTs) {
|
|
176
|
+
data._banTs[pubkey] = eventTs;
|
|
177
|
+
data.banned = data.banned.filter((p) => p !== pubkey);
|
|
178
|
+
}
|
|
179
|
+
} else if (parsed.ns === 'kick' && pubkey) {
|
|
74
180
|
data.kicked = data.kicked || [];
|
|
75
181
|
if (!data.kicked.includes(pubkey)) data.kicked.push(pubkey);
|
|
76
182
|
} else if (parsed.ns === 'timeout' && pubkey) {
|
|
77
183
|
const body = JSON.parse(event.content);
|
|
78
184
|
if (body.expiry > Math.floor(Date.now() / 1000)) (data.timeouts = data.timeouts || {})[pubkey] = { expiry: body.expiry };
|
|
79
185
|
else if (data.timeouts?.[pubkey]) delete data.timeouts[pubkey];
|
|
186
|
+
} else if (parsed.ns === 'mute' && pubkey) {
|
|
187
|
+
const body = JSON.parse(event.content);
|
|
188
|
+
// d-tag shape is 'mute:<serverId>:<channelId>:<pubkey>' (see
|
|
189
|
+
// mute()/unmute() above) -> parts = [serverId, channelId,
|
|
190
|
+
// pubkey] after parseDtag strips the namespace; body.channelId
|
|
191
|
+
// is the primary source (always present, set by mute()/unmute())
|
|
192
|
+
// with the d-tag position as a defensive fallback.
|
|
193
|
+
const channelId = body.channelId ?? parsed.parts[1];
|
|
194
|
+
data.muted[channelId] = data.muted[channelId] || [];
|
|
195
|
+
if (body.action === 'unmute') data.muted[channelId] = data.muted[channelId].filter((p) => p !== pubkey);
|
|
196
|
+
else if (!data.muted[channelId].includes(pubkey)) data.muted[channelId].push(pubkey);
|
|
80
197
|
}
|
|
81
198
|
this.store.set(serverId, data);
|
|
82
199
|
this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, data } }));
|
|
200
|
+
|
|
201
|
+
let body = null;
|
|
202
|
+
try { body = JSON.parse(event.content); } catch {}
|
|
203
|
+
this._recordAudit({ serverId, ns: parsed.ns, pubkey, actor: event.pubkey, action: body?.action || parsed.ns, at: eventTs, eventId: event.id });
|
|
83
204
|
} catch {}
|
|
84
205
|
});
|
|
85
206
|
}
|
package/src/data.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fragment, Reassembler, MTU_DEFAULT } from './frame.js';
|
|
2
|
+
|
|
1
3
|
// STUN handles same-LAN / non-symmetric-NAT cases; TURN is required when both
|
|
2
4
|
// peers sit behind symmetric or restricted-cone NAT (typical home routers,
|
|
3
5
|
// most carrier-grade NAT, corporate networks). UDP, TCP, and TLS TURN variants
|
|
@@ -51,6 +53,15 @@ const PRESENCE_EXPIRY = 300000;
|
|
|
51
53
|
const HEARTBEAT = 30000;
|
|
52
54
|
const DISCONNECT_GRACE = 8000;
|
|
53
55
|
const DC_LABEL = 'wireweave-data';
|
|
56
|
+
// Second, parallel data channel per peer for large binary/unreliable
|
|
57
|
+
// traffic (game snapshots etc) — {ordered:false, maxRetransmits:0} is the
|
|
58
|
+
// real WebRTC config for "send it once, don't retransmit, don't block on
|
|
59
|
+
// order" (UDP-like), distinct from DC_LABEL's default reliable/ordered
|
|
60
|
+
// channel above. Large payloads sent on it are auto-fragmented per
|
|
61
|
+
// frame.js's MTU-aware framing since a real RTCDataChannel message has a
|
|
62
|
+
// practical ~16KB limit before cross-browser fragmentation quirks.
|
|
63
|
+
const DC_LABEL_UNRELIABLE = 'wireweave-data-unreliable';
|
|
64
|
+
const UNRELIABLE_DC_OPTIONS = { ordered: false, maxRetransmits: 0 };
|
|
54
65
|
|
|
55
66
|
const deriveRoomId = async (namespace, room) => {
|
|
56
67
|
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((namespace || 'default') + ':data:' + room));
|
|
@@ -67,7 +78,7 @@ const deriveRoomId = async (namespace, room) => {
|
|
|
67
78
|
const defaultCreatePeerConnection = (config) => new RTCPeerConnection(config);
|
|
68
79
|
|
|
69
80
|
export class DataSession extends EventTarget {
|
|
70
|
-
constructor({ fsm, xstate, relayPool, auth, namespace = '', dataChannelOptions = { ordered: true }, iceServers = null, createPeerConnection = defaultCreatePeerConnection }) {
|
|
81
|
+
constructor({ fsm, xstate, relayPool, auth, namespace = '', dataChannelOptions = { ordered: true }, iceServers = null, createPeerConnection = defaultCreatePeerConnection, mtu = MTU_DEFAULT, fragmentStaleMs = 10000 }) {
|
|
71
82
|
super();
|
|
72
83
|
if (!fsm || !xstate || !relayPool || !auth) throw new Error('DataSession: missing deps');
|
|
73
84
|
this.fsm = fsm; this.xstate = xstate; this.pool = relayPool; this.auth = auth;
|
|
@@ -81,6 +92,13 @@ export class DataSession extends EventTarget {
|
|
|
81
92
|
this.heartbeat = null; this.joinTs = 0;
|
|
82
93
|
this.retrySchedule = {};
|
|
83
94
|
this.displayName = '';
|
|
95
|
+
// MTU-aware unreliable channel: real WebRTC datachannel messages have a
|
|
96
|
+
// practical ~16KB limit before cross-browser fragmentation quirks —
|
|
97
|
+
// mtu is configurable (e.g. larger for controlled/native peers such as
|
|
98
|
+
// node-datachannel) but defaults to the safe browser-wide value.
|
|
99
|
+
this.mtu = mtu;
|
|
100
|
+
this.fragmentStaleMs = fragmentStaleMs;
|
|
101
|
+
this._nextMessageId = 0;
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
_initActor() {
|
|
@@ -151,6 +169,48 @@ export class DataSession extends EventTarget {
|
|
|
151
169
|
return n;
|
|
152
170
|
}
|
|
153
171
|
|
|
172
|
+
// Sends a large binary payload over the unreliable/unordered channel,
|
|
173
|
+
// auto-fragmenting per this.mtu (frame.js). Each fragment is sent as its
|
|
174
|
+
// own dc.send() call — real WebRTC {ordered:false, maxRetransmits:0}
|
|
175
|
+
// datachannel semantics mean any individual fragment can be dropped;
|
|
176
|
+
// the receiver's Reassembler tolerates that (see _wireDataChannel above).
|
|
177
|
+
// Returns false without sending anything if the unreliable channel isn't
|
|
178
|
+
// open, or if fragmentation itself throws (e.g. payload exceeds the
|
|
179
|
+
// wire-format's fragment-count ceiling — see frame.js maxPayloadBytes).
|
|
180
|
+
sendUnreliable(peerPubkey, payload) {
|
|
181
|
+
const peer = this.peers.get(peerPubkey);
|
|
182
|
+
if (!peer?.dcUnreliable || peer.dcUnreliable.readyState !== 'open') return false;
|
|
183
|
+
let frames;
|
|
184
|
+
try { frames = fragment(payload, { messageId: this._nextMessageIdFor(), mtu: this.mtu }); }
|
|
185
|
+
catch (e) { this._emit('error', { message: 'sendUnreliable fragment failed: ' + e.message }); return false; }
|
|
186
|
+
try { for (const f of frames) peer.dcUnreliable.send(f); return true; }
|
|
187
|
+
catch { return false; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
broadcastUnreliable(payload) {
|
|
191
|
+
let frames;
|
|
192
|
+
try { frames = fragment(payload, { messageId: this._nextMessageIdFor(), mtu: this.mtu }); }
|
|
193
|
+
catch (e) { this._emit('error', { message: 'broadcastUnreliable fragment failed: ' + e.message }); return 0; }
|
|
194
|
+
let n = 0;
|
|
195
|
+
for (const [, peer] of this.peers) {
|
|
196
|
+
if (peer.dcUnreliable?.readyState === 'open') {
|
|
197
|
+
try { for (const f of frames) peer.dcUnreliable.send(f); n++; } catch {}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return n;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Per-session messageId counter, uint16 wraparound (see frame.js header
|
|
204
|
+
// format + the mtu-messageid-wraparound PRD note: collisions are scoped
|
|
205
|
+
// to the same peer's own overlapping in-flight traffic within staleMs,
|
|
206
|
+
// an accepted small-probability risk matching typical UDP-sequence-number
|
|
207
|
+
// designs rather than something solved perfectly here).
|
|
208
|
+
_nextMessageIdFor() {
|
|
209
|
+
const id = this._nextMessageId;
|
|
210
|
+
this._nextMessageId = (this._nextMessageId + 1) & 0xffff;
|
|
211
|
+
return id;
|
|
212
|
+
}
|
|
213
|
+
|
|
154
214
|
getParticipants() { return Array.from(this.participants.values()); }
|
|
155
215
|
getPeers() { return Array.from(this.peers.keys()); }
|
|
156
216
|
|
|
@@ -201,7 +261,7 @@ export class DataSession extends EventTarget {
|
|
|
201
261
|
const fsmActor = this.xstate.createActor(this.fsm.peerMachine);
|
|
202
262
|
fsmActor.subscribe((snap) => { const p = this.peers.get(peerPubkey); if (p) p.state = snap.value; });
|
|
203
263
|
fsmActor.start();
|
|
204
|
-
const peer = { pc: null, dc: null, pendingCandidates: [], bufferedCandidates: [], iceTimer: null, disconnectTimer: null, failCount: 0, state: 'new', fsm: fsmActor, remoteDescSet: false };
|
|
264
|
+
const peer = { pc: null, dc: null, dcUnreliable: null, reassembler: new Reassembler({ staleMs: this.fragmentStaleMs }), pendingCandidates: [], bufferedCandidates: [], iceTimer: null, disconnectTimer: null, failCount: 0, state: 'new', fsm: fsmActor, remoteDescSet: false };
|
|
205
265
|
this.peers.set(peerPubkey, peer);
|
|
206
266
|
const pc = this.createPeerConnection({ iceServers: this.iceServers, bundlePolicy: 'max-bundle', iceCandidatePoolSize: 4, iceTransportPolicy: 'all' });
|
|
207
267
|
peer.pc = pc;
|
|
@@ -237,13 +297,21 @@ export class DataSession extends EventTarget {
|
|
|
237
297
|
if (pc.connectionState === 'closed') this._closePeer(peerPubkey);
|
|
238
298
|
};
|
|
239
299
|
if (isOfferer) {
|
|
300
|
+
// Both data channels MUST be created before createOffer() below — a
|
|
301
|
+
// channel created after the offer is negotiated is never included in
|
|
302
|
+
// that offer's SDP, so the answerer would never see it.
|
|
240
303
|
try {
|
|
241
304
|
const dc = pc.createDataChannel(DC_LABEL, this.dcOptions);
|
|
242
|
-
peer.dc = dc; this._wireDataChannel(dc, peer, peerPubkey);
|
|
305
|
+
peer.dc = dc; this._wireDataChannel(dc, peer, peerPubkey, false);
|
|
306
|
+
} catch {}
|
|
307
|
+
try {
|
|
308
|
+
const dcU = pc.createDataChannel(DC_LABEL_UNRELIABLE, UNRELIABLE_DC_OPTIONS);
|
|
309
|
+
peer.dcUnreliable = dcU; this._wireDataChannel(dcU, peer, peerPubkey, true);
|
|
243
310
|
} catch {}
|
|
244
311
|
} else {
|
|
245
312
|
pc.ondatachannel = (ev) => {
|
|
246
|
-
if (ev.channel.label === DC_LABEL) { peer.dc = ev.channel; this._wireDataChannel(peer.dc, peer, peerPubkey); }
|
|
313
|
+
if (ev.channel.label === DC_LABEL) { peer.dc = ev.channel; this._wireDataChannel(peer.dc, peer, peerPubkey, false); }
|
|
314
|
+
else if (ev.channel.label === DC_LABEL_UNRELIABLE) { peer.dcUnreliable = ev.channel; this._wireDataChannel(peer.dcUnreliable, peer, peerPubkey, true); }
|
|
247
315
|
};
|
|
248
316
|
}
|
|
249
317
|
if (isOfferer) {
|
|
@@ -252,12 +320,28 @@ export class DataSession extends EventTarget {
|
|
|
252
320
|
}
|
|
253
321
|
}
|
|
254
322
|
|
|
255
|
-
_wireDataChannel(dc, peer, peerPubkey) {
|
|
323
|
+
_wireDataChannel(dc, peer, peerPubkey, unreliable) {
|
|
256
324
|
dc.binaryType = 'arraybuffer';
|
|
257
|
-
dc.onopen = () => this._emit('peer-open', { peerPubkey });
|
|
258
|
-
dc.onclose = () => this._emit('peer-close', { peerPubkey });
|
|
259
|
-
dc.
|
|
260
|
-
|
|
325
|
+
dc.onopen = () => this._emit('peer-open', { peerPubkey, unreliable });
|
|
326
|
+
dc.onclose = () => this._emit('peer-close', { peerPubkey, unreliable });
|
|
327
|
+
dc.onerror = (e) => this._emit('peer-error', { peerPubkey, unreliable, error: e });
|
|
328
|
+
if (!unreliable) {
|
|
329
|
+
dc.onmessage = (e) => this._emit('data', { peerPubkey, data: e.data, unreliable: false });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Unreliable channel carries MTU-framed fragments (see frame.js) — feed
|
|
333
|
+
// each incoming fragment into this peer's Reassembler and only emit
|
|
334
|
+
// 'data' once a full message is reassembled. A permanently-dropped
|
|
335
|
+
// fragment (real risk on {ordered:false, maxRetransmits:0}) never
|
|
336
|
+
// blocks delivery of the next message; its buffer is evicted by the
|
|
337
|
+
// Reassembler's own staleMs sweep, run lazily on every feed().
|
|
338
|
+
dc.onmessage = (e) => {
|
|
339
|
+
let payload;
|
|
340
|
+
try { payload = e.data instanceof ArrayBuffer ? e.data : (e.data?.buffer ?? e.data); } catch { return; }
|
|
341
|
+
let out;
|
|
342
|
+
try { out = peer.reassembler.feed(payload); } catch { return; }
|
|
343
|
+
if (out) this._emit('data', { peerPubkey, data: out.buffer.byteLength === out.byteLength ? out.buffer : out.slice().buffer, unreliable: true });
|
|
344
|
+
};
|
|
261
345
|
}
|
|
262
346
|
|
|
263
347
|
_doIceRestart(peer, peerPubkey, fsmActor) {
|
|
@@ -318,6 +402,7 @@ export class DataSession extends EventTarget {
|
|
|
318
402
|
if (peer.iceTimer) clearTimeout(peer.iceTimer);
|
|
319
403
|
if (peer.disconnectTimer) clearTimeout(peer.disconnectTimer);
|
|
320
404
|
try { peer.dc?.close(); } catch {}
|
|
405
|
+
try { peer.dcUnreliable?.close(); } catch {}
|
|
321
406
|
try { peer.pc?.close(); } catch {}
|
|
322
407
|
this.peers.delete(peerPubkey);
|
|
323
408
|
this._emit('peer-closed', { peerPubkey });
|
|
@@ -343,6 +428,8 @@ export class DataSession extends EventTarget {
|
|
|
343
428
|
fsmState: peer.fsm?.getSnapshot().value,
|
|
344
429
|
connState: peer.pc?.connectionState,
|
|
345
430
|
dcState: peer.dc?.readyState || null,
|
|
431
|
+
dcUnreliableState: peer.dcUnreliable?.readyState || null,
|
|
432
|
+
pendingFragmentSets: peer.reassembler?.pendingCount() ?? 0,
|
|
346
433
|
candidates: peer.pendingCandidates.length,
|
|
347
434
|
buffered: peer.bufferedCandidates.length
|
|
348
435
|
});
|
package/src/dm.js
CHANGED
|
@@ -1,41 +1,53 @@
|
|
|
1
|
+
const GIFT_WRAP_KIND = 1059;
|
|
2
|
+
|
|
1
3
|
export class DM extends EventTarget {
|
|
2
4
|
constructor({ relayPool, auth, nostrTools }) {
|
|
3
5
|
super();
|
|
4
6
|
if (!relayPool || !auth || !nostrTools) throw new Error('DM: deps required');
|
|
5
7
|
if (!nostrTools.nip44) throw new Error('nostr-tools nip44 missing');
|
|
8
|
+
if (!nostrTools.nip59) throw new Error('nostr-tools nip59 missing');
|
|
6
9
|
this.pool = relayPool;
|
|
7
10
|
this.auth = auth;
|
|
8
11
|
this.NT = nostrTools;
|
|
9
12
|
this.subId = null;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
// NIP-17: send is a real kind:14 rumor, gift-wrapped (NIP-59) once per
|
|
16
|
+
// recipient and once more for ourselves (self-copy), each wrap signed by
|
|
17
|
+
// a fresh single-use random key so no relay observer can attribute the
|
|
18
|
+
// wrap's outer envelope to either the sender or the recipient — only the
|
|
19
|
+
// holder of the recipient's (or our own) private key can even see it's a
|
|
20
|
+
// DM at all, let alone read it or learn who sent it.
|
|
19
21
|
async send(peerPubkey, plaintext) {
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const signed = await this.auth.sign({
|
|
22
|
+
if (!this.auth.privkey) throw new Error('DM: privkey required (extension signing not supported for nip17)');
|
|
23
|
+
const rumor = {
|
|
23
24
|
kind: 14,
|
|
24
25
|
created_at: Math.floor(Date.now() / 1000),
|
|
25
26
|
tags: [['p', peerPubkey]],
|
|
26
|
-
content:
|
|
27
|
-
}
|
|
28
|
-
this.
|
|
29
|
-
|
|
27
|
+
content: plaintext
|
|
28
|
+
};
|
|
29
|
+
const wrapForPeer = this.NT.nip59.wrapEvent(rumor, this.auth.privkey, peerPubkey);
|
|
30
|
+
const wrapForSelf = this.NT.nip59.wrapEvent(rumor, this.auth.privkey, this.auth.pubkey);
|
|
31
|
+
this.pool.publish(wrapForPeer);
|
|
32
|
+
this.pool.publish(wrapForSelf);
|
|
33
|
+
return wrapForPeer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Unwrap a gift-wrap (kind 1059) down to its rumor and return the
|
|
37
|
+
// plaintext. Requires our own privkey — only the wrap's addressed
|
|
38
|
+
// recipient (the 'p' tag on the outer event) can unwrap it.
|
|
39
|
+
decrypt(wrap) {
|
|
40
|
+
if (!this.auth.privkey) throw new Error('DM: privkey required (extension signing not supported for nip17)');
|
|
41
|
+
const rumor = this.NT.nip59.unwrapEvent(wrap, this.auth.privkey);
|
|
42
|
+
return rumor.content;
|
|
30
43
|
}
|
|
31
44
|
|
|
32
|
-
decrypt(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (!
|
|
37
|
-
|
|
38
|
-
return this.NT.nip44.v2.decrypt(event.content, key);
|
|
45
|
+
// Same as decrypt() but returns the full unwrapped rumor (sender pubkey,
|
|
46
|
+
// created_at, tags) alongside the plaintext, for callers that need the
|
|
47
|
+
// real (rumor-level) sender identity rather than the wrap's throwaway key.
|
|
48
|
+
unwrap(wrap) {
|
|
49
|
+
if (!this.auth.privkey) throw new Error('DM: privkey required (extension signing not supported for nip17)');
|
|
50
|
+
return this.NT.nip59.unwrapEvent(wrap, this.auth.privkey);
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
subscribe(onMessage) {
|
|
@@ -43,14 +55,14 @@ export class DM extends EventTarget {
|
|
|
43
55
|
const subId = 'dm-' + this.auth.pubkey.slice(0, 16);
|
|
44
56
|
this.subId = subId;
|
|
45
57
|
this.pool.subscribe(subId, [
|
|
46
|
-
{ kinds: [
|
|
47
|
-
{ kinds: [14], authors: [this.auth.pubkey] }
|
|
58
|
+
{ kinds: [GIFT_WRAP_KIND], '#p': [this.auth.pubkey] }
|
|
48
59
|
], (event) => {
|
|
49
60
|
try {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
? (
|
|
53
|
-
:
|
|
61
|
+
const rumor = this.unwrap(event);
|
|
62
|
+
const peer = rumor.pubkey === this.auth.pubkey
|
|
63
|
+
? (rumor.tags.find(t => t[0] === 'p')?.[1] || '')
|
|
64
|
+
: rumor.pubkey;
|
|
65
|
+
onMessage({ event, rumor, plaintext: rumor.content, peer });
|
|
54
66
|
} catch (e) {
|
|
55
67
|
this._emit('error', { event, error: e.message });
|
|
56
68
|
}
|
package/src/dtag.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const NAMESPACES = new Set(['ban', 'timeout', 'kick', 'page', 'channels', 'roles', 'settings']);
|
|
1
|
+
const NAMESPACES = new Set(['ban', 'timeout', 'kick', 'page', 'channels', 'roles', 'settings', 'unban', 'mute']);
|
|
2
2
|
|
|
3
3
|
// Frozen on-relay wire prefix. The brand is now wireweave but deployed events
|
|
4
4
|
// carry 'zellous-' d-tags, so this is a published-contract constant — do not
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// A minimal, REAL NIP-01-speaking nostr relay, meant to be spun up
|
|
2
|
+
// in-process for deterministic tests independent of public relay uptime
|
|
3
|
+
// (AGENTS.md's testRelay() depends on real public relays specifically to
|
|
4
|
+
// mask single-relay flake — this is the complementary piece: a real relay
|
|
5
|
+
// process test.js itself controls, for assertions that need to NOT flake
|
|
6
|
+
// on a third party's infrastructure). This is not a mock: it's a real
|
|
7
|
+
// `ws` WebSocket server that actually parses/validates/stores/relays
|
|
8
|
+
// real signed nostr events per the NIP-01 wire protocol (EVENT/REQ/CLOSE/
|
|
9
|
+
// EOSE/OK/NOTICE), matching the repo's real-services-only test discipline
|
|
10
|
+
// (an ephemeral relay is a real relay, just short-lived and unpersisted).
|
|
11
|
+
//
|
|
12
|
+
// Deliberately minimal: in-memory event store (no expiry/persistence
|
|
13
|
+
// needed for a test run), naive filter matching (kinds/authors/#tag/since/
|
|
14
|
+
// until/limit — the filter shapes wireweave's own RelayPool actually
|
|
15
|
+
// sends), and no NIP-11 relay-info document. Not meant for production use.
|
|
16
|
+
|
|
17
|
+
export class EphemeralRelay {
|
|
18
|
+
constructor({ WebSocketServer, verifyEvent = null, port = 0 } = {}) {
|
|
19
|
+
if (!WebSocketServer) throw new Error('EphemeralRelay: WebSocketServer required (e.g. ws\'s WebSocketServer)');
|
|
20
|
+
this.verifyEvent = verifyEvent;
|
|
21
|
+
this.events = [];
|
|
22
|
+
this.clients = new Map(); // ws -> Map<subId, filters[]>
|
|
23
|
+
this.wss = new WebSocketServer({ port });
|
|
24
|
+
this.wss.on('connection', (ws) => this._onConnection(ws));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get port() { return this.wss.address()?.port; }
|
|
28
|
+
get url() { return 'ws://127.0.0.1:' + this.port; }
|
|
29
|
+
|
|
30
|
+
_onConnection(ws) {
|
|
31
|
+
this.clients.set(ws, new Map());
|
|
32
|
+
ws.on('message', (raw) => {
|
|
33
|
+
let msg;
|
|
34
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
35
|
+
if (!Array.isArray(msg)) return;
|
|
36
|
+
const [type] = msg;
|
|
37
|
+
if (type === 'EVENT') this._handleEvent(ws, msg[1]);
|
|
38
|
+
else if (type === 'REQ') this._handleReq(ws, msg[1], msg.slice(2));
|
|
39
|
+
else if (type === 'CLOSE') this._handleClose(ws, msg[1]);
|
|
40
|
+
});
|
|
41
|
+
ws.on('close', () => this.clients.delete(ws));
|
|
42
|
+
ws.on('error', () => {});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_handleEvent(ws, event) {
|
|
46
|
+
if (!event?.id) return;
|
|
47
|
+
if (this.verifyEvent) {
|
|
48
|
+
let ok = false;
|
|
49
|
+
try { ok = this.verifyEvent(event); } catch { ok = false; }
|
|
50
|
+
if (!ok) { this._send(ws, ['OK', event.id, false, 'invalid: signature verification failed']); return; }
|
|
51
|
+
}
|
|
52
|
+
if (!this.events.find((e) => e.id === event.id)) {
|
|
53
|
+
this.events.push(event);
|
|
54
|
+
// Broadcast to every OTHER client's matching live subscriptions —
|
|
55
|
+
// real relay fan-out, not just an echo back to the publisher.
|
|
56
|
+
for (const [clientWs, subs] of this.clients) {
|
|
57
|
+
for (const [subId, filters] of subs) {
|
|
58
|
+
if (this._matches(event, filters)) this._send(clientWs, ['EVENT', subId, event]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
this._send(ws, ['OK', event.id, true, '']);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_handleReq(ws, subId, filters) {
|
|
66
|
+
if (!subId) return;
|
|
67
|
+
this.clients.get(ws)?.set(subId, filters);
|
|
68
|
+
const matched = this.events.filter((e) => this._matches(e, filters));
|
|
69
|
+
for (const e of matched) this._send(ws, ['EVENT', subId, e]);
|
|
70
|
+
this._send(ws, ['EOSE', subId]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_handleClose(ws, subId) {
|
|
74
|
+
this.clients.get(ws)?.delete(subId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_matches(event, filters) {
|
|
78
|
+
if (!Array.isArray(filters) || filters.length === 0) return true;
|
|
79
|
+
return filters.some((f) => this._matchesOne(event, f));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_matchesOne(event, f) {
|
|
83
|
+
if (f.ids && !f.ids.includes(event.id)) return false;
|
|
84
|
+
if (f.authors && !f.authors.includes(event.pubkey)) return false;
|
|
85
|
+
if (f.kinds && !f.kinds.includes(event.kind)) return false;
|
|
86
|
+
if (f.since != null && event.created_at < f.since) return false;
|
|
87
|
+
if (f.until != null && event.created_at > f.until) return false;
|
|
88
|
+
for (const key of Object.keys(f)) {
|
|
89
|
+
if (!key.startsWith('#')) continue;
|
|
90
|
+
const tagName = key.slice(1);
|
|
91
|
+
const wanted = f[key];
|
|
92
|
+
const has = (event.tags || []).some((t) => t[0] === tagName && wanted.includes(t[1]));
|
|
93
|
+
if (!has) return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_send(ws, msg) {
|
|
99
|
+
if (ws.readyState === 1) { try { ws.send(JSON.stringify(msg)); } catch {} }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
close() {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
for (const ws of this.clients.keys()) { try { ws.close(); } catch {} }
|
|
105
|
+
this.clients.clear();
|
|
106
|
+
this.wss.close(() => resolve());
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const createEphemeralRelay = (opts) => new EphemeralRelay(opts);
|
package/src/frame.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// MTU-aware binary framing for unreliable/unordered WebRTC data channels.
|
|
2
|
+
//
|
|
3
|
+
// A real RTCDataChannel message has a practical ~16KB limit before browsers
|
|
4
|
+
// start doing their own internal SCTP fragmentation with inconsistent
|
|
5
|
+
// cross-browser behavior at larger sizes — see MTU_DEFAULT below. Large
|
|
6
|
+
// binary payloads (game snapshots, file chunks) are split here into
|
|
7
|
+
// framed fragments that each fit under a configurable MTU, sent
|
|
8
|
+
// independently over an unordered/unreliable channel, and reassembled by
|
|
9
|
+
// (peer, messageId) on the receiving side.
|
|
10
|
+
//
|
|
11
|
+
// Wire format per fragment (fixed 11-byte header, little-endian):
|
|
12
|
+
// byte 0 : version/magic (0xF7)
|
|
13
|
+
// bytes 1-2 : messageId (uint16)
|
|
14
|
+
// bytes 3-4 : fragmentIndex (uint16, 0-based)
|
|
15
|
+
// bytes 5-6 : fragmentCount (uint16, total fragments for this message)
|
|
16
|
+
// bytes 7-10 : totalPayloadLength (uint32, full reassembled byte length)
|
|
17
|
+
// bytes 11+ : fragment payload bytes
|
|
18
|
+
//
|
|
19
|
+
// fragmentCount is a uint16, so a message can have at most 65535 fragments —
|
|
20
|
+
// maxPayloadBytes() below enforces that bound at fragmentation time rather
|
|
21
|
+
// than silently overflowing the header field.
|
|
22
|
+
|
|
23
|
+
const MAGIC = 0xf7;
|
|
24
|
+
const HEADER_BYTES = 11;
|
|
25
|
+
|
|
26
|
+
// ~16KB is the safe practical default across current browsers before
|
|
27
|
+
// RTCDataChannel's own internal fragmentation gets inconsistent
|
|
28
|
+
// cross-browser (Chrome and Firefox both technically allow larger single
|
|
29
|
+
// messages, but behavior at the edges has historically differed). Leave
|
|
30
|
+
// headroom under 16384 for the frame header itself and any SCTP/DTLS
|
|
31
|
+
// overhead the transport adds on top.
|
|
32
|
+
export const MTU_DEFAULT = 16000;
|
|
33
|
+
|
|
34
|
+
// fragmentCount is a uint16 field — this is a hard wire-format ceiling, not
|
|
35
|
+
// a tunable. A caller handing over a payload requiring more fragments than
|
|
36
|
+
// this at the configured MTU gets a clear error instead of a silently
|
|
37
|
+
// wrapped/truncated header field.
|
|
38
|
+
export const MAX_FRAGMENTS = 0xffff;
|
|
39
|
+
|
|
40
|
+
export const maxPayloadBytes = (mtu = MTU_DEFAULT) => {
|
|
41
|
+
const perFragment = mtu - HEADER_BYTES;
|
|
42
|
+
return perFragment * MAX_FRAGMENTS;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const toUint8 = (payload) => {
|
|
46
|
+
if (payload instanceof Uint8Array) return payload;
|
|
47
|
+
if (payload instanceof ArrayBuffer) return new Uint8Array(payload);
|
|
48
|
+
if (ArrayBuffer.isView(payload)) return new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
49
|
+
throw new Error('frame: payload must be an ArrayBuffer or a TypedArray');
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const encodeHeader = ({ messageId, fragmentIndex, fragmentCount, totalPayloadLength }) => {
|
|
53
|
+
const buf = new ArrayBuffer(HEADER_BYTES);
|
|
54
|
+
const dv = new DataView(buf);
|
|
55
|
+
dv.setUint8(0, MAGIC);
|
|
56
|
+
dv.setUint16(1, messageId, true);
|
|
57
|
+
dv.setUint16(3, fragmentIndex, true);
|
|
58
|
+
dv.setUint16(5, fragmentCount, true);
|
|
59
|
+
dv.setUint32(7, totalPayloadLength, true);
|
|
60
|
+
return buf;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Returns null (not a throw) on a header that doesn't parse as this frame
|
|
64
|
+
// format — the caller decides whether that's a protocol violation or just
|
|
65
|
+
// stray traffic on the same channel to ignore.
|
|
66
|
+
export const decodeHeader = (bytes) => {
|
|
67
|
+
const u8 = toUint8(bytes);
|
|
68
|
+
if (u8.byteLength < HEADER_BYTES) return null;
|
|
69
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
70
|
+
if (dv.getUint8(0) !== MAGIC) return null;
|
|
71
|
+
return {
|
|
72
|
+
messageId: dv.getUint16(1, true),
|
|
73
|
+
fragmentIndex: dv.getUint16(3, true),
|
|
74
|
+
fragmentCount: dv.getUint16(5, true),
|
|
75
|
+
totalPayloadLength: dv.getUint32(7, true)
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Splits `payload` into an array of ArrayBuffers, each a complete wire
|
|
80
|
+
// fragment (header + slice), ready to hand to dc.send() one at a time.
|
|
81
|
+
// A zero-length payload still produces exactly one (header-only) fragment,
|
|
82
|
+
// so fragmentCount is always >= 1 and the reassembler never waits forever
|
|
83
|
+
// on a message that legitimately has zero fragments.
|
|
84
|
+
export const fragment = (payload, { messageId, mtu = MTU_DEFAULT }) => {
|
|
85
|
+
if (mtu <= HEADER_BYTES) throw new Error('frame: mtu must be greater than the ' + HEADER_BYTES + '-byte header');
|
|
86
|
+
const u8 = toUint8(payload);
|
|
87
|
+
const totalPayloadLength = u8.byteLength;
|
|
88
|
+
const perFragment = mtu - HEADER_BYTES;
|
|
89
|
+
const fragmentCount = Math.max(1, Math.ceil(totalPayloadLength / perFragment));
|
|
90
|
+
if (fragmentCount > MAX_FRAGMENTS) {
|
|
91
|
+
throw new Error('frame: payload of ' + totalPayloadLength + ' bytes needs ' + fragmentCount +
|
|
92
|
+
' fragments at mtu=' + mtu + ', exceeding the ' + MAX_FRAGMENTS + '-fragment wire-format ceiling ' +
|
|
93
|
+
'(max ' + maxPayloadBytes(mtu) + ' bytes at this mtu)');
|
|
94
|
+
}
|
|
95
|
+
const out = new Array(fragmentCount);
|
|
96
|
+
for (let i = 0; i < fragmentCount; i++) {
|
|
97
|
+
const start = i * perFragment;
|
|
98
|
+
const end = Math.min(start + perFragment, totalPayloadLength);
|
|
99
|
+
const slice = u8.subarray(start, end);
|
|
100
|
+
const frameBuf = new ArrayBuffer(HEADER_BYTES + slice.byteLength);
|
|
101
|
+
new Uint8Array(frameBuf, 0, HEADER_BYTES).set(new Uint8Array(encodeHeader({
|
|
102
|
+
messageId, fragmentIndex: i, fragmentCount, totalPayloadLength
|
|
103
|
+
})));
|
|
104
|
+
new Uint8Array(frameBuf, HEADER_BYTES).set(slice);
|
|
105
|
+
out[i] = frameBuf;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const DEFAULT_STALE_MS = 10000;
|
|
111
|
+
const DEFAULT_MAX_IN_FLIGHT = 256;
|
|
112
|
+
|
|
113
|
+
// Reassembles fragments arriving in ANY order (unordered/unreliable channel)
|
|
114
|
+
// keyed by messageId. Call feed() per incoming fragment; it returns the
|
|
115
|
+
// reassembled Uint8Array once every fragment for that messageId has
|
|
116
|
+
// arrived, or null while still incomplete. Incomplete sets older than
|
|
117
|
+
// staleMs are evicted so a permanently-dropped fragment (real risk on an
|
|
118
|
+
// unreliable channel) never leaks memory forever. A hard cap on concurrent
|
|
119
|
+
// in-flight messageIds (evict-oldest) bounds memory even against a
|
|
120
|
+
// misbehaving/adversarial sender that never completes a message.
|
|
121
|
+
export class Reassembler {
|
|
122
|
+
constructor({ staleMs = DEFAULT_STALE_MS, maxInFlight = DEFAULT_MAX_IN_FLIGHT } = {}) {
|
|
123
|
+
this.staleMs = staleMs;
|
|
124
|
+
this.maxInFlight = maxInFlight;
|
|
125
|
+
this.sets = new Map(); // messageId -> { fragmentCount, totalPayloadLength, parts: Map<index, Uint8Array>, received, lastSeen }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Sweeps stale incomplete sets. Called lazily on every feed() so no timer
|
|
129
|
+
// is required, but can also be called on an interval by a host that wants
|
|
130
|
+
// eviction to happen even when no new traffic arrives.
|
|
131
|
+
sweep(now = Date.now()) {
|
|
132
|
+
let evicted = 0;
|
|
133
|
+
for (const [id, set] of this.sets) {
|
|
134
|
+
if (now - set.lastSeen > this.staleMs) { this.sets.delete(id); evicted++; }
|
|
135
|
+
}
|
|
136
|
+
return evicted;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_evictOldestIfOverCap() {
|
|
140
|
+
if (this.sets.size <= this.maxInFlight) return;
|
|
141
|
+
let oldestId = null, oldestTs = Infinity;
|
|
142
|
+
for (const [id, set] of this.sets) {
|
|
143
|
+
if (set.lastSeen < oldestTs) { oldestTs = set.lastSeen; oldestId = id; }
|
|
144
|
+
}
|
|
145
|
+
if (oldestId !== null) this.sets.delete(oldestId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Returns the reassembled Uint8Array once complete, else null. Duplicate
|
|
149
|
+
// fragment delivery (real risk on an unreliable channel that retransmits,
|
|
150
|
+
// or an app-level resend) is idempotent — re-feeding an already-seen
|
|
151
|
+
// (messageId, fragmentIndex) pair does not double-count toward
|
|
152
|
+
// completion. Malformed/out-of-range headers are dropped, not thrown.
|
|
153
|
+
feed(rawFragment) {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
this.sweep(now);
|
|
156
|
+
const u8 = toUint8(rawFragment);
|
|
157
|
+
const header = decodeHeader(u8);
|
|
158
|
+
if (!header) return null;
|
|
159
|
+
const { messageId, fragmentIndex, fragmentCount, totalPayloadLength } = header;
|
|
160
|
+
if (fragmentCount < 1 || fragmentIndex >= fragmentCount) return null;
|
|
161
|
+
|
|
162
|
+
let set = this.sets.get(messageId);
|
|
163
|
+
if (!set) {
|
|
164
|
+
set = { fragmentCount, totalPayloadLength, parts: new Map(), received: 0, lastSeen: now };
|
|
165
|
+
this.sets.set(messageId, set);
|
|
166
|
+
this._evictOldestIfOverCap();
|
|
167
|
+
} else if (set.fragmentCount !== fragmentCount || set.totalPayloadLength !== totalPayloadLength) {
|
|
168
|
+
// Header disagrees with the in-flight set for this messageId (e.g. a
|
|
169
|
+
// wrapped-around id reused before the old set went stale) — start
|
|
170
|
+
// fresh rather than corrupt the old set's reassembly.
|
|
171
|
+
set = { fragmentCount, totalPayloadLength, parts: new Map(), received: 0, lastSeen: now };
|
|
172
|
+
this.sets.set(messageId, set);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
set.lastSeen = now;
|
|
176
|
+
if (!set.parts.has(fragmentIndex)) {
|
|
177
|
+
set.parts.set(fragmentIndex, u8.subarray(HEADER_BYTES));
|
|
178
|
+
set.received++;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (set.received < set.fragmentCount) return null;
|
|
182
|
+
|
|
183
|
+
const out = new Uint8Array(set.totalPayloadLength);
|
|
184
|
+
let offset = 0;
|
|
185
|
+
for (let i = 0; i < set.fragmentCount; i++) {
|
|
186
|
+
const part = set.parts.get(i);
|
|
187
|
+
out.set(part, offset);
|
|
188
|
+
offset += part.byteLength;
|
|
189
|
+
}
|
|
190
|
+
this.sets.delete(messageId);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Introspection for debugging/verification — number of messageIds
|
|
195
|
+
// currently buffered incomplete, never permanently growing on a healthy
|
|
196
|
+
// sweep cadence.
|
|
197
|
+
pendingCount() { return this.sets.size; }
|
|
198
|
+
has(messageId) { return this.sets.has(messageId); }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const createReassembler = (opts) => new Reassembler(opts);
|
|
202
|
+
export { HEADER_BYTES };
|
package/src/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { RelayPool, createRelayPool, RelayHealth } from './relay-pool.js';
|
|
2
2
|
export { NostrAuth, createAuth } from './auth.js';
|
|
3
|
+
export { Profile, createProfile } from './profile.js';
|
|
3
4
|
export { createFSM } from './fsm.js';
|
|
4
5
|
export { VoiceSession, createVoiceSession } from './voice.js';
|
|
5
6
|
export { DataSession, createDataSession } from './data.js';
|
|
7
|
+
export { fragment, encodeHeader, decodeHeader, Reassembler, createReassembler, MTU_DEFAULT, MAX_FRAGMENTS, maxPayloadBytes } from './frame.js';
|
|
6
8
|
export { Chat, createChat } from './chat.js';
|
|
7
9
|
export { Channels, createChannels } from './channels.js';
|
|
8
10
|
export { Servers, createServers } from './servers.js';
|
package/src/message.js
CHANGED
|
@@ -1,22 +1,149 @@
|
|
|
1
|
+
import { safeSetItem } from './safe-storage.js';
|
|
2
|
+
|
|
3
|
+
const STORE_KEY_PREFIX = 'ww_msgbus_';
|
|
4
|
+
const OUTBOX_KEY_PREFIX = 'ww_msgbus_outbox_';
|
|
5
|
+
const PERSIST_DEBOUNCE_MS = 500;
|
|
6
|
+
|
|
7
|
+
// Offline-first: MessageBus was purely in-memory (this.messages array,
|
|
8
|
+
// capped, gone on reload). storage (any localStorage/IndexedDB-shaped
|
|
9
|
+
// getItem/setItem/removeItem sync API — matches the same duck-typed
|
|
10
|
+
// contract safe-storage.js/RelayPool's health persistence already use)
|
|
11
|
+
// makes the message list survive a reload, keyed by `roomKey` so multiple
|
|
12
|
+
// rooms/channels don't collide. sendFn is the actual network send (e.g.
|
|
13
|
+
// pool.publish / a Chat.send wrapper) — add() calls it immediately if
|
|
14
|
+
// online, or queues into a persisted outbox if offline/sendFn throws/isn't
|
|
15
|
+
// connected, flushed automatically once flushOutbox() is called (e.g. on a
|
|
16
|
+
// relay 'relay-status':'connected' event from the caller).
|
|
1
17
|
export class MessageBus extends EventTarget {
|
|
2
|
-
constructor({ maxMessages = 50 } = {}) {
|
|
18
|
+
constructor({ maxMessages = 50, storage = null, roomKey = 'default', sendFn = null, isOnline = () => true } = {}) {
|
|
3
19
|
super();
|
|
4
20
|
this.max = maxMessages;
|
|
5
21
|
this.messages = [];
|
|
6
22
|
this.handlers = {};
|
|
23
|
+
this.storage = storage;
|
|
24
|
+
this.roomKey = roomKey;
|
|
25
|
+
this.sendFn = sendFn;
|
|
26
|
+
this.isOnline = isOnline;
|
|
27
|
+
this.outbox = [];
|
|
28
|
+
this._persistTimer = null;
|
|
29
|
+
this._loadPersisted();
|
|
30
|
+
this._loadOutbox();
|
|
7
31
|
}
|
|
8
32
|
|
|
9
33
|
handle(m) { this.handlers[m.type]?.(m); }
|
|
10
34
|
register(type, fn) { this.handlers[type] = fn; }
|
|
11
35
|
|
|
36
|
+
// Adds a message locally (always, immediately — the local view is never
|
|
37
|
+
// blocked on network state) and, if a sendFn is configured, attempts the
|
|
38
|
+
// real send. Offline (isOnline() false) or a throwing/failing sendFn
|
|
39
|
+
// routes the message into a persisted outbox instead of losing it.
|
|
12
40
|
add(text, { audioData = null, userId = null, username = null } = {}) {
|
|
13
|
-
const msg = { id: Date.now().toString(36) + Math.random().toString(36).slice(2), text, time: Date.now(), userId, username, audioData };
|
|
41
|
+
const msg = { id: Date.now().toString(36) + Math.random().toString(36).slice(2), text, time: Date.now(), userId, username, audioData, pending: false };
|
|
42
|
+
if (this.sendFn) {
|
|
43
|
+
const online = this.isOnline();
|
|
44
|
+
if (!online) {
|
|
45
|
+
msg.pending = true;
|
|
46
|
+
this._queueOutbox(msg);
|
|
47
|
+
} else {
|
|
48
|
+
try {
|
|
49
|
+
const result = this.sendFn(msg);
|
|
50
|
+
if (result === false) { msg.pending = true; this._queueOutbox(msg); }
|
|
51
|
+
} catch {
|
|
52
|
+
msg.pending = true;
|
|
53
|
+
this._queueOutbox(msg);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
14
57
|
this.messages = [...this.messages, msg];
|
|
15
58
|
if (this.messages.length > this.max) this.messages = this.messages.slice(-this.max);
|
|
59
|
+
this._schedulePersist();
|
|
16
60
|
this.dispatchEvent(new CustomEvent('message', { detail: msg }));
|
|
17
61
|
this.dispatchEvent(new CustomEvent('messages', { detail: { list: this.messages } }));
|
|
18
62
|
return msg;
|
|
19
63
|
}
|
|
64
|
+
|
|
65
|
+
// Retries every outboxed (offline-queued) message through sendFn, in
|
|
66
|
+
// original order. A message that sends successfully this pass is marked
|
|
67
|
+
// pending:false and removed from the outbox; one that still fails stays
|
|
68
|
+
// queued for the next flush call. Call this when connectivity is
|
|
69
|
+
// restored (e.g. from a relay-pool 'relay-status' handler).
|
|
70
|
+
flushOutbox() {
|
|
71
|
+
if (!this.sendFn || this.outbox.length === 0) return { sent: 0, remaining: this.outbox.length };
|
|
72
|
+
const stillPending = [];
|
|
73
|
+
let sent = 0;
|
|
74
|
+
for (const msg of this.outbox) {
|
|
75
|
+
let ok = false;
|
|
76
|
+
try { ok = this.sendFn(msg) !== false; } catch { ok = false; }
|
|
77
|
+
if (ok) {
|
|
78
|
+
sent++;
|
|
79
|
+
const local = this.messages.find((m) => m.id === msg.id);
|
|
80
|
+
if (local) local.pending = false;
|
|
81
|
+
} else {
|
|
82
|
+
stillPending.push(msg);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.outbox = stillPending;
|
|
86
|
+
this._persistOutbox();
|
|
87
|
+
if (sent > 0) this.dispatchEvent(new CustomEvent('messages', { detail: { list: this.messages } }));
|
|
88
|
+
this.dispatchEvent(new CustomEvent('outbox-flushed', { detail: { sent, remaining: this.outbox.length } }));
|
|
89
|
+
return { sent, remaining: this.outbox.length };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getOutbox() { return this.outbox.slice(); }
|
|
93
|
+
|
|
94
|
+
_queueOutbox(msg) {
|
|
95
|
+
this.outbox.push(msg);
|
|
96
|
+
this._persistOutbox();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_schedulePersist() {
|
|
100
|
+
if (!this.storage) return;
|
|
101
|
+
if (this._persistTimer) return;
|
|
102
|
+
this._persistTimer = setTimeout(() => { this._persistTimer = null; this._persistNow(); }, PERSIST_DEBOUNCE_MS);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_persistNow() {
|
|
106
|
+
if (!this.storage) return;
|
|
107
|
+
safeSetItem(this.storage, this, STORE_KEY_PREFIX + this.roomKey, JSON.stringify(this.messages));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_persistOutbox() {
|
|
111
|
+
if (!this.storage) return;
|
|
112
|
+
safeSetItem(this.storage, this, OUTBOX_KEY_PREFIX + this.roomKey, JSON.stringify(this.outbox));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_loadPersisted() {
|
|
116
|
+
if (!this.storage) return;
|
|
117
|
+
try {
|
|
118
|
+
const raw = this.storage.getItem(STORE_KEY_PREFIX + this.roomKey);
|
|
119
|
+
if (!raw) return;
|
|
120
|
+
const parsed = JSON.parse(raw);
|
|
121
|
+
if (Array.isArray(parsed)) this.messages = parsed.slice(-this.max);
|
|
122
|
+
} catch { /* corrupt/absent persisted messages is non-fatal */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_loadOutbox() {
|
|
126
|
+
if (!this.storage) return;
|
|
127
|
+
try {
|
|
128
|
+
const raw = this.storage.getItem(OUTBOX_KEY_PREFIX + this.roomKey);
|
|
129
|
+
if (!raw) return;
|
|
130
|
+
const parsed = JSON.parse(raw);
|
|
131
|
+
if (Array.isArray(parsed)) this.outbox = parsed;
|
|
132
|
+
} catch { /* corrupt/absent persisted outbox is non-fatal */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Clears BOTH the in-memory list and any persisted copy — a caller
|
|
136
|
+
// switching rooms with a fresh MessageBus per room never needs this, but
|
|
137
|
+
// one reusing a single MessageBus across rooms (changing roomKey) does.
|
|
138
|
+
clear() {
|
|
139
|
+
this.messages = [];
|
|
140
|
+
this.outbox = [];
|
|
141
|
+
if (this.storage) {
|
|
142
|
+
try { this.storage.removeItem(STORE_KEY_PREFIX + this.roomKey); } catch {}
|
|
143
|
+
try { this.storage.removeItem(OUTBOX_KEY_PREFIX + this.roomKey); } catch {}
|
|
144
|
+
}
|
|
145
|
+
this.dispatchEvent(new CustomEvent('messages', { detail: { list: this.messages } }));
|
|
146
|
+
}
|
|
20
147
|
}
|
|
21
148
|
|
|
22
149
|
export const createMessageBus = (opts) => new MessageBus(opts);
|
package/src/profile.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Portable nostr identity/profiles: kind:0 (NIP-01 "set_metadata") is the
|
|
2
|
+
// real nostr-native replaceable event carrying name/picture/about/nip05 —
|
|
3
|
+
// publishing it here means an identity (bare pubkey today) becomes portable
|
|
4
|
+
// across ANY wireweave-based app (spoint etc), not just the app that first
|
|
5
|
+
// created it, since any relay-connected nostr client already knows how to
|
|
6
|
+
// read kind:0. chat.js already has a read-only per-Chat-instance
|
|
7
|
+
// _fetchProfile/resolveProfile cache for showing names in a chat UI; this
|
|
8
|
+
// module is the write path (publish your own profile) plus a
|
|
9
|
+
// relay-pool-agnostic fetch-by-pubkey a non-Chat caller can use, and real
|
|
10
|
+
// NIP-05 (`name@domain`) identifier verification via the domain's
|
|
11
|
+
// `/.well-known/nostr.json` per the NIP-05 spec.
|
|
12
|
+
|
|
13
|
+
const KIND_METADATA = 0;
|
|
14
|
+
const PROFILE_CACHE_TTL_MS = 300000;
|
|
15
|
+
|
|
16
|
+
export class Profile extends EventTarget {
|
|
17
|
+
constructor({ relayPool, auth, fetchImpl = null }) {
|
|
18
|
+
super();
|
|
19
|
+
if (!relayPool || !auth) throw new Error('Profile: relayPool + auth required');
|
|
20
|
+
this.pool = relayPool;
|
|
21
|
+
this.auth = auth;
|
|
22
|
+
this.fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
|
|
23
|
+
this.cache = new Map(); // pubkey -> { profile, fetchedAt, eventCreatedAt }
|
|
24
|
+
this.subs = new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Publishes (or replaces — kind:0 is a NIP-01 replaceable event, the relay
|
|
28
|
+
// keeps only the newest per-pubkey copy) your own profile metadata.
|
|
29
|
+
// `fields` is shallow-merged onto whatever's already cached for yourself
|
|
30
|
+
// so a caller can update just one field (e.g. only `picture`) without
|
|
31
|
+
// clobbering the rest.
|
|
32
|
+
async publish(fields) {
|
|
33
|
+
if (!this.auth.isLoggedIn()) throw new Error('Profile: not logged in');
|
|
34
|
+
const existing = this.cache.get(this.auth.pubkey)?.profile || {};
|
|
35
|
+
const next = { ...existing, ...fields };
|
|
36
|
+
const signed = await this.auth.sign({
|
|
37
|
+
kind: KIND_METADATA,
|
|
38
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
39
|
+
tags: [],
|
|
40
|
+
content: JSON.stringify(next)
|
|
41
|
+
});
|
|
42
|
+
this.pool.publish(signed);
|
|
43
|
+
this.cache.set(this.auth.pubkey, { profile: next, fetchedAt: Date.now(), eventCreatedAt: signed.created_at });
|
|
44
|
+
this._emit('updated', { pubkey: this.auth.pubkey, profile: next });
|
|
45
|
+
return signed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// One-shot fetch (resolves once, EOSE-driven, with a timeout) — for a
|
|
49
|
+
// caller that just needs "the current profile for this pubkey" without
|
|
50
|
+
// wanting a standing subscription. Serves from cache if fresh
|
|
51
|
+
// (PROFILE_CACHE_TTL_MS) unless forceRefresh is set.
|
|
52
|
+
async fetchOnce(pubkey, { timeoutMs = 8000, forceRefresh = false } = {}) {
|
|
53
|
+
const cached = this.cache.get(pubkey);
|
|
54
|
+
if (cached && !forceRefresh && Date.now() - cached.fetchedAt < PROFILE_CACHE_TTL_MS) return cached.profile;
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const subId = 'profile-once-' + pubkey.slice(0, 16) + '-' + Math.random().toString(36).slice(2, 8);
|
|
57
|
+
let settled = false;
|
|
58
|
+
const timer = setTimeout(() => { if (!settled) { settled = true; this.pool.unsubscribe(subId); resolve(cached?.profile ?? null); } }, timeoutMs);
|
|
59
|
+
let best = null;
|
|
60
|
+
this.pool.subscribe(subId,
|
|
61
|
+
[{ kinds: [KIND_METADATA], authors: [pubkey] }],
|
|
62
|
+
(event) => {
|
|
63
|
+
if (best && best.created_at >= event.created_at) return;
|
|
64
|
+
try { best = { created_at: event.created_at, profile: JSON.parse(event.content) }; } catch {}
|
|
65
|
+
},
|
|
66
|
+
() => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
this.pool.unsubscribe(subId);
|
|
71
|
+
if (best) {
|
|
72
|
+
this.cache.set(pubkey, { profile: best.profile, fetchedAt: Date.now(), eventCreatedAt: best.created_at });
|
|
73
|
+
this._emit('updated', { pubkey, profile: best.profile });
|
|
74
|
+
}
|
|
75
|
+
resolve(best?.profile ?? cached?.profile ?? null);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Standing subscription — calls onUpdate(profile) every time a newer
|
|
81
|
+
// kind:0 for this pubkey arrives (profile changed live), same
|
|
82
|
+
// newest-wins dedupe chat.js's _fetchProfile already uses.
|
|
83
|
+
subscribe(pubkey, onUpdate) {
|
|
84
|
+
if (this.subs.has(pubkey)) return this.subs.get(pubkey);
|
|
85
|
+
const subId = 'profile-sub-' + pubkey.slice(0, 16);
|
|
86
|
+
this.subs.set(pubkey, subId);
|
|
87
|
+
this.pool.subscribe(subId,
|
|
88
|
+
[{ kinds: [KIND_METADATA], authors: [pubkey] }],
|
|
89
|
+
(event) => {
|
|
90
|
+
const cached = this.cache.get(pubkey);
|
|
91
|
+
if (cached && cached.eventCreatedAt >= event.created_at) return;
|
|
92
|
+
let profile;
|
|
93
|
+
try { profile = JSON.parse(event.content); } catch { return; }
|
|
94
|
+
this.cache.set(pubkey, { profile, fetchedAt: Date.now(), eventCreatedAt: event.created_at });
|
|
95
|
+
this._emit('updated', { pubkey, profile });
|
|
96
|
+
onUpdate?.(profile);
|
|
97
|
+
});
|
|
98
|
+
return subId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
unsubscribe(pubkey) {
|
|
102
|
+
const subId = this.subs.get(pubkey);
|
|
103
|
+
if (subId) { this.pool.unsubscribe(subId); this.subs.delete(pubkey); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getCached(pubkey) { return this.cache.get(pubkey)?.profile ?? null; }
|
|
107
|
+
|
|
108
|
+
// Real NIP-05 verification: fetches https://<domain>/.well-known/nostr.json
|
|
109
|
+
// and checks that names[localpart] resolves to the expected pubkey — per
|
|
110
|
+
// spec this is the ONLY trust anchor for a nip05 identifier claimed in a
|
|
111
|
+
// profile (a profile can put any string in its nip05 field; verifying it
|
|
112
|
+
// requires this real HTTP round-trip to the claimed domain, it is not
|
|
113
|
+
// something derivable from the nostr event alone). Returns false (never
|
|
114
|
+
// throws) on any network/parse/mismatch failure — an unverifiable nip05
|
|
115
|
+
// is a real, expected outcome, not an error.
|
|
116
|
+
async verifyNip05(identifier, expectedPubkey) {
|
|
117
|
+
if (!this.fetch) return false;
|
|
118
|
+
const match = /^(?:([\w.+-]+)@)?([\w.-]+)$/.exec((identifier || '').trim());
|
|
119
|
+
if (!match) return false;
|
|
120
|
+
const localpart = match[1] || '_';
|
|
121
|
+
const domain = match[2];
|
|
122
|
+
if (!domain) return false;
|
|
123
|
+
try {
|
|
124
|
+
const res = await this.fetch('https://' + domain + '/.well-known/nostr.json?name=' + encodeURIComponent(localpart));
|
|
125
|
+
if (!res.ok) return false;
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
const resolved = body?.names?.[localpart];
|
|
128
|
+
return typeof resolved === 'string' && resolved === expectedPubkey;
|
|
129
|
+
} catch { return false; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const createProfile = (opts) => new Profile(opts);
|
package/src/relay-pool.js
CHANGED
|
@@ -118,8 +118,55 @@ const fnv1a = (s) => {
|
|
|
118
118
|
|
|
119
119
|
const safeSubId = (subId) => subId.length <= 64 ? subId : subId.slice(0, 55) + '-' + fnv1a(subId);
|
|
120
120
|
|
|
121
|
+
// Shared publish budget: a real token bucket, refilled continuously at
|
|
122
|
+
// refillPerSec tokens/sec up to burstCap, drained one token per publish()
|
|
123
|
+
// call. This is the SINGLE choke point every module's writes go through
|
|
124
|
+
// (chat, dm, bans, roles, settings, servers, data-channel signaling all
|
|
125
|
+
// call pool.publish()) so one shared bucket per RelayPool instance budgets
|
|
126
|
+
// abuse across all of them at once, rather than each module needing (and
|
|
127
|
+
// forgetting) its own independent limiter — chat.js's own 5-per-10s limiter
|
|
128
|
+
// stays as an app-level UX throttle (rate-limited event + retryAfterMs for
|
|
129
|
+
// a chat input box), this is the lower-level protocol-wide backstop.
|
|
130
|
+
const DEFAULT_BUDGET_BURST = 30;
|
|
131
|
+
const DEFAULT_BUDGET_REFILL_PER_SEC = 3;
|
|
132
|
+
|
|
133
|
+
class PublishBudget {
|
|
134
|
+
constructor({ burstCap = DEFAULT_BUDGET_BURST, refillPerSec = DEFAULT_BUDGET_REFILL_PER_SEC } = {}) {
|
|
135
|
+
this.burstCap = burstCap;
|
|
136
|
+
this.refillPerSec = refillPerSec;
|
|
137
|
+
this.tokens = burstCap;
|
|
138
|
+
this.lastRefillAt = Date.now();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_refill() {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const elapsedSec = (now - this.lastRefillAt) / 1000;
|
|
144
|
+
if (elapsedSec <= 0) return;
|
|
145
|
+
this.tokens = Math.min(this.burstCap, this.tokens + elapsedSec * this.refillPerSec);
|
|
146
|
+
this.lastRefillAt = now;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Returns true and consumes a token if available, else false (caller
|
|
150
|
+
// decides what "budget exceeded" means — RelayPool.publish() below
|
|
151
|
+
// queues the event as pending rather than dropping it, since a
|
|
152
|
+
// rate-limited-not-lost event still eventually goes out once the bucket
|
|
153
|
+
// refills, via the same _drainPending path a disconnected relay uses).
|
|
154
|
+
tryConsume() {
|
|
155
|
+
this._refill();
|
|
156
|
+
if (this.tokens < 1) return false;
|
|
157
|
+
this.tokens -= 1;
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
retryAfterMs() {
|
|
162
|
+
this._refill();
|
|
163
|
+
if (this.tokens >= 1) return 0;
|
|
164
|
+
return Math.ceil((1 - this.tokens) / this.refillPerSec * 1000);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
121
168
|
export class RelayPool extends EventTarget {
|
|
122
|
-
constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null, storage = null, fallbackRelays = FALLBACK_RELAYS, autoRotate = true } = {}) {
|
|
169
|
+
constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null, storage = null, fallbackRelays = FALLBACK_RELAYS, autoRotate = true, publishBudget = {} } = {}) {
|
|
123
170
|
super();
|
|
124
171
|
this.urls = [...relays];
|
|
125
172
|
this.relays = new Map();
|
|
@@ -141,6 +188,10 @@ export class RelayPool extends EventTarget {
|
|
|
141
188
|
for (const url of this.urls) this.health.set(url, new RelayHealth(url));
|
|
142
189
|
this._loadHealth();
|
|
143
190
|
this._saveHealthTimer = null;
|
|
191
|
+
// publishBudget:false disables the budget entirely (unbounded, the old
|
|
192
|
+
// behavior) — anything else (including {}) gets a real token bucket.
|
|
193
|
+
this.budget = publishBudget === false ? null : new PublishBudget(publishBudget);
|
|
194
|
+
this._budgetDrainTimer = null;
|
|
144
195
|
|
|
145
196
|
// Debug-panel integration: window.__wireweave.relayPool (or relayPool2,
|
|
146
197
|
// relayPool3... for additional instances) exposes healthReport() live.
|
|
@@ -254,6 +305,7 @@ export class RelayPool extends EventTarget {
|
|
|
254
305
|
this._closed = true;
|
|
255
306
|
for (const [, t] of this._reconnectTimers) clearTimeout(t);
|
|
256
307
|
this._reconnectTimers.clear();
|
|
308
|
+
if (this._budgetDrainTimer) { clearTimeout(this._budgetDrainTimer); this._budgetDrainTimer = null; }
|
|
257
309
|
for (const [, rec] of this._acks) { clearTimeout(rec.timer); rec.resolve(false); }
|
|
258
310
|
this._acks.clear();
|
|
259
311
|
for (const [, r] of this.relays) {
|
|
@@ -401,7 +453,21 @@ export class RelayPool extends EventTarget {
|
|
|
401
453
|
this.subs.delete(subId);
|
|
402
454
|
}
|
|
403
455
|
|
|
456
|
+
// Budget-gated: over-budget calls are not dropped, they're queued exactly
|
|
457
|
+
// like a disconnected-relay event (via _queuePending) and flushed by the
|
|
458
|
+
// normal _drainPending path once either a relay reconnects or, for a
|
|
459
|
+
// budget-only rejection, the next successful publish()/heal() drains the
|
|
460
|
+
// backlog opportunistically. This means a caller that publishes faster
|
|
461
|
+
// than the budget allows sees eventual delivery, not silent loss — the
|
|
462
|
+
// same "fire-and-forget with delivery confidence via publishAndWait()"
|
|
463
|
+
// contract the rest of this class already documents.
|
|
404
464
|
publish(event) {
|
|
465
|
+
if (this.budget && !this.budget.tryConsume()) {
|
|
466
|
+
this._emit('rate-limited', { retryAfterMs: this.budget.retryAfterMs() });
|
|
467
|
+
this._queuePending(event, new Set());
|
|
468
|
+
this._scheduleBudgetDrain();
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
405
471
|
let sent = false;
|
|
406
472
|
let anyDisconnected = false;
|
|
407
473
|
const sentTo = new Set();
|
|
@@ -419,6 +485,38 @@ export class RelayPool extends EventTarget {
|
|
|
419
485
|
return sent;
|
|
420
486
|
}
|
|
421
487
|
|
|
488
|
+
// Live budget introspection for a debug panel / caller backoff decision.
|
|
489
|
+
budgetStatus() {
|
|
490
|
+
if (!this.budget) return { enabled: false };
|
|
491
|
+
this.budget._refill();
|
|
492
|
+
return { enabled: true, tokens: this.budget.tokens, burstCap: this.budget.burstCap, refillPerSec: this.budget.refillPerSec, retryAfterMs: this.budget.retryAfterMs() };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// A budget-rejected publish() queues into `this.pending` exactly like a
|
|
496
|
+
// disconnected-relay event, but `this.pending` otherwise only drains on
|
|
497
|
+
// ws.onopen (a relay reconnecting) — if every relay stays connected the
|
|
498
|
+
// whole time, a budget-queued event needs its OWN retry path once tokens
|
|
499
|
+
// refill, or it would sit queued until PENDING_TTL_MS expiry and get
|
|
500
|
+
// silently dropped despite the relay connection being perfectly healthy.
|
|
501
|
+
// One timer, scheduled only while budget-queued events are outstanding
|
|
502
|
+
// (never a standing interval), retries a real drain against every
|
|
503
|
+
// currently-open relay once the bucket should have refilled a token.
|
|
504
|
+
_scheduleBudgetDrain() {
|
|
505
|
+
if (this._budgetDrainTimer || !this.budget || this._closed) return;
|
|
506
|
+
const delay = Math.max(50, this.budget.retryAfterMs());
|
|
507
|
+
this._budgetDrainTimer = setTimeout(() => {
|
|
508
|
+
this._budgetDrainTimer = null;
|
|
509
|
+
if (this._closed) return;
|
|
510
|
+
let anyOpen = false;
|
|
511
|
+
for (const [url, relay] of this.relays) {
|
|
512
|
+
if (relay.ws?.readyState === 1) { anyOpen = true; this._drainPending(url, relay.ws); }
|
|
513
|
+
}
|
|
514
|
+
// Still budget-limited or nothing connected yet — keep retrying as
|
|
515
|
+
// long as there's a real backlog, so it isn't stranded until TTL.
|
|
516
|
+
if (this.pending.length > 0 && (anyOpen || this.budget.retryAfterMs() > 0)) this._scheduleBudgetDrain();
|
|
517
|
+
}, delay);
|
|
518
|
+
}
|
|
519
|
+
|
|
422
520
|
// Tracks delivery per relay URL, not just "sent to at least one" — a relay
|
|
423
521
|
// mid-reconnect during a partial outage otherwise never gets the event.
|
|
424
522
|
_queuePending(event, sentTo) {
|